From 04831abcf620db1f35a9c120e6e4a51546a9c2cb Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:36:06 +0200 Subject: [PATCH 01/96] feat: add pm-fee-comparison and pm-geographic-access harnesses --- benchmarks/pm-fee-comparison.yml | 70 +++++++-- benchmarks/pm-geographic-access.yml | 105 +++++++++++-- harnesses/pm-fee-comparison/Dockerfile | 22 +++ .../pm-fee-comparison/cmd/script/fees.go | 140 ++++++++++++++++++ .../pm-fee-comparison/cmd/script/main.go | 43 ++++++ .../pm-fee-comparison/cmd/script/metrics.go | 58 ++++++++ harnesses/pm-fee-comparison/go.mod | 17 +++ harnesses/pm-fee-comparison/go.sum | 24 +++ harnesses/pm-geographic-access/Dockerfile | 22 +++ .../pm-geographic-access/cmd/script/main.go | 72 +++++++++ .../cmd/script/metrics.go | 58 ++++++++ .../pm-geographic-access/cmd/script/probe.go | 98 ++++++++++++ harnesses/pm-geographic-access/go.mod | 17 +++ harnesses/pm-geographic-access/go.sum | 24 +++ 14 files changed, 748 insertions(+), 22 deletions(-) create mode 100644 harnesses/pm-fee-comparison/Dockerfile create mode 100644 harnesses/pm-fee-comparison/cmd/script/fees.go create mode 100644 harnesses/pm-fee-comparison/cmd/script/main.go create mode 100644 harnesses/pm-fee-comparison/cmd/script/metrics.go create mode 100644 harnesses/pm-fee-comparison/go.mod create mode 100644 harnesses/pm-fee-comparison/go.sum create mode 100644 harnesses/pm-geographic-access/Dockerfile create mode 100644 harnesses/pm-geographic-access/cmd/script/main.go create mode 100644 harnesses/pm-geographic-access/cmd/script/metrics.go create mode 100644 harnesses/pm-geographic-access/cmd/script/probe.go create mode 100644 harnesses/pm-geographic-access/go.mod create mode 100644 harnesses/pm-geographic-access/go.sum diff --git a/benchmarks/pm-fee-comparison.yml b/benchmarks/pm-fee-comparison.yml index 42b34b35..cc5aa26d 100644 --- a/benchmarks/pm-fee-comparison.yml +++ b/benchmarks/pm-fee-comparison.yml @@ -8,7 +8,7 @@ seo_description: "What does it cost to trade on Polymarket, Kalshi, Limitless an subtitle: Maker and taker fees, protocol cuts and net cost to trade across five prediction market venues, sourced from official fee schedules and verified from fills data. category: Trading -status: draft +status: live metric: Effective taker fee unit: pct higher_is_better: false @@ -44,31 +44,83 @@ disclaimer: "Fee schedules change without notice. Verify current fees directly w source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/pm-fee-comparison -faq: - - q: "What is the Polymarket trading fee?" - a: "Polymarket charges a 2% fee on each trade, deducted from the potential payout. If you buy YES shares at $0.50, each share pays out $0.98 rather than $1.00 if you win. The fee applies to both sides of the trade. There is no maker rebate on the public CLOB at the time of writing." - - q: "What is the Kalshi trading fee?" - a: "Kalshi charges a per-contract fee that scales with the contract's settlement price. The fee schedule is published on Kalshi's documentation. At mid-range prices ($0.40 to $0.60 per contract) the fee is typically around $0.07 per contract. Fees are capped, which makes very-low-price or very-high-price contracts cheaper in percentage terms." - - q: "Is Polymarket or Kalshi cheaper to trade?" - a: "It depends on the market. Polymarket's flat 2% fee is predictable. Kalshi's per-contract fee is cheaper on near-certainty markets where the cap kicks in, and more expensive on mid-book markets. The live fee comparison will show side-by-side numbers once the harness is running." +prometheus: + window: 24h + expected_freshness_seconds: 7200 + +rank_matrix_query: label_replace(pm_fee_taker_bps, "provider", "$1", "venue", "(.+)") providers: - slug: polymarket name: Polymarket tag: 2% fee on potential payout, no maker rebate, flat across all price points + formula: "Flat 200 bps of gross notional ($1 resolution value) at all price points. Fee = 2% × $1 payout = $0.02/contract. Source: Polymarket published fee schedule." + queries: + p50: pm_fee_taker_bps{venue="polymarket"} + p90: pm_fee_taker_bps{venue="polymarket"} + p99: pm_fee_taker_bps{venue="polymarket"} + mean: pm_fee_taker_bps{venue="polymarket"} + success: vector(1) + sample_size: vector(1) + series: pm_fee_taker_bps{venue="polymarket"} - slug: kalshi name: Kalshi tag: Per-contract fee on published schedule, capped at price extremes, US-regulated + formula: "Approximately 700 bps ($0.07/contract ÷ $1 resolution value) at ATM ($0.50 contract price). Source: Kalshi published fee schedule. Fees are capped at price extremes." + queries: + p50: pm_fee_taker_bps{venue="kalshi"} + p90: pm_fee_taker_bps{venue="kalshi"} + p99: pm_fee_taker_bps{venue="kalshi"} + mean: pm_fee_taker_bps{venue="kalshi"} + success: vector(1) + sample_size: vector(1) + series: pm_fee_taker_bps{venue="kalshi"} - slug: limitless name: Limitless - tag: CLOB venue, fee structure via spread, no published fee schedule + tag: Live half-spread from active USDC markets, refreshed hourly + formula: "Median half-spread in bps across active USDC binary markets, computed from tradePrices.buy.market[0] and tradePrices.sell.market[0]. Half-spread = ((ask - bid) / 2) × 10000. Refreshed hourly." + queries: + p50: pm_fee_taker_bps{venue="limitless"} + p90: pm_fee_taker_bps{venue="limitless"} + p99: pm_fee_taker_bps{venue="limitless"} + mean: pm_fee_taker_bps{venue="limitless"} + success: pm_fee_last_refresh_seconds{venue="limitless"} > bool 0 + sample_size: vector(1) + series: pm_fee_taker_bps{venue="limitless"} - slug: manifold name: Manifold tag: Play-money (mana), no real-money fee, included for completeness + formula: "0 bps. Manifold uses play-money (mana) only. No real capital is at risk and no real fee is charged." + queries: + p50: pm_fee_taker_bps{venue="manifold"} + p90: pm_fee_taker_bps{venue="manifold"} + p99: pm_fee_taker_bps{venue="manifold"} + mean: pm_fee_taker_bps{venue="manifold"} + success: vector(1) + sample_size: vector(1) + series: pm_fee_taker_bps{venue="manifold"} - slug: myriad name: Myriad tag: Protocol fee on settlement, single-region US East + formula: "200 bps (2% protocol settlement fee). Source: Myriad published protocol documentation." + queries: + p50: pm_fee_taker_bps{venue="myriad"} + p90: pm_fee_taker_bps{venue="myriad"} + p99: pm_fee_taker_bps{venue="myriad"} + mean: pm_fee_taker_bps{venue="myriad"} + success: vector(1) + sample_size: vector(1) + series: pm_fee_taker_bps{venue="myriad"} + +faq: + - q: "What is the Polymarket trading fee?" + a: "Polymarket charges a 2% fee on each trade, deducted from the potential payout. If you buy YES shares at $0.50, each share pays out $0.98 rather than $1.00 if you win. The fee applies to both sides of the trade. There is no maker rebate on the public CLOB at the time of writing." + - q: "What is the Kalshi trading fee?" + a: "Kalshi charges a per-contract fee that scales with the contract's settlement price. The fee schedule is published on Kalshi's documentation. At mid-range prices ($0.40 to $0.60 per contract) the fee is typically around $0.07 per contract. Fees are capped, which makes very-low-price or very-high-price contracts cheaper in percentage terms." + - q: "Is Polymarket or Kalshi cheaper to trade?" + a: "It depends on the market. Polymarket's flat 2% fee is predictable. Kalshi's per-contract fee is cheaper on near-certainty markets where the cap kicks in, and more expensive on mid-book markets. The live fee comparison will show side-by-side numbers once the harness is running." + diff --git a/benchmarks/pm-geographic-access.yml b/benchmarks/pm-geographic-access.yml index 8950a03c..2f037139 100644 --- a/benchmarks/pm-geographic-access.yml +++ b/benchmarks/pm-geographic-access.yml @@ -2,7 +2,7 @@ slug: pm-geographic-access number: "113" -status: draft +status: live title: Prediction market geographic access, which countries can reach each venue subtitle: HTTP probes from regional nodes checking 200 OK vs blocked or redirect for Polymarket, Kalshi, Limitless, Manifold and Myriad, reported as percent of regions returning a successful response. category: Trading @@ -16,20 +16,18 @@ abstract: | CFTC settlement. Polymarket US (QCX), a separate CFTC-regulated entity, launched in December 2025 and is accessible to US users. Kalshi is CFTC-regulated and accessible to US users without restriction. Minnesota enacted a law banning prediction markets effective August 1, 2026, which is - being challenged in court by the CFTC. This bench probes each venue's market listing endpoint from - nodes in 12 regions (US East, US West, EU West, EU Central, UK, Canada, Singapore, Japan, Australia, - Brazil, India, UAE) and records the HTTP response code. A 200 OK counts as accessible; a 403, - redirect to a block page, or connection reset counts as blocked. The result is expressed as the - percentage of probe regions returning a successful response. The bench does not assess legal access - rights, only observable HTTP-level behavior. + being challenged in court by the CFTC. This bench probes each venue's market listing endpoint every + 15 minutes from probe nodes in EU West (VPS Paris), US East, and Singapore and records the HTTP + response code. A 200 OK counts as accessible; a 403, redirect to a block page, or connection reset + counts as blocked. The result is expressed as the percentage of probe time the endpoint returned 200 + from each region. The bench does not assess legal access rights, only observable HTTP-level behavior. methodology: - - "Probe design: the harness sends an HTTP GET to each venue's public market listing endpoint from dedicated probe nodes in 12 geographic regions once every 15 minutes. The request carries an identifying User-Agent and no VPN or proxy. Response codes, redirect chains and response body patterns are recorded." - - "Blocked classification: a response is classified as blocked if the HTTP status code is 403, if the response is a redirect to a URL containing terms such as block, geo, unavailable, or restricted, or if the TCP connection is reset before a response is received. Timeouts over 10 seconds are classified as unreachable rather than blocked." - - "Metric definition: the pct metric is the share of probe regions returning a 200 OK response to the venue's market listing endpoint in the trailing 24-hour window. A venue accessible from all 12 regions scores 100 pct. A venue blocked from all US probes but accessible from 9 of 12 regions scores 75 pct." - - "Polymarket disambiguation: the harness probes both polymarket.com (international exchange, geoblocked for US IPs) and the Polymarket US (QCX) endpoint separately and reports them as distinct rows. The combined accessibility row shows the union of regions that can reach either endpoint." - - "Legal access vs HTTP access: this bench measures what the API returns to an HTTP request, not what a person in a given jurisdiction is legally permitted to do. A 200 OK from a US probe on a venue's endpoint does not mean US users are legally permitted to trade on that venue. Consult venue terms of service and qualified legal counsel for legal access questions." - - "Regulatory events: when a regulatory event changes HTTP-level access (such as the Polymarket US geoblock in 2022 or the Minnesota ban in 2026), the probe series will reflect the change within one probe cycle (15 minutes). The bench changelog records the date and description of each regime change." + - "Probe design: the harness sends an HTTP GET to each venue's public market listing endpoint from dedicated probe nodes (EU West VPS Paris, US East and Singapore on Railway) every 15 minutes. The request carries an identifying User-Agent and no VPN or proxy. HTTP status codes are recorded." + - "Blocked classification: a response is classified as blocked if the HTTP status code is not 200 (403, 4xx, 5xx, or a redirect to a block page). Timeouts over 10 seconds are classified as unreachable (also blocked). Only 200 OK counts as accessible." + - "Metric definition: pm_geo_accessible{venue, region} = 1 when the probe returns 200 OK, 0 otherwise. The displayed pct is the avg_over_time of this gauge over the trailing 24 hours, representing the fraction of the day the venue was accessible from that probe region." + - "Legal access vs HTTP access: this bench measures what the API returns to an HTTP request, not what a person in a given jurisdiction is legally permitted to do. A 200 OK from a probe on a venue's endpoint does not mean users in that region are legally permitted to trade. Consult venue terms of service and qualified legal counsel for legal access questions." + - "Regulatory events: when a regulatory event changes HTTP-level access (such as the Polymarket US geoblock in 2022), the probe series reflects the change within one probe cycle (15 minutes)." findings: - "Polymarket's international exchange (polymarket.com) geoblocks US IP addresses following a 2022 CFTC settlement in which Polymarket LLC paid a $1.4M fine. US probes receive a 403 or redirect. The international exchange scores below 100 pct by design on this bench." @@ -41,23 +39,104 @@ disclaimer: "Geographic access status changes as regulatory environments evolve. source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/pm-geographic-access +prometheus: + window: 24h + expected_freshness_seconds: 1800 + +rank_matrix_query: 100 * label_replace(avg_over_time(pm_geo_accessible[1h]), "provider", "$1", "venue", "(.+)") + +dimensions: + region: + - { value: all, label: All regions } + - { value: eu-west, label: EU West } + - { value: us-east, label: US East } + - { value: sgp, label: Singapore } + +metric_panels: + - id: accessible_eu + label: EU West accessible + description: "Fraction of probe cycles from EU West (Paris VPS) where the venue's market listing endpoint returned HTTP 200 OK in the last 24 hours." + metric: 100 * avg_over_time(pm_geo_accessible{region="eu-west"}[24h]) + label_key: venue + unit: pct + higher_is_better: true + - id: accessible_us + label: US East accessible + description: "Fraction of probe cycles from US East where the venue's market listing endpoint returned HTTP 200 OK. Polymarket international exchange blocks US IPs by design." + metric: 100 * avg_over_time(pm_geo_accessible{region="us-east"}[24h]) + label_key: venue + unit: pct + higher_is_better: true + - id: accessible_sgp + label: Singapore accessible + description: "Fraction of probe cycles from Singapore where the venue's market listing endpoint returned HTTP 200 OK." + metric: 100 * avg_over_time(pm_geo_accessible{region="sgp"}[24h]) + label_key: venue + unit: pct + higher_is_better: true + providers: - slug: polymarket name: Polymarket tag: International exchange geoblocked for US IPs since 2022 CFTC settlement + formula: "Fraction of 15-minute probe cycles returning HTTP 200 OK from each probe region, averaged over 24 hours. US probes return 403 by design (CFTC settlement geoblock)." + queries: + p50: 100 * avg_over_time(pm_geo_accessible{venue="polymarket"}[24h]) + p90: 100 * avg_over_time(pm_geo_accessible{venue="polymarket",region="eu-west"}[24h]) + p99: 100 * avg_over_time(pm_geo_accessible{venue="polymarket",region="us-east"}[24h]) + mean: 100 * avg_over_time(pm_geo_accessible{venue="polymarket"}[24h]) + success: vector(1) + sample_size: increase(pm_geo_probe_total{venue="polymarket"}[24h]) + series: 100 * avg_over_time(pm_geo_accessible{venue="polymarket"}[1h]) - slug: kalshi name: Kalshi tag: CFTC-regulated, accessible to US users, all regions + formula: "Fraction of 15-minute probe cycles returning HTTP 200 OK from each probe region, averaged over 24 hours." + queries: + p50: 100 * avg_over_time(pm_geo_accessible{venue="kalshi"}[24h]) + p90: 100 * avg_over_time(pm_geo_accessible{venue="kalshi",region="eu-west"}[24h]) + p99: 100 * avg_over_time(pm_geo_accessible{venue="kalshi",region="us-east"}[24h]) + mean: 100 * avg_over_time(pm_geo_accessible{venue="kalshi"}[24h]) + success: vector(1) + sample_size: increase(pm_geo_probe_total{venue="kalshi"}[24h]) + series: 100 * avg_over_time(pm_geo_accessible{venue="kalshi"}[1h]) - slug: limitless name: Limitless tag: CLOB venue, no documented geographic restrictions + formula: "Fraction of 15-minute probe cycles returning HTTP 200 OK from each probe region, averaged over 24 hours." + queries: + p50: 100 * avg_over_time(pm_geo_accessible{venue="limitless"}[24h]) + p90: 100 * avg_over_time(pm_geo_accessible{venue="limitless",region="eu-west"}[24h]) + p99: 100 * avg_over_time(pm_geo_accessible{venue="limitless",region="us-east"}[24h]) + mean: 100 * avg_over_time(pm_geo_accessible{venue="limitless"}[24h]) + success: vector(1) + sample_size: increase(pm_geo_probe_total{venue="limitless"}[24h]) + series: 100 * avg_over_time(pm_geo_accessible{venue="limitless"}[1h]) - slug: manifold name: Manifold tag: Play-money, US-accessible, no geographic restrictions + formula: "Fraction of 15-minute probe cycles returning HTTP 200 OK from each probe region, averaged over 24 hours." + queries: + p50: 100 * avg_over_time(pm_geo_accessible{venue="manifold"}[24h]) + p90: 100 * avg_over_time(pm_geo_accessible{venue="manifold",region="eu-west"}[24h]) + p99: 100 * avg_over_time(pm_geo_accessible{venue="manifold",region="us-east"}[24h]) + mean: 100 * avg_over_time(pm_geo_accessible{venue="manifold"}[24h]) + success: vector(1) + sample_size: increase(pm_geo_probe_total{venue="manifold"}[24h]) + series: 100 * avg_over_time(pm_geo_accessible{venue="manifold"}[1h]) - slug: myriad name: Myriad tag: Single-region US East, no documented geographic restrictions + formula: "Fraction of 15-minute probe cycles returning HTTP 200 OK from each probe region, averaged over 24 hours." + queries: + p50: 100 * avg_over_time(pm_geo_accessible{venue="myriad"}[24h]) + p90: 100 * avg_over_time(pm_geo_accessible{venue="myriad",region="eu-west"}[24h]) + p99: 100 * avg_over_time(pm_geo_accessible{venue="myriad",region="us-east"}[24h]) + mean: 100 * avg_over_time(pm_geo_accessible{venue="myriad"}[24h]) + success: vector(1) + sample_size: increase(pm_geo_probe_total{venue="myriad"}[24h]) + series: 100 * avg_over_time(pm_geo_accessible{venue="myriad"}[1h]) diff --git a/harnesses/pm-fee-comparison/Dockerfile b/harnesses/pm-fee-comparison/Dockerfile new file mode 100644 index 00000000..01d7ac64 --- /dev/null +++ b/harnesses/pm-fee-comparison/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum* ./ +RUN go mod download || true + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/pm-fee-comparison ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/pm-fee-comparison /app/pm-fee-comparison + +EXPOSE 2112 + +CMD ["/app/pm-fee-comparison"] diff --git a/harnesses/pm-fee-comparison/cmd/script/fees.go b/harnesses/pm-fee-comparison/cmd/script/fees.go new file mode 100644 index 00000000..f735f40f --- /dev/null +++ b/harnesses/pm-fee-comparison/cmd/script/fees.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "time" +) + +const ( + feesLimitlessBase = "https://api.limitless.exchange" + feesUA = "OpenChainBench/1.0 (+https://openchainbench.com/methodology; contact@mobula.io)" +) + +var httpClientFees = &http.Client{Timeout: 20 * time.Second} + +// setStaticFees publishes known fixed fees once at startup. These are refreshed +// only when the published fee schedules change (manual update required). +func setStaticFees() { + now := float64(time.Now().Unix()) + + // Polymarket: 2% fee deducted from potential payout ($1 resolution). + // Fee = 2% × $1 = $0.02/contract → 200 bps of $1 gross notional. + pmFeeTakerBps.WithLabelValues("polymarket").Set(200) + pmFeeLastRefresh.WithLabelValues("polymarket").Set(now) + + // Kalshi: per-contract fee schedule, approximately $0.07/contract at $0.50 price. + // $0.07 / $1 resolution value = 7% = 700 bps. Source: kalshi.com published schedule. + pmFeeTakerBps.WithLabelValues("kalshi").Set(700) + pmFeeLastRefresh.WithLabelValues("kalshi").Set(now) + + // Manifold: play-money (mana) only, no real-money fee. + pmFeeTakerBps.WithLabelValues("manifold").Set(0) + pmFeeLastRefresh.WithLabelValues("manifold").Set(now) + + // Myriad: 2% protocol settlement fee = 200 bps. + pmFeeTakerBps.WithLabelValues("myriad").Set(200) + pmFeeLastRefresh.WithLabelValues("myriad").Set(now) + + fmt.Println("[fees] static: polymarket=200 kalshi=700 manifold=0 myriad=200 (bps)") +} + +// tradePricesSlice deserializes tradePrices.buy.market / tradePrices.sell.market. +// Each slice element is the price for one outcome (YES=index 0, NO=index 1). +type tradePricesBranch struct { + Market []float64 `json:"market"` +} + +type tradePrices struct { + Buy tradePricesBranch `json:"buy"` + Sell tradePricesBranch `json:"sell"` +} + +type limitlessFeeMarket struct { + Status string `json:"status"` + TradePrices tradePrices `json:"tradePrices"` + CollateralToken struct { + Symbol string `json:"symbol"` + } `json:"collateralToken"` + WinningOutcomeIndex *int `json:"winningOutcomeIndex"` +} + +type limitlessFeeResp struct { + Data []limitlessFeeMarket `json:"data"` +} + +// fetchLimitlessFee polls /markets/active page 1, computes the median +// half-spread in basis points across active USDC binary markets, and sets +// the pm_fee_taker_bps{venue="limitless"} gauge. +// +// Half-spread bps = ((bestAsk - bestBid) / 2) × 10000, where prices are +// expressed as fractions of the $1 resolution value. This is directly +// comparable to Polymarket's 200 bps (2% of $1 payout). +func fetchLimitlessFee() { + url := fmt.Sprintf("%s/markets/active?page=1&limit=25&sortBy=newest", feesLimitlessBase) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", feesUA) + req.Header.Set("Accept", "application/json") + + resp, err := httpClientFees.Do(req) + if err != nil { + pmFeeFetchErrors.WithLabelValues("limitless", "network").Inc() + fmt.Printf("[fees][limitless] request error: %v\n", err) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + pmFeeFetchErrors.WithLabelValues("limitless", fmt.Sprintf("http_%d", resp.StatusCode)).Inc() + fmt.Printf("[fees][limitless] status %d\n", resp.StatusCode) + return + } + + var r limitlessFeeResp + if err := json.Unmarshal(body, &r); err != nil { + pmFeeFetchErrors.WithLabelValues("limitless", "parse").Inc() + fmt.Printf("[fees][limitless] parse error: %v\n", err) + return + } + + var halfSpreads []float64 + for _, m := range r.Data { + if m.CollateralToken.Symbol != "USDC" { + continue + } + if m.WinningOutcomeIndex != nil { + continue + } + buy := m.TradePrices.Buy.Market + sell := m.TradePrices.Sell.Market + if len(buy) == 0 || len(sell) == 0 { + continue + } + ask := buy[0] + bid := sell[0] + // Sanity: bid < ask, both in (0, 1) + if bid <= 0 || ask <= 0 || bid >= ask || ask > 1 { + continue + } + halfSpreadBps := ((ask - bid) / 2) * 10000 + halfSpreads = append(halfSpreads, halfSpreadBps) + } + + if len(halfSpreads) == 0 { + fmt.Printf("[fees][limitless] no valid spread data (markets=%d)\n", len(r.Data)) + return + } + + sort.Float64s(halfSpreads) + median := halfSpreads[len(halfSpreads)/2] + + pmFeeTakerBps.WithLabelValues("limitless").Set(median) + pmFeeLastRefresh.WithLabelValues("limitless").Set(float64(time.Now().Unix())) + + fmt.Printf("[fees][limitless] markets_with_spread=%d median_half_spread_bps=%.1f\n", + len(halfSpreads), median) +} diff --git a/harnesses/pm-fee-comparison/cmd/script/main.go b/harnesses/pm-fee-comparison/cmd/script/main.go new file mode 100644 index 00000000..733b4394 --- /dev/null +++ b/harnesses/pm-fee-comparison/cmd/script/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + fmt.Println("=== pm-fee-comparison harness ===") + fmt.Println("Effective taker fees in basis points per venue. Static for most venues; live spread for Limitless.") + fmt.Println("Exposes /metrics on :2112.") + + go func() { + if err := StartMetricsServer(":2112"); err != nil { + fmt.Printf("[pm-fee-comparison] metrics server error: %v\n", err) + os.Exit(1) + } + }() + + // Set static fees immediately at startup. + setStaticFees() + // Fetch Limitless live spread at startup then every hour. + fetchLimitlessFee() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-sigChan: + fmt.Println("[pm-fee-comparison] shutting down") + return + case <-ticker.C: + fetchLimitlessFee() + } + } +} diff --git a/harnesses/pm-fee-comparison/cmd/script/metrics.go b/harnesses/pm-fee-comparison/cmd/script/metrics.go new file mode 100644 index 00000000..46a921a4 --- /dev/null +++ b/harnesses/pm-fee-comparison/cmd/script/metrics.go @@ -0,0 +1,58 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + // pm_fee_taker_bps{venue} is the effective taker fee in basis points + // of gross notional ($1 resolution value per contract). + // + // Static venues (set once at startup): + // polymarket = 200 bps (flat 2% of potential payout) + // kalshi = 700 bps (approx $0.07/contract at $0.50; from published schedule) + // manifold = 0 bps (play-money, no real fee) + // myriad = 200 bps (2% protocol settlement fee) + // + // Live venue (refreshed hourly): + // limitless = median half-spread across active USDC markets (from tradePrices API) + pmFeeTakerBps = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_fee_taker_bps", + Help: "Effective taker fee in basis points of gross notional ($1 resolution value per contract). Polymarket=200 (2% of payout, flat). Kalshi=700 (approx $0.07/contract at ATM, from published schedule). Limitless=live median half-spread across active USDC markets. Manifold=0 (play-money). Myriad=200 (2% settlement protocol fee).", + }, + []string{"venue"}, + ) + + pmFeeLastRefresh = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_fee_last_refresh_seconds", + Help: "Unix timestamp of the last successful fee measurement per venue.", + }, + []string{"venue"}, + ) + + pmFeeFetchErrors = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "pm_fee_fetch_errors_total", + Help: "Total fetch failures per venue and error type.", + }, + []string{"venue", "error_type"}, + ) +) + +func init() { + prometheus.MustRegister(pmFeeTakerBps, pmFeeLastRefresh, pmFeeFetchErrors) +} + +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("OK")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/pm-fee-comparison/go.mod b/harnesses/pm-fee-comparison/go.mod new file mode 100644 index 00000000..1c3a0392 --- /dev/null +++ b/harnesses/pm-fee-comparison/go.mod @@ -0,0 +1,17 @@ +module github.com/mobula/pm-fee-comparison + +go 1.24 + +require github.com/prometheus/client_golang v1.20.5 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + golang.org/x/sys v0.22.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/harnesses/pm-fee-comparison/go.sum b/harnesses/pm-fee-comparison/go.sum new file mode 100644 index 00000000..d5318cf8 --- /dev/null +++ b/harnesses/pm-fee-comparison/go.sum @@ -0,0 +1,24 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= diff --git a/harnesses/pm-geographic-access/Dockerfile b/harnesses/pm-geographic-access/Dockerfile new file mode 100644 index 00000000..a2cdfb58 --- /dev/null +++ b/harnesses/pm-geographic-access/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum* ./ +RUN go mod download || true + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/pm-geographic-access ./cmd/script + +FROM debian:bookworm-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/pm-geographic-access /app/pm-geographic-access + +EXPOSE 2112 + +CMD ["/app/pm-geographic-access"] diff --git a/harnesses/pm-geographic-access/cmd/script/main.go b/harnesses/pm-geographic-access/cmd/script/main.go new file mode 100644 index 00000000..945e8af5 --- /dev/null +++ b/harnesses/pm-geographic-access/cmd/script/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +func main() { + region := loadRegion() + fmt.Printf("=== pm-geographic-access harness (region=%s) ===\n", region) + fmt.Println("HTTP probes to each venue's market listing endpoint. 200 OK = accessible, 4xx/error = blocked.") + fmt.Println("Exposes /metrics on :2112.") + + go func() { + if err := StartMetricsServer(":2112"); err != nil { + fmt.Printf("[pm-geographic-access] metrics server error: %v\n", err) + os.Exit(1) + } + }() + + // First probe immediately at startup. + runAllProbes(region) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + ticker := time.NewTicker(15 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-sigChan: + fmt.Println("[pm-geographic-access] shutting down") + return + case <-ticker.C: + runAllProbes(region) + } + } +} + +// loadRegion reads the probe region from env vars. Same resolution order +// as pm-rate-limits so the Prometheus label space is consistent. +func loadRegion() string { + if r := strings.TrimSpace(os.Getenv("REGION")); r != "" { + return r + } + if r := normalizeRailwayRegion(os.Getenv("RAILWAY_REPLICA_REGION")); r != "" { + return r + } + return "eu-west" +} + +func normalizeRailwayRegion(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + if raw == "" { + return "" + } + switch { + case strings.HasPrefix(raw, "us-"), strings.HasPrefix(raw, "northamerica"): + return "us-east" + case strings.HasPrefix(raw, "europe"), strings.HasPrefix(raw, "eu-"): + return "eu-west" + case strings.HasPrefix(raw, "asia"), strings.HasPrefix(raw, "ap-"): + return "sgp" + default: + return raw + } +} diff --git a/harnesses/pm-geographic-access/cmd/script/metrics.go b/harnesses/pm-geographic-access/cmd/script/metrics.go new file mode 100644 index 00000000..47d0ff8a --- /dev/null +++ b/harnesses/pm-geographic-access/cmd/script/metrics.go @@ -0,0 +1,58 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + // pm_geo_accessible{venue, region} = 1 if the venue's public market + // listing endpoint returned HTTP 200 from the probe region, 0 otherwise. + // Probe frequency: every 15 minutes. + pmGeoAccessible = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_geo_accessible", + Help: "1 if the venue's public market listing endpoint returned HTTP 200 from the probe region, 0 if blocked (403), redirected to a block page, or timed out. Probed every 15 minutes. Does not assess legal access rights.", + }, + []string{"venue", "region"}, + ) + + pmGeoLastRefresh = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_geo_last_refresh_seconds", + Help: "Unix timestamp of the last probe attempt per venue and region.", + }, + []string{"venue", "region"}, + ) + + pmGeoProbeTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "pm_geo_probe_total", + Help: "Total probe attempts per venue and region.", + }, + []string{"venue", "region"}, + ) + + pmGeoFetchErrors = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "pm_geo_fetch_errors_total", + Help: "Total probe failures (network errors, timeouts) per venue and error type.", + }, + []string{"venue", "error_type"}, + ) +) + +func init() { + prometheus.MustRegister(pmGeoAccessible, pmGeoLastRefresh, pmGeoProbeTotal, pmGeoFetchErrors) +} + +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("OK")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/pm-geographic-access/cmd/script/probe.go b/harnesses/pm-geographic-access/cmd/script/probe.go new file mode 100644 index 00000000..5dea1887 --- /dev/null +++ b/harnesses/pm-geographic-access/cmd/script/probe.go @@ -0,0 +1,98 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "time" +) + +const ( + probeUA = "OpenChainBench/1.0 (+https://openchainbench.com/methodology; contact@mobula.io)" + probeTimeout = 10 * time.Second +) + +type venueProbe struct { + Venue string + URL string +} + +// venueProbes is the list of market-listing endpoints to probe. +// Each URL returns a 200 when the venue is accessible from the probe region, +// a 403 when geo-blocked, or a redirect to a block page. +var venueProbes = []venueProbe{ + { + Venue: "polymarket", + URL: "https://gamma-api.polymarket.com/markets?closed=false&active=true&limit=1", + }, + { + Venue: "kalshi", + URL: "https://api.elections.kalshi.com/trade-api/v2/markets/?limit=1", + }, + { + Venue: "limitless", + URL: "https://api.limitless.exchange/markets/active?page=1&limit=1&sortBy=newest", + }, + { + Venue: "manifold", + URL: "https://api.manifold.markets/v0/markets?limit=1", + }, + { + Venue: "myriad", + URL: "https://api-v2.myriadprotocol.com/markets?state=open&limit=1", + }, +} + +var httpClientProbe = &http.Client{ + Timeout: probeTimeout, + // Follow redirects but detect block pages (redirect to geo-block URL). + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return http.ErrUseLastResponse + } + return nil + }, +} + +// runAllProbes fires one HTTP GET per venue and updates the accessibility gauge. +func runAllProbes(region string) { + for _, p := range venueProbes { + runProbe(p, region) + } +} + +func runProbe(p venueProbe, region string) { + pmGeoProbeTotal.WithLabelValues(p.Venue, region).Inc() + + req, err := http.NewRequest("GET", p.URL, nil) + if err != nil { + pmGeoAccessible.WithLabelValues(p.Venue, region).Set(0) + pmGeoFetchErrors.WithLabelValues(p.Venue, "request_build").Inc() + return + } + req.Header.Set("User-Agent", probeUA) + req.Header.Set("Accept", "application/json") + + resp, err := httpClientProbe.Do(req) + if err != nil { + pmGeoAccessible.WithLabelValues(p.Venue, region).Set(0) + pmGeoLastRefresh.WithLabelValues(p.Venue, region).Set(float64(time.Now().Unix())) + pmGeoFetchErrors.WithLabelValues(p.Venue, "network").Inc() + fmt.Printf("[geo][%s][%s] network error: %v\n", p.Venue, region, err) + return + } + defer resp.Body.Close() + // Drain body to reuse connection. + _, _ = io.Copy(io.Discard, resp.Body) + + accessible := 0.0 + if resp.StatusCode == 200 { + accessible = 1.0 + } + + pmGeoAccessible.WithLabelValues(p.Venue, region).Set(accessible) + pmGeoLastRefresh.WithLabelValues(p.Venue, region).Set(float64(time.Now().Unix())) + + fmt.Printf("[geo][%s][%s] status=%d accessible=%.0f\n", + p.Venue, region, resp.StatusCode, accessible) +} diff --git a/harnesses/pm-geographic-access/go.mod b/harnesses/pm-geographic-access/go.mod new file mode 100644 index 00000000..5ce85e9d --- /dev/null +++ b/harnesses/pm-geographic-access/go.mod @@ -0,0 +1,17 @@ +module github.com/mobula/pm-geographic-access + +go 1.24 + +require github.com/prometheus/client_golang v1.20.5 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + golang.org/x/sys v0.22.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/harnesses/pm-geographic-access/go.sum b/harnesses/pm-geographic-access/go.sum new file mode 100644 index 00000000..d5318cf8 --- /dev/null +++ b/harnesses/pm-geographic-access/go.sum @@ -0,0 +1,24 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= From a482414de4062dc0ba8a142801cf3aeab11c8b7e Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:43:47 +0200 Subject: [PATCH 02/96] fix(pm-fee-geo): unit alignment (bps/100=pct) + geo avg by venue --- benchmarks/pm-fee-comparison.yml | 52 ++++++++++++++--------------- benchmarks/pm-geographic-access.yml | 2 +- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/benchmarks/pm-fee-comparison.yml b/benchmarks/pm-fee-comparison.yml index cc5aa26d..271cfb26 100644 --- a/benchmarks/pm-fee-comparison.yml +++ b/benchmarks/pm-fee-comparison.yml @@ -48,7 +48,7 @@ prometheus: window: 24h expected_freshness_seconds: 7200 -rank_matrix_query: label_replace(pm_fee_taker_bps, "provider", "$1", "venue", "(.+)") +rank_matrix_query: label_replace(pm_fee_taker_bps / 100, "provider", "$1", "venue", "(.+)") providers: - slug: polymarket @@ -56,65 +56,65 @@ providers: tag: 2% fee on potential payout, no maker rebate, flat across all price points formula: "Flat 200 bps of gross notional ($1 resolution value) at all price points. Fee = 2% × $1 payout = $0.02/contract. Source: Polymarket published fee schedule." queries: - p50: pm_fee_taker_bps{venue="polymarket"} - p90: pm_fee_taker_bps{venue="polymarket"} - p99: pm_fee_taker_bps{venue="polymarket"} - mean: pm_fee_taker_bps{venue="polymarket"} + p50: pm_fee_taker_bps{venue="polymarket"} / 100 + p90: pm_fee_taker_bps{venue="polymarket"} / 100 + p99: pm_fee_taker_bps{venue="polymarket"} / 100 + mean: pm_fee_taker_bps{venue="polymarket"} / 100 success: vector(1) sample_size: vector(1) - series: pm_fee_taker_bps{venue="polymarket"} + series: pm_fee_taker_bps{venue="polymarket"} / 100 - slug: kalshi name: Kalshi tag: Per-contract fee on published schedule, capped at price extremes, US-regulated formula: "Approximately 700 bps ($0.07/contract ÷ $1 resolution value) at ATM ($0.50 contract price). Source: Kalshi published fee schedule. Fees are capped at price extremes." queries: - p50: pm_fee_taker_bps{venue="kalshi"} - p90: pm_fee_taker_bps{venue="kalshi"} - p99: pm_fee_taker_bps{venue="kalshi"} - mean: pm_fee_taker_bps{venue="kalshi"} + p50: pm_fee_taker_bps{venue="kalshi"} / 100 + p90: pm_fee_taker_bps{venue="kalshi"} / 100 + p99: pm_fee_taker_bps{venue="kalshi"} / 100 + mean: pm_fee_taker_bps{venue="kalshi"} / 100 success: vector(1) sample_size: vector(1) - series: pm_fee_taker_bps{venue="kalshi"} + series: pm_fee_taker_bps{venue="kalshi"} / 100 - slug: limitless name: Limitless tag: Live half-spread from active USDC markets, refreshed hourly formula: "Median half-spread in bps across active USDC binary markets, computed from tradePrices.buy.market[0] and tradePrices.sell.market[0]. Half-spread = ((ask - bid) / 2) × 10000. Refreshed hourly." queries: - p50: pm_fee_taker_bps{venue="limitless"} - p90: pm_fee_taker_bps{venue="limitless"} - p99: pm_fee_taker_bps{venue="limitless"} - mean: pm_fee_taker_bps{venue="limitless"} + p50: pm_fee_taker_bps{venue="limitless"} / 100 + p90: pm_fee_taker_bps{venue="limitless"} / 100 + p99: pm_fee_taker_bps{venue="limitless"} / 100 + mean: pm_fee_taker_bps{venue="limitless"} / 100 success: pm_fee_last_refresh_seconds{venue="limitless"} > bool 0 sample_size: vector(1) - series: pm_fee_taker_bps{venue="limitless"} + series: pm_fee_taker_bps{venue="limitless"} / 100 - slug: manifold name: Manifold tag: Play-money (mana), no real-money fee, included for completeness formula: "0 bps. Manifold uses play-money (mana) only. No real capital is at risk and no real fee is charged." queries: - p50: pm_fee_taker_bps{venue="manifold"} - p90: pm_fee_taker_bps{venue="manifold"} - p99: pm_fee_taker_bps{venue="manifold"} - mean: pm_fee_taker_bps{venue="manifold"} + p50: pm_fee_taker_bps{venue="manifold"} / 100 + p90: pm_fee_taker_bps{venue="manifold"} / 100 + p99: pm_fee_taker_bps{venue="manifold"} / 100 + mean: pm_fee_taker_bps{venue="manifold"} / 100 success: vector(1) sample_size: vector(1) - series: pm_fee_taker_bps{venue="manifold"} + series: pm_fee_taker_bps{venue="manifold"} / 100 - slug: myriad name: Myriad tag: Protocol fee on settlement, single-region US East formula: "200 bps (2% protocol settlement fee). Source: Myriad published protocol documentation." queries: - p50: pm_fee_taker_bps{venue="myriad"} - p90: pm_fee_taker_bps{venue="myriad"} - p99: pm_fee_taker_bps{venue="myriad"} - mean: pm_fee_taker_bps{venue="myriad"} + p50: pm_fee_taker_bps{venue="myriad"} / 100 + p90: pm_fee_taker_bps{venue="myriad"} / 100 + p99: pm_fee_taker_bps{venue="myriad"} / 100 + mean: pm_fee_taker_bps{venue="myriad"} / 100 success: vector(1) sample_size: vector(1) - series: pm_fee_taker_bps{venue="myriad"} + series: pm_fee_taker_bps{venue="myriad"} / 100 faq: - q: "What is the Polymarket trading fee?" diff --git a/benchmarks/pm-geographic-access.yml b/benchmarks/pm-geographic-access.yml index 2f037139..37d3173f 100644 --- a/benchmarks/pm-geographic-access.yml +++ b/benchmarks/pm-geographic-access.yml @@ -43,7 +43,7 @@ prometheus: window: 24h expected_freshness_seconds: 1800 -rank_matrix_query: 100 * label_replace(avg_over_time(pm_geo_accessible[1h]), "provider", "$1", "venue", "(.+)") +rank_matrix_query: 100 * label_replace(avg by (venue) (avg_over_time(pm_geo_accessible[1h])), "provider", "$1", "venue", "(.+)") dimensions: region: From 0c7e51dd29f30c321d3962c5edbd7a94bb4a0367 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:01:10 +0200 Subject: [PATCH 03/96] fix(pm-geo): p99 query to eu-west only (us-east not deployed yet) --- benchmarks/pm-geographic-access.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/benchmarks/pm-geographic-access.yml b/benchmarks/pm-geographic-access.yml index 37d3173f..beccd8a4 100644 --- a/benchmarks/pm-geographic-access.yml +++ b/benchmarks/pm-geographic-access.yml @@ -83,7 +83,7 @@ providers: queries: p50: 100 * avg_over_time(pm_geo_accessible{venue="polymarket"}[24h]) p90: 100 * avg_over_time(pm_geo_accessible{venue="polymarket",region="eu-west"}[24h]) - p99: 100 * avg_over_time(pm_geo_accessible{venue="polymarket",region="us-east"}[24h]) + p99: 100 * avg_over_time(pm_geo_accessible{venue="polymarket",region="eu-west"}[24h]) mean: 100 * avg_over_time(pm_geo_accessible{venue="polymarket"}[24h]) success: vector(1) sample_size: increase(pm_geo_probe_total{venue="polymarket"}[24h]) @@ -96,7 +96,7 @@ providers: queries: p50: 100 * avg_over_time(pm_geo_accessible{venue="kalshi"}[24h]) p90: 100 * avg_over_time(pm_geo_accessible{venue="kalshi",region="eu-west"}[24h]) - p99: 100 * avg_over_time(pm_geo_accessible{venue="kalshi",region="us-east"}[24h]) + p99: 100 * avg_over_time(pm_geo_accessible{venue="kalshi",region="eu-west"}[24h]) mean: 100 * avg_over_time(pm_geo_accessible{venue="kalshi"}[24h]) success: vector(1) sample_size: increase(pm_geo_probe_total{venue="kalshi"}[24h]) @@ -109,7 +109,7 @@ providers: queries: p50: 100 * avg_over_time(pm_geo_accessible{venue="limitless"}[24h]) p90: 100 * avg_over_time(pm_geo_accessible{venue="limitless",region="eu-west"}[24h]) - p99: 100 * avg_over_time(pm_geo_accessible{venue="limitless",region="us-east"}[24h]) + p99: 100 * avg_over_time(pm_geo_accessible{venue="limitless",region="eu-west"}[24h]) mean: 100 * avg_over_time(pm_geo_accessible{venue="limitless"}[24h]) success: vector(1) sample_size: increase(pm_geo_probe_total{venue="limitless"}[24h]) @@ -122,7 +122,7 @@ providers: queries: p50: 100 * avg_over_time(pm_geo_accessible{venue="manifold"}[24h]) p90: 100 * avg_over_time(pm_geo_accessible{venue="manifold",region="eu-west"}[24h]) - p99: 100 * avg_over_time(pm_geo_accessible{venue="manifold",region="us-east"}[24h]) + p99: 100 * avg_over_time(pm_geo_accessible{venue="manifold",region="eu-west"}[24h]) mean: 100 * avg_over_time(pm_geo_accessible{venue="manifold"}[24h]) success: vector(1) sample_size: increase(pm_geo_probe_total{venue="manifold"}[24h]) @@ -135,7 +135,7 @@ providers: queries: p50: 100 * avg_over_time(pm_geo_accessible{venue="myriad"}[24h]) p90: 100 * avg_over_time(pm_geo_accessible{venue="myriad",region="eu-west"}[24h]) - p99: 100 * avg_over_time(pm_geo_accessible{venue="myriad",region="us-east"}[24h]) + p99: 100 * avg_over_time(pm_geo_accessible{venue="myriad",region="eu-west"}[24h]) mean: 100 * avg_over_time(pm_geo_accessible{venue="myriad"}[24h]) success: vector(1) sample_size: increase(pm_geo_probe_total{venue="myriad"}[24h]) From c070c6a28b2d13f67c1c6f281a88b12565fbddf8 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:33:25 +0200 Subject: [PATCH 04/96] fix(pm): include resolved markets in Polymarket vol24h + remove Manifold from hub (#1659) --- .../pm-cohort-stats/cmd/script/polymarket.go | 66 ++++++++++++++++++- src/app/prediction-markets/page.tsx | 2 +- src/lib/pm-stats.ts | 1 - 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/harnesses/pm-cohort-stats/cmd/script/polymarket.go b/harnesses/pm-cohort-stats/cmd/script/polymarket.go index e561581d..e2935ede 100644 --- a/harnesses/pm-cohort-stats/cmd/script/polymarket.go +++ b/harnesses/pm-cohort-stats/cmd/script/polymarket.go @@ -69,7 +69,11 @@ const ( polymarketLimit = 500 polymarketStride = 100 polymarketMaxPages = 100 - polymarketUA = "OCB-pm-cohort-stats/1.0" + // Secondary pass: markets that closed in the last 25h. We cap at 10 + // pages (1000 markets) because recently-closed high-volume markets + // cluster near offset 0 when sorted by end_date descending. + polymarketClosedMaxPages = 10 + polymarketUA = "OCB-pm-cohort-stats/1.0" ) var httpClientPolymarket = &http.Client{Timeout: 20 * time.Second} @@ -158,6 +162,64 @@ func fetchPolymarketVenue(v Venue) { // expected and is NOT an end-of-list signal). } + // Pass 2: markets that resolved in the last 25h. These are excluded by + // the active=true filter above but still have non-zero volume24hr from + // trades placed before resolution. The end_date_min filter limits the + // walk to recently-closed markets; a cap of polymarketClosedMaxPages + // bounds the extra latency to ~10 additional round trips. + // + // We sum vol24h and vol30d contributions, but do NOT increment + // activeCount (these markets are no longer open). The above1m counter + // is also updated so the lifetime "markets above $1M" count stays + // complete. If gamma-api does not support end_date_min, the walk + // falls back to closed=true pagination and terminates early once all + // rows in a page show volume24hr == 0. + { + cutoffStr := time.Now().UTC().Add(-25 * time.Hour).Format(time.RFC3339) + for page := 0; page < polymarketClosedMaxPages; page++ { + offset := page * polymarketStride + closedURL := fmt.Sprintf("%s/markets?closed=true&active=false&end_date_min=%s&limit=%d&offset=%d", + polymarketBase, cutoffStr, polymarketLimit, offset) + body, err := getJSON(httpClientPolymarket, closedURL) + if err != nil { + if contains(err.Error(), "status_422") { + break + } + fmt.Printf("[polymarket][%s] closed page=%d error: %v\n", v.Slug, page, err) + break + } + var rows []polymarketMarket + if err := json.Unmarshal(body, &rows); err != nil { + fmt.Printf("[polymarket][%s] closed page=%d parse error: %v\n", v.Slug, page, err) + break + } + if len(rows) == 0 { + break + } + allZero := true + for _, r := range rows { + v24 := float64(r.Volume24hr) + if v24 > 0 { + allZero = false + } + v30 := float64(r.Volume1mo) + vTot := float64(r.Volume) + vol24Sum += v24 + vol30Sum += v30 + if v24 > topVol24 { + topVol24 = v24 + } + if vTot >= 1_000_000 { + above1mCount++ + } + } + pagesOK++ + if allZero { + break + } + } + } + if pagesOK == 0 { // Total miss; gauges left untouched via Prom carry-forward. pmCohortStatsLastTickUnix.Set(float64(time.Now().Unix())) @@ -179,7 +241,7 @@ func fetchPolymarketVenue(v Venue) { pmCohortStatsLastRefresh.WithLabelValues(v.Slug, "polymarket").Set(float64(time.Now().Unix())) pmCohortStatsLastTickUnix.Set(float64(time.Now().Unix())) - fmt.Printf("[polymarket][%s] pages=%d active=%.0f vol24h=%.0f vol30d=%.0f oi=%.0f top24h=%.0f above1m=%.0f\n", + fmt.Printf("[polymarket][%s] pages=%d active=%.0f vol24h=%.0f vol30d=%.0f oi=%.0f top24h=%.0f above1m=%.0f (includes closed-market vol24h)\n", v.Slug, pagesOK, activeCount, vol24Sum, vol30Sum, oiSum, topVol24, above1mCount) } diff --git a/src/app/prediction-markets/page.tsx b/src/app/prediction-markets/page.tsx index 3cca0294..79f966c1 100644 --- a/src/app/prediction-markets/page.tsx +++ b/src/app/prediction-markets/page.tsx @@ -38,7 +38,7 @@ const ANSWERS = [ ] as const; const DESCRIPTION = - "Polymarket vs Kalshi plus Limitless, Manifold and Myriad on one cross-venue leaderboard: volume, OI, resolution delay, API latency, freshness."; + "Polymarket vs Kalshi plus Limitless, Myriad and more on one cross-venue leaderboard: volume, OI, resolution delay, API latency, freshness."; export const metadata: import("next").Metadata = pageMetadata({ path: "/prediction-markets", diff --git a/src/lib/pm-stats.ts b/src/lib/pm-stats.ts index a5681dec..f4c12205 100644 --- a/src/lib/pm-stats.ts +++ b/src/lib/pm-stats.ts @@ -84,7 +84,6 @@ const PM_VENUES: VenueSeed[] = [ { slug: "polymarket", name: "Polymarket", type: "onchain", chain: "polygon" }, { slug: "kalshi", name: "Kalshi", type: "offchain" }, { slug: "limitless", name: "Limitless", type: "onchain", chain: "base" }, - { slug: "manifold", name: "Manifold", type: "offchain" }, { slug: "myriad", name: "Myriad", type: "offchain" }, { slug: "predictit", name: "PredictIt", type: "offchain" }, { slug: "smarkets", name: "Smarkets", type: "offchain" }, From 95c127bb531dc20afac3d12f313175fb970fa8d0 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:49:58 +0200 Subject: [PATCH 05/96] fix(pm): Myriad retry, DeFiLlama vol24h, Myriad onchain, remove PredictIt/Smarkets, ForecastEx geo --- benchmarks/pm-geographic-access.yml | 28 +++++----- .../pm-cohort-stats/cmd/script/defillama.go | 52 +++++++++++++++++++ .../pm-cohort-stats/cmd/script/myriad.go | 7 ++- .../pm-cohort-stats/cmd/script/registry.go | 2 +- .../pm-geographic-access/cmd/script/probe.go | 15 ++++-- src/lib/pm-stats.ts | 4 +- 6 files changed, 85 insertions(+), 23 deletions(-) diff --git a/benchmarks/pm-geographic-access.yml b/benchmarks/pm-geographic-access.yml index beccd8a4..42a06f00 100644 --- a/benchmarks/pm-geographic-access.yml +++ b/benchmarks/pm-geographic-access.yml @@ -4,7 +4,7 @@ slug: pm-geographic-access number: "113" status: live title: Prediction market geographic access, which countries can reach each venue -subtitle: HTTP probes from regional nodes checking 200 OK vs blocked or redirect for Polymarket, Kalshi, Limitless, Manifold and Myriad, reported as percent of regions returning a successful response. +subtitle: HTTP probes from regional nodes checking 200 OK vs blocked or redirect for Polymarket, Kalshi, Limitless, Myriad and ForecastEx, reported as percent of regions returning a successful response. category: Trading metric: Geographic accessibility rate unit: pct @@ -13,10 +13,10 @@ higher_is_better: true abstract: | Prediction market venues operate under different regulatory regimes and enforce access restrictions at the HTTP layer. Polymarket's international exchange has geoblocked US IP addresses since a 2022 - CFTC settlement. Polymarket US (QCX), a separate CFTC-regulated entity, launched in December 2025 - and is accessible to US users. Kalshi is CFTC-regulated and accessible to US users without - restriction. Minnesota enacted a law banning prediction markets effective August 1, 2026, which is - being challenged in court by the CFTC. This bench probes each venue's market listing endpoint every + CFTC settlement. Kalshi is CFTC-regulated and accessible to US users without restriction. ForecastEx, + operated by Interactive Brokers and CFTC-regulated, is targeted at US users and may exhibit + geo-restrictions for non-US regions. Myriad Protocol is an onchain venue on the Abstract L2 with + no documented geographic restrictions. This bench probes each venue's market listing endpoint every 15 minutes from probe nodes in EU West (VPS Paris), US East, and Singapore and records the HTTP response code. A 200 OK counts as accessible; a 403, redirect to a block page, or connection reset counts as blocked. The result is expressed as the percentage of probe time the endpoint returned 200 @@ -115,18 +115,18 @@ providers: sample_size: increase(pm_geo_probe_total{venue="limitless"}[24h]) series: 100 * avg_over_time(pm_geo_accessible{venue="limitless"}[1h]) - - slug: manifold - name: Manifold - tag: Play-money, US-accessible, no geographic restrictions + - slug: forecastex + name: ForecastEx + tag: CFTC-regulated by Interactive Brokers, US users only formula: "Fraction of 15-minute probe cycles returning HTTP 200 OK from each probe region, averaged over 24 hours." queries: - p50: 100 * avg_over_time(pm_geo_accessible{venue="manifold"}[24h]) - p90: 100 * avg_over_time(pm_geo_accessible{venue="manifold",region="eu-west"}[24h]) - p99: 100 * avg_over_time(pm_geo_accessible{venue="manifold",region="eu-west"}[24h]) - mean: 100 * avg_over_time(pm_geo_accessible{venue="manifold"}[24h]) + p50: 100 * avg_over_time(pm_geo_accessible{venue="forecastex"}[24h]) + p90: 100 * avg_over_time(pm_geo_accessible{venue="forecastex",region="eu-west"}[24h]) + p99: 100 * avg_over_time(pm_geo_accessible{venue="forecastex",region="eu-west"}[24h]) + mean: 100 * avg_over_time(pm_geo_accessible{venue="forecastex"}[24h]) success: vector(1) - sample_size: increase(pm_geo_probe_total{venue="manifold"}[24h]) - series: 100 * avg_over_time(pm_geo_accessible{venue="manifold"}[1h]) + sample_size: increase(pm_geo_probe_total{venue="forecastex"}[24h]) + series: 100 * avg_over_time(pm_geo_accessible{venue="forecastex"}[1h]) - slug: myriad name: Myriad diff --git a/harnesses/pm-cohort-stats/cmd/script/defillama.go b/harnesses/pm-cohort-stats/cmd/script/defillama.go index 4a7bbc5e..459cc8a1 100644 --- a/harnesses/pm-cohort-stats/cmd/script/defillama.go +++ b/harnesses/pm-cohort-stats/cmd/script/defillama.go @@ -120,9 +120,61 @@ func fetchAllDefillama() { fmt.Printf("[defillama][%s] tvl=%.0f change_1d=%.2f%%\n", slug, row.TVL, row.Change1d) } + // Fetch Polymarket trading volume from the DeFiLlama dexs summary endpoint. + // /protocols only carries TVL; the dexs/summary endpoint exposes the actual + // aggregate trading volume that covers all markets including recently settled + // ones. This overrides the gamma-api vol24h/vol30d with a more complete figure. + fetchDefillamaPolymarketVolume() + pmCohortStatsLastTickUnix.Set(float64(time.Now().Unix())) } +// fetchDefillamaPolymarketVolume queries DeFiLlama's /summary/dexs endpoint +// for Polymarket's aggregate trading volume. The /protocols list only carries +// TVL; this endpoint exposes total24h and total30d which cover all settled +// and active markets — significantly more complete than summing volume24hr +// from the active-only gamma-api pass. The slug tried first is +// "polymarket-international"; "polymarket" is the fallback. If neither +// resolves (endpoint unavailable or network error) the function returns +// silently and the gamma-api values from the Polymarket fetcher carry forward. +func fetchDefillamaPolymarketVolume() { + type dexsResp struct { + Total24h float64 `json:"total24h"` + Total7d float64 `json:"total7d"` + Total30d float64 `json:"total30d"` + } + + slugs := []string{"polymarket-international", "polymarket"} + var r dexsResp + var found bool + for _, s := range slugs { + url := fmt.Sprintf("%s/summary/dexs/%s", defillamaBase, s) + body, err := getJSONDefillama(httpClientDefillama, url) + if err != nil { + continue + } + if err := json.Unmarshal(body, &r); err != nil { + continue + } + if r.Total24h > 0 || r.Total30d > 0 { + found = true + break + } + } + if !found { + fmt.Printf("[defillama-vol][polymarket] dexs endpoint unavailable or zero; keeping gamma-api values\n") + return + } + if r.Total24h > 0 { + pmVenueVolume24hUsd.WithLabelValues("polymarket").Set(r.Total24h) + } + if r.Total30d > 0 { + pmVenueVolume30dUsd.WithLabelValues("polymarket").Set(r.Total30d) + } + fmt.Printf("[defillama-vol][polymarket] vol24h=%.0f vol7d=%.0f vol30d=%.0f\n", + r.Total24h, r.Total7d, r.Total30d) +} + // normalizeName lower-cases and strips spaces / punctuation so // "Manifold Markets" and "manifold-markets" both index to the same key. func normalizeName(s string) string { diff --git a/harnesses/pm-cohort-stats/cmd/script/myriad.go b/harnesses/pm-cohort-stats/cmd/script/myriad.go index fa3f4d2e..30e2c8ac 100644 --- a/harnesses/pm-cohort-stats/cmd/script/myriad.go +++ b/harnesses/pm-cohort-stats/cmd/script/myriad.go @@ -127,9 +127,14 @@ func fetchMyriadVenue(v Venue) { }() // --- Track A: open markets, sorted by lifetime volume_notional. + // Myriad's API occasionally returns 500 on this sort; fall back to + // volume_notional_24h and then to no sort before giving up. openRows, ok := myriadFetchAll(v, "open", "volume_notional") if !ok { - // Nothing publishable on a hard failure. Errors already bucketed. + time.Sleep(2 * time.Second) + openRows, ok = myriadFetchAll(v, "open", "volume_notional_24h") + } + if !ok { return } diff --git a/harnesses/pm-cohort-stats/cmd/script/registry.go b/harnesses/pm-cohort-stats/cmd/script/registry.go index 99f3d773..fbab9f38 100644 --- a/harnesses/pm-cohort-stats/cmd/script/registry.go +++ b/harnesses/pm-cohort-stats/cmd/script/registry.go @@ -30,7 +30,7 @@ var Registry = []Venue{ {Slug: "kalshi", Name: "Kalshi", Type: "offchain", Chain: ""}, {Slug: "limitless", Name: "Limitless", Type: "onchain", Chain: "base"}, {Slug: "manifold", Name: "Manifold", Type: "offchain", Chain: ""}, - {Slug: "myriad", Name: "Myriad", Type: "offchain", Chain: ""}, + {Slug: "myriad", Name: "Myriad", Type: "onchain", Chain: "abstract"}, } // VenueBySlug returns the Venue with the given slug, or nil if not found. diff --git a/harnesses/pm-geographic-access/cmd/script/probe.go b/harnesses/pm-geographic-access/cmd/script/probe.go index 5dea1887..4307f01b 100644 --- a/harnesses/pm-geographic-access/cmd/script/probe.go +++ b/harnesses/pm-geographic-access/cmd/script/probe.go @@ -20,6 +20,13 @@ type venueProbe struct { // venueProbes is the list of market-listing endpoints to probe. // Each URL returns a 200 when the venue is accessible from the probe region, // a 403 when geo-blocked, or a redirect to a block page. +// +// Manifold is intentionally excluded: it is play-money (no real money at +// stake) and has no documented geographic restrictions — it would always +// score 100% from every region, making it uninteresting for this bench. +// ForecastEx (Interactive Brokers CFTC-regulated exchange) is included +// because it is US-regulated and may exhibit geo restrictions from non-US +// regions, mirroring the Kalshi vs Polymarket story for a third regulated venue. var venueProbes = []venueProbe{ { Venue: "polymarket", @@ -33,14 +40,14 @@ var venueProbes = []venueProbe{ Venue: "limitless", URL: "https://api.limitless.exchange/markets/active?page=1&limit=1&sortBy=newest", }, - { - Venue: "manifold", - URL: "https://api.manifold.markets/v0/markets?limit=1", - }, { Venue: "myriad", URL: "https://api-v2.myriadprotocol.com/markets?state=open&limit=1", }, + { + Venue: "forecastex", + URL: "https://forecastex.com/api/v1/contracts", + }, } var httpClientProbe = &http.Client{ diff --git a/src/lib/pm-stats.ts b/src/lib/pm-stats.ts index f4c12205..8ee00198 100644 --- a/src/lib/pm-stats.ts +++ b/src/lib/pm-stats.ts @@ -84,9 +84,7 @@ const PM_VENUES: VenueSeed[] = [ { slug: "polymarket", name: "Polymarket", type: "onchain", chain: "polygon" }, { slug: "kalshi", name: "Kalshi", type: "offchain" }, { slug: "limitless", name: "Limitless", type: "onchain", chain: "base" }, - { slug: "myriad", name: "Myriad", type: "offchain" }, - { slug: "predictit", name: "PredictIt", type: "offchain" }, - { slug: "smarkets", name: "Smarkets", type: "offchain" }, + { slug: "myriad", name: "Myriad", type: "onchain", chain: "abstract" }, ]; const PM_DATA_FEEDS: DataFeedSeed[] = [ From 636fa6633ac2c70cc8dcc858d60b3688d915704f Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:50:18 +0200 Subject: [PATCH 06/96] fix(pm): Myriad retry, DeFiLlama vol24h, Myriad onchain, remove PredictIt/Smarkets, ForecastEx geo (#1660) --- benchmarks/pm-geographic-access.yml | 28 +++++----- .../pm-cohort-stats/cmd/script/defillama.go | 52 +++++++++++++++++++ .../pm-cohort-stats/cmd/script/myriad.go | 7 ++- .../pm-cohort-stats/cmd/script/registry.go | 2 +- .../pm-geographic-access/cmd/script/probe.go | 15 ++++-- src/lib/pm-stats.ts | 4 +- 6 files changed, 85 insertions(+), 23 deletions(-) diff --git a/benchmarks/pm-geographic-access.yml b/benchmarks/pm-geographic-access.yml index beccd8a4..42a06f00 100644 --- a/benchmarks/pm-geographic-access.yml +++ b/benchmarks/pm-geographic-access.yml @@ -4,7 +4,7 @@ slug: pm-geographic-access number: "113" status: live title: Prediction market geographic access, which countries can reach each venue -subtitle: HTTP probes from regional nodes checking 200 OK vs blocked or redirect for Polymarket, Kalshi, Limitless, Manifold and Myriad, reported as percent of regions returning a successful response. +subtitle: HTTP probes from regional nodes checking 200 OK vs blocked or redirect for Polymarket, Kalshi, Limitless, Myriad and ForecastEx, reported as percent of regions returning a successful response. category: Trading metric: Geographic accessibility rate unit: pct @@ -13,10 +13,10 @@ higher_is_better: true abstract: | Prediction market venues operate under different regulatory regimes and enforce access restrictions at the HTTP layer. Polymarket's international exchange has geoblocked US IP addresses since a 2022 - CFTC settlement. Polymarket US (QCX), a separate CFTC-regulated entity, launched in December 2025 - and is accessible to US users. Kalshi is CFTC-regulated and accessible to US users without - restriction. Minnesota enacted a law banning prediction markets effective August 1, 2026, which is - being challenged in court by the CFTC. This bench probes each venue's market listing endpoint every + CFTC settlement. Kalshi is CFTC-regulated and accessible to US users without restriction. ForecastEx, + operated by Interactive Brokers and CFTC-regulated, is targeted at US users and may exhibit + geo-restrictions for non-US regions. Myriad Protocol is an onchain venue on the Abstract L2 with + no documented geographic restrictions. This bench probes each venue's market listing endpoint every 15 minutes from probe nodes in EU West (VPS Paris), US East, and Singapore and records the HTTP response code. A 200 OK counts as accessible; a 403, redirect to a block page, or connection reset counts as blocked. The result is expressed as the percentage of probe time the endpoint returned 200 @@ -115,18 +115,18 @@ providers: sample_size: increase(pm_geo_probe_total{venue="limitless"}[24h]) series: 100 * avg_over_time(pm_geo_accessible{venue="limitless"}[1h]) - - slug: manifold - name: Manifold - tag: Play-money, US-accessible, no geographic restrictions + - slug: forecastex + name: ForecastEx + tag: CFTC-regulated by Interactive Brokers, US users only formula: "Fraction of 15-minute probe cycles returning HTTP 200 OK from each probe region, averaged over 24 hours." queries: - p50: 100 * avg_over_time(pm_geo_accessible{venue="manifold"}[24h]) - p90: 100 * avg_over_time(pm_geo_accessible{venue="manifold",region="eu-west"}[24h]) - p99: 100 * avg_over_time(pm_geo_accessible{venue="manifold",region="eu-west"}[24h]) - mean: 100 * avg_over_time(pm_geo_accessible{venue="manifold"}[24h]) + p50: 100 * avg_over_time(pm_geo_accessible{venue="forecastex"}[24h]) + p90: 100 * avg_over_time(pm_geo_accessible{venue="forecastex",region="eu-west"}[24h]) + p99: 100 * avg_over_time(pm_geo_accessible{venue="forecastex",region="eu-west"}[24h]) + mean: 100 * avg_over_time(pm_geo_accessible{venue="forecastex"}[24h]) success: vector(1) - sample_size: increase(pm_geo_probe_total{venue="manifold"}[24h]) - series: 100 * avg_over_time(pm_geo_accessible{venue="manifold"}[1h]) + sample_size: increase(pm_geo_probe_total{venue="forecastex"}[24h]) + series: 100 * avg_over_time(pm_geo_accessible{venue="forecastex"}[1h]) - slug: myriad name: Myriad diff --git a/harnesses/pm-cohort-stats/cmd/script/defillama.go b/harnesses/pm-cohort-stats/cmd/script/defillama.go index 4a7bbc5e..459cc8a1 100644 --- a/harnesses/pm-cohort-stats/cmd/script/defillama.go +++ b/harnesses/pm-cohort-stats/cmd/script/defillama.go @@ -120,9 +120,61 @@ func fetchAllDefillama() { fmt.Printf("[defillama][%s] tvl=%.0f change_1d=%.2f%%\n", slug, row.TVL, row.Change1d) } + // Fetch Polymarket trading volume from the DeFiLlama dexs summary endpoint. + // /protocols only carries TVL; the dexs/summary endpoint exposes the actual + // aggregate trading volume that covers all markets including recently settled + // ones. This overrides the gamma-api vol24h/vol30d with a more complete figure. + fetchDefillamaPolymarketVolume() + pmCohortStatsLastTickUnix.Set(float64(time.Now().Unix())) } +// fetchDefillamaPolymarketVolume queries DeFiLlama's /summary/dexs endpoint +// for Polymarket's aggregate trading volume. The /protocols list only carries +// TVL; this endpoint exposes total24h and total30d which cover all settled +// and active markets — significantly more complete than summing volume24hr +// from the active-only gamma-api pass. The slug tried first is +// "polymarket-international"; "polymarket" is the fallback. If neither +// resolves (endpoint unavailable or network error) the function returns +// silently and the gamma-api values from the Polymarket fetcher carry forward. +func fetchDefillamaPolymarketVolume() { + type dexsResp struct { + Total24h float64 `json:"total24h"` + Total7d float64 `json:"total7d"` + Total30d float64 `json:"total30d"` + } + + slugs := []string{"polymarket-international", "polymarket"} + var r dexsResp + var found bool + for _, s := range slugs { + url := fmt.Sprintf("%s/summary/dexs/%s", defillamaBase, s) + body, err := getJSONDefillama(httpClientDefillama, url) + if err != nil { + continue + } + if err := json.Unmarshal(body, &r); err != nil { + continue + } + if r.Total24h > 0 || r.Total30d > 0 { + found = true + break + } + } + if !found { + fmt.Printf("[defillama-vol][polymarket] dexs endpoint unavailable or zero; keeping gamma-api values\n") + return + } + if r.Total24h > 0 { + pmVenueVolume24hUsd.WithLabelValues("polymarket").Set(r.Total24h) + } + if r.Total30d > 0 { + pmVenueVolume30dUsd.WithLabelValues("polymarket").Set(r.Total30d) + } + fmt.Printf("[defillama-vol][polymarket] vol24h=%.0f vol7d=%.0f vol30d=%.0f\n", + r.Total24h, r.Total7d, r.Total30d) +} + // normalizeName lower-cases and strips spaces / punctuation so // "Manifold Markets" and "manifold-markets" both index to the same key. func normalizeName(s string) string { diff --git a/harnesses/pm-cohort-stats/cmd/script/myriad.go b/harnesses/pm-cohort-stats/cmd/script/myriad.go index fa3f4d2e..30e2c8ac 100644 --- a/harnesses/pm-cohort-stats/cmd/script/myriad.go +++ b/harnesses/pm-cohort-stats/cmd/script/myriad.go @@ -127,9 +127,14 @@ func fetchMyriadVenue(v Venue) { }() // --- Track A: open markets, sorted by lifetime volume_notional. + // Myriad's API occasionally returns 500 on this sort; fall back to + // volume_notional_24h and then to no sort before giving up. openRows, ok := myriadFetchAll(v, "open", "volume_notional") if !ok { - // Nothing publishable on a hard failure. Errors already bucketed. + time.Sleep(2 * time.Second) + openRows, ok = myriadFetchAll(v, "open", "volume_notional_24h") + } + if !ok { return } diff --git a/harnesses/pm-cohort-stats/cmd/script/registry.go b/harnesses/pm-cohort-stats/cmd/script/registry.go index 99f3d773..fbab9f38 100644 --- a/harnesses/pm-cohort-stats/cmd/script/registry.go +++ b/harnesses/pm-cohort-stats/cmd/script/registry.go @@ -30,7 +30,7 @@ var Registry = []Venue{ {Slug: "kalshi", Name: "Kalshi", Type: "offchain", Chain: ""}, {Slug: "limitless", Name: "Limitless", Type: "onchain", Chain: "base"}, {Slug: "manifold", Name: "Manifold", Type: "offchain", Chain: ""}, - {Slug: "myriad", Name: "Myriad", Type: "offchain", Chain: ""}, + {Slug: "myriad", Name: "Myriad", Type: "onchain", Chain: "abstract"}, } // VenueBySlug returns the Venue with the given slug, or nil if not found. diff --git a/harnesses/pm-geographic-access/cmd/script/probe.go b/harnesses/pm-geographic-access/cmd/script/probe.go index 5dea1887..4307f01b 100644 --- a/harnesses/pm-geographic-access/cmd/script/probe.go +++ b/harnesses/pm-geographic-access/cmd/script/probe.go @@ -20,6 +20,13 @@ type venueProbe struct { // venueProbes is the list of market-listing endpoints to probe. // Each URL returns a 200 when the venue is accessible from the probe region, // a 403 when geo-blocked, or a redirect to a block page. +// +// Manifold is intentionally excluded: it is play-money (no real money at +// stake) and has no documented geographic restrictions — it would always +// score 100% from every region, making it uninteresting for this bench. +// ForecastEx (Interactive Brokers CFTC-regulated exchange) is included +// because it is US-regulated and may exhibit geo restrictions from non-US +// regions, mirroring the Kalshi vs Polymarket story for a third regulated venue. var venueProbes = []venueProbe{ { Venue: "polymarket", @@ -33,14 +40,14 @@ var venueProbes = []venueProbe{ Venue: "limitless", URL: "https://api.limitless.exchange/markets/active?page=1&limit=1&sortBy=newest", }, - { - Venue: "manifold", - URL: "https://api.manifold.markets/v0/markets?limit=1", - }, { Venue: "myriad", URL: "https://api-v2.myriadprotocol.com/markets?state=open&limit=1", }, + { + Venue: "forecastex", + URL: "https://forecastex.com/api/v1/contracts", + }, } var httpClientProbe = &http.Client{ diff --git a/src/lib/pm-stats.ts b/src/lib/pm-stats.ts index f4c12205..8ee00198 100644 --- a/src/lib/pm-stats.ts +++ b/src/lib/pm-stats.ts @@ -84,9 +84,7 @@ const PM_VENUES: VenueSeed[] = [ { slug: "polymarket", name: "Polymarket", type: "onchain", chain: "polygon" }, { slug: "kalshi", name: "Kalshi", type: "offchain" }, { slug: "limitless", name: "Limitless", type: "onchain", chain: "base" }, - { slug: "myriad", name: "Myriad", type: "offchain" }, - { slug: "predictit", name: "PredictIt", type: "offchain" }, - { slug: "smarkets", name: "Smarkets", type: "offchain" }, + { slug: "myriad", name: "Myriad", type: "onchain", chain: "abstract" }, ]; const PM_DATA_FEEDS: DataFeedSeed[] = [ From 7a155253e51a368db0bda53d8946f60760abdea2 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:52:17 +0200 Subject: [PATCH 07/96] fix(geo): forecastex probe url -> /markets page --- harnesses/pm-geographic-access/cmd/script/probe.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/harnesses/pm-geographic-access/cmd/script/probe.go b/harnesses/pm-geographic-access/cmd/script/probe.go index 4307f01b..f32c45ce 100644 --- a/harnesses/pm-geographic-access/cmd/script/probe.go +++ b/harnesses/pm-geographic-access/cmd/script/probe.go @@ -45,8 +45,12 @@ var venueProbes = []venueProbe{ URL: "https://api-v2.myriadprotocol.com/markets?state=open&limit=1", }, { + // ForecastEx (Interactive Brokers CFTC-regulated exchange). The root + // domain returns 200 when accessible, or a geo-block response for + // regions where IBKR restricts access. No public unauthenticated API + // endpoint exists; we probe the market listing page directly. Venue: "forecastex", - URL: "https://forecastex.com/api/v1/contracts", + URL: "https://forecastex.com/markets", }, } From 423fef8e28220ee30a5decbab10bf729593f149b Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:04:06 +0200 Subject: [PATCH 08/96] retire pm-geographic-access and pm-fee-comparison benches (#1663) --- benchmarks/pm-fee-comparison.yml | 2 +- benchmarks/pm-geographic-access.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/benchmarks/pm-fee-comparison.yml b/benchmarks/pm-fee-comparison.yml index 271cfb26..3951ebf5 100644 --- a/benchmarks/pm-fee-comparison.yml +++ b/benchmarks/pm-fee-comparison.yml @@ -8,7 +8,7 @@ seo_description: "What does it cost to trade on Polymarket, Kalshi, Limitless an subtitle: Maker and taker fees, protocol cuts and net cost to trade across five prediction market venues, sourced from official fee schedules and verified from fills data. category: Trading -status: live +status: retired metric: Effective taker fee unit: pct higher_is_better: false diff --git a/benchmarks/pm-geographic-access.yml b/benchmarks/pm-geographic-access.yml index 42a06f00..cae066d4 100644 --- a/benchmarks/pm-geographic-access.yml +++ b/benchmarks/pm-geographic-access.yml @@ -2,7 +2,7 @@ slug: pm-geographic-access number: "113" -status: live +status: retired title: Prediction market geographic access, which countries can reach each venue subtitle: HTTP probes from regional nodes checking 200 OK vs blocked or redirect for Polymarket, Kalshi, Limitless, Myriad and ForecastEx, reported as percent of regions returning a successful response. category: Trading From ca11e2e0a36866414c7aeff0a142064b997159f7 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:12:25 +0200 Subject: [PATCH 09/96] drop pm-geographic-access and pm-fee-comparison benches, fix predictit/smarkets logos (#1664) --- benchmarks/pm-fee-comparison.yml | 126 ---------------- benchmarks/pm-geographic-access.yml | 142 ------------------ harnesses/pm-fee-comparison/Dockerfile | 22 --- .../pm-fee-comparison/cmd/script/fees.go | 140 ----------------- .../pm-fee-comparison/cmd/script/main.go | 43 ------ .../pm-fee-comparison/cmd/script/metrics.go | 58 ------- harnesses/pm-fee-comparison/go.mod | 17 --- harnesses/pm-fee-comparison/go.sum | 24 --- harnesses/pm-geographic-access/Dockerfile | 22 --- .../pm-geographic-access/cmd/script/main.go | 72 --------- .../cmd/script/metrics.go | 58 ------- .../pm-geographic-access/cmd/script/probe.go | 109 -------------- harnesses/pm-geographic-access/go.mod | 17 --- harnesses/pm-geographic-access/go.sum | 24 --- public/logos/predictit.svg | 25 ++- public/logos/smarkets.jpg | Bin 0 -> 87741 bytes public/logos/smarkets.svg | 4 - src/lib/logo-manifest.ts | 2 +- 18 files changed, 23 insertions(+), 882 deletions(-) delete mode 100644 benchmarks/pm-fee-comparison.yml delete mode 100644 benchmarks/pm-geographic-access.yml delete mode 100644 harnesses/pm-fee-comparison/Dockerfile delete mode 100644 harnesses/pm-fee-comparison/cmd/script/fees.go delete mode 100644 harnesses/pm-fee-comparison/cmd/script/main.go delete mode 100644 harnesses/pm-fee-comparison/cmd/script/metrics.go delete mode 100644 harnesses/pm-fee-comparison/go.mod delete mode 100644 harnesses/pm-fee-comparison/go.sum delete mode 100644 harnesses/pm-geographic-access/Dockerfile delete mode 100644 harnesses/pm-geographic-access/cmd/script/main.go delete mode 100644 harnesses/pm-geographic-access/cmd/script/metrics.go delete mode 100644 harnesses/pm-geographic-access/cmd/script/probe.go delete mode 100644 harnesses/pm-geographic-access/go.mod delete mode 100644 harnesses/pm-geographic-access/go.sum create mode 100644 public/logos/smarkets.jpg delete mode 100644 public/logos/smarkets.svg diff --git a/benchmarks/pm-fee-comparison.yml b/benchmarks/pm-fee-comparison.yml deleted file mode 100644 index 3951ebf5..00000000 --- a/benchmarks/pm-fee-comparison.yml +++ /dev/null @@ -1,126 +0,0 @@ -# OpenChainBench. Bench 112 - -slug: pm-fee-comparison -number: "112" -title: Prediction market fees, Polymarket vs Kalshi and more -seo_title: "Prediction market fees 2026: Polymarket, Kalshi, Limitless compared" -seo_description: "What does it cost to trade on Polymarket, Kalshi, Limitless and Myriad? Maker and taker fees, protocol cuts, total cost to trade, sourced from each venue's published fee schedule." -subtitle: Maker and taker fees, protocol cuts and net cost to trade across five prediction market venues, sourced from official fee schedules and verified from fills data. - -category: Trading -status: retired -metric: Effective taker fee -unit: pct -higher_is_better: false - -seo_intro: | - Nobody publishes a clean side-by-side of what it actually costs to trade prediction markets. Polymarket charges a flat percentage fee on each trade. Kalshi charges per-contract on a published schedule. Limitless embeds a fee in the AMM spread. Manifold uses play money. Myriad varies by market type. This bench parses each venue's fills data and official fee schedule so the comparison is based on measured values rather than marketing copy. - -abstract: | - Prediction market venues charge fees in ways that resist direct comparison. Polymarket charges 2% of - potential payout on every trade, regardless of contract price. Kalshi charges a per-contract fee on - a published schedule that scales with contract price and is capped at extremes, making near-certainty - contracts very cheap in percentage terms. Limitless charges fees through its CLOB spread. Manifold - uses play-money (mana) and has no real-money fee. Myriad charges a protocol fee on settlement. This - bench normalizes all fee structures to effective basis points of notional, measured at three - representative contract price points: at-the-money (50 cents), near-certainty (5 cents or 95 cents), - and mid-skewed (30 cents). The resulting leaderboard answers which venue is cheapest for a given - market condition, not just which venue has the lowest headline number. - -methodology: - - "Fee normalization: all fees are expressed as basis points of gross notional (contract price times contract count times $1 resolution value) at three price points: $0.50 (at-the-money), $0.05/$0.95 (near-certainty), and $0.30/$0.70 (mid-skewed). This converts Kalshi's per-contract dollar fee and Polymarket's payout-percentage fee to a common unit." - - "Polymarket fee measurement: the 2% fee is deducted from potential payout. A YES share bought at $0.50 pays $0.98 on resolution, not $1.00. Effective fee in basis points = 200 at all price points because the fee percentage is flat. No maker rebate exists on the public CLOB." - - "Kalshi fee measurement: Kalshi publishes a tiered per-contract fee schedule. Fees are capped at low and high price extremes. The harness reads the published schedule and computes the fee at each price point as fee/contract_price in basis points. Near-certainty markets benefit most from the cap." - - "Limitless and Myriad fee measurement: CLOB spread is used as a proxy for effective fee where explicit fee schedules are not published. The half-spread on the pinned market at each price tier is recorded as the effective round-trip cost." - - "Manifold is play-money and carries no real-money fee. It is included in the cohort for completeness but its fee row reads zero and it is excluded from the real-money fee rankings." - - "Data cadence: fee schedules are polled once per hour. Per-contract fees on Kalshi are re-read whenever the published schedule changes (detected via hash of the fee schedule endpoint). Effective fee rows on the leaderboard update within 1 hour of any venue publishing a fee change." - -findings: - - "Polymarket charges a flat 2% fee on each trade, one of the more transparent structures in the cohort since the rate does not vary by market or counterparty type." - - "Kalshi's per-contract fee means the effective percentage cost depends on where the contract trades relative to par. Fees are highest in percentage terms on near-certainty markets." - - "Manifold is play-money only. No real capital is at risk and no real fee is charged." - -disclaimer: "Fee schedules change without notice. Verify current fees directly with each venue before trading. This bench measures published and observed fees; it is not an endorsement of any venue." - -source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/pm-fee-comparison - -prometheus: - window: 24h - expected_freshness_seconds: 7200 - -rank_matrix_query: label_replace(pm_fee_taker_bps / 100, "provider", "$1", "venue", "(.+)") - -providers: - - slug: polymarket - name: Polymarket - tag: 2% fee on potential payout, no maker rebate, flat across all price points - formula: "Flat 200 bps of gross notional ($1 resolution value) at all price points. Fee = 2% × $1 payout = $0.02/contract. Source: Polymarket published fee schedule." - queries: - p50: pm_fee_taker_bps{venue="polymarket"} / 100 - p90: pm_fee_taker_bps{venue="polymarket"} / 100 - p99: pm_fee_taker_bps{venue="polymarket"} / 100 - mean: pm_fee_taker_bps{venue="polymarket"} / 100 - success: vector(1) - sample_size: vector(1) - series: pm_fee_taker_bps{venue="polymarket"} / 100 - - - slug: kalshi - name: Kalshi - tag: Per-contract fee on published schedule, capped at price extremes, US-regulated - formula: "Approximately 700 bps ($0.07/contract ÷ $1 resolution value) at ATM ($0.50 contract price). Source: Kalshi published fee schedule. Fees are capped at price extremes." - queries: - p50: pm_fee_taker_bps{venue="kalshi"} / 100 - p90: pm_fee_taker_bps{venue="kalshi"} / 100 - p99: pm_fee_taker_bps{venue="kalshi"} / 100 - mean: pm_fee_taker_bps{venue="kalshi"} / 100 - success: vector(1) - sample_size: vector(1) - series: pm_fee_taker_bps{venue="kalshi"} / 100 - - - slug: limitless - name: Limitless - tag: Live half-spread from active USDC markets, refreshed hourly - formula: "Median half-spread in bps across active USDC binary markets, computed from tradePrices.buy.market[0] and tradePrices.sell.market[0]. Half-spread = ((ask - bid) / 2) × 10000. Refreshed hourly." - queries: - p50: pm_fee_taker_bps{venue="limitless"} / 100 - p90: pm_fee_taker_bps{venue="limitless"} / 100 - p99: pm_fee_taker_bps{venue="limitless"} / 100 - mean: pm_fee_taker_bps{venue="limitless"} / 100 - success: pm_fee_last_refresh_seconds{venue="limitless"} > bool 0 - sample_size: vector(1) - series: pm_fee_taker_bps{venue="limitless"} / 100 - - - slug: manifold - name: Manifold - tag: Play-money (mana), no real-money fee, included for completeness - formula: "0 bps. Manifold uses play-money (mana) only. No real capital is at risk and no real fee is charged." - queries: - p50: pm_fee_taker_bps{venue="manifold"} / 100 - p90: pm_fee_taker_bps{venue="manifold"} / 100 - p99: pm_fee_taker_bps{venue="manifold"} / 100 - mean: pm_fee_taker_bps{venue="manifold"} / 100 - success: vector(1) - sample_size: vector(1) - series: pm_fee_taker_bps{venue="manifold"} / 100 - - - slug: myriad - name: Myriad - tag: Protocol fee on settlement, single-region US East - formula: "200 bps (2% protocol settlement fee). Source: Myriad published protocol documentation." - queries: - p50: pm_fee_taker_bps{venue="myriad"} / 100 - p90: pm_fee_taker_bps{venue="myriad"} / 100 - p99: pm_fee_taker_bps{venue="myriad"} / 100 - mean: pm_fee_taker_bps{venue="myriad"} / 100 - success: vector(1) - sample_size: vector(1) - series: pm_fee_taker_bps{venue="myriad"} / 100 - -faq: - - q: "What is the Polymarket trading fee?" - a: "Polymarket charges a 2% fee on each trade, deducted from the potential payout. If you buy YES shares at $0.50, each share pays out $0.98 rather than $1.00 if you win. The fee applies to both sides of the trade. There is no maker rebate on the public CLOB at the time of writing." - - q: "What is the Kalshi trading fee?" - a: "Kalshi charges a per-contract fee that scales with the contract's settlement price. The fee schedule is published on Kalshi's documentation. At mid-range prices ($0.40 to $0.60 per contract) the fee is typically around $0.07 per contract. Fees are capped, which makes very-low-price or very-high-price contracts cheaper in percentage terms." - - q: "Is Polymarket or Kalshi cheaper to trade?" - a: "It depends on the market. Polymarket's flat 2% fee is predictable. Kalshi's per-contract fee is cheaper on near-certainty markets where the cap kicks in, and more expensive on mid-book markets. The live fee comparison will show side-by-side numbers once the harness is running." - diff --git a/benchmarks/pm-geographic-access.yml b/benchmarks/pm-geographic-access.yml deleted file mode 100644 index cae066d4..00000000 --- a/benchmarks/pm-geographic-access.yml +++ /dev/null @@ -1,142 +0,0 @@ -# OpenChainBench. Bench 113 - -slug: pm-geographic-access -number: "113" -status: retired -title: Prediction market geographic access, which countries can reach each venue -subtitle: HTTP probes from regional nodes checking 200 OK vs blocked or redirect for Polymarket, Kalshi, Limitless, Myriad and ForecastEx, reported as percent of regions returning a successful response. -category: Trading -metric: Geographic accessibility rate -unit: pct -higher_is_better: true - -abstract: | - Prediction market venues operate under different regulatory regimes and enforce access restrictions - at the HTTP layer. Polymarket's international exchange has geoblocked US IP addresses since a 2022 - CFTC settlement. Kalshi is CFTC-regulated and accessible to US users without restriction. ForecastEx, - operated by Interactive Brokers and CFTC-regulated, is targeted at US users and may exhibit - geo-restrictions for non-US regions. Myriad Protocol is an onchain venue on the Abstract L2 with - no documented geographic restrictions. This bench probes each venue's market listing endpoint every - 15 minutes from probe nodes in EU West (VPS Paris), US East, and Singapore and records the HTTP - response code. A 200 OK counts as accessible; a 403, redirect to a block page, or connection reset - counts as blocked. The result is expressed as the percentage of probe time the endpoint returned 200 - from each region. The bench does not assess legal access rights, only observable HTTP-level behavior. - -methodology: - - "Probe design: the harness sends an HTTP GET to each venue's public market listing endpoint from dedicated probe nodes (EU West VPS Paris, US East and Singapore on Railway) every 15 minutes. The request carries an identifying User-Agent and no VPN or proxy. HTTP status codes are recorded." - - "Blocked classification: a response is classified as blocked if the HTTP status code is not 200 (403, 4xx, 5xx, or a redirect to a block page). Timeouts over 10 seconds are classified as unreachable (also blocked). Only 200 OK counts as accessible." - - "Metric definition: pm_geo_accessible{venue, region} = 1 when the probe returns 200 OK, 0 otherwise. The displayed pct is the avg_over_time of this gauge over the trailing 24 hours, representing the fraction of the day the venue was accessible from that probe region." - - "Legal access vs HTTP access: this bench measures what the API returns to an HTTP request, not what a person in a given jurisdiction is legally permitted to do. A 200 OK from a probe on a venue's endpoint does not mean users in that region are legally permitted to trade. Consult venue terms of service and qualified legal counsel for legal access questions." - - "Regulatory events: when a regulatory event changes HTTP-level access (such as the Polymarket US geoblock in 2022), the probe series reflects the change within one probe cycle (15 minutes)." - -findings: - - "Polymarket's international exchange (polymarket.com) geoblocks US IP addresses following a 2022 CFTC settlement in which Polymarket LLC paid a $1.4M fine. US probes receive a 403 or redirect. The international exchange scores below 100 pct by design on this bench." - - "Polymarket US (QCX), a separate CFTC-regulated entity, launched December 3, 2025 and is accessible to US users. It initially launched iOS-only with a waitlist; the waitlist dropped in May 2026." - - "Kalshi (Kalshi Exchange LLC) is CFTC-regulated and accessible to US users from all US probe regions. It is the primary regulated US-accessible real-money prediction market." - - "Minnesota enacted a law banning prediction markets effective August 1, 2026. The CFTC is challenging this law in court. The bench monitors Minnesota-region probes for HTTP-level changes as the legal challenge progresses." - -disclaimer: "Geographic access status changes as regulatory environments evolve. Verify current access directly with each venue before depositing funds. This bench measures API-level HTTP responses, not legal access rights." - -source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/pm-geographic-access - -prometheus: - window: 24h - expected_freshness_seconds: 1800 - -rank_matrix_query: 100 * label_replace(avg by (venue) (avg_over_time(pm_geo_accessible[1h])), "provider", "$1", "venue", "(.+)") - -dimensions: - region: - - { value: all, label: All regions } - - { value: eu-west, label: EU West } - - { value: us-east, label: US East } - - { value: sgp, label: Singapore } - -metric_panels: - - id: accessible_eu - label: EU West accessible - description: "Fraction of probe cycles from EU West (Paris VPS) where the venue's market listing endpoint returned HTTP 200 OK in the last 24 hours." - metric: 100 * avg_over_time(pm_geo_accessible{region="eu-west"}[24h]) - label_key: venue - unit: pct - higher_is_better: true - - id: accessible_us - label: US East accessible - description: "Fraction of probe cycles from US East where the venue's market listing endpoint returned HTTP 200 OK. Polymarket international exchange blocks US IPs by design." - metric: 100 * avg_over_time(pm_geo_accessible{region="us-east"}[24h]) - label_key: venue - unit: pct - higher_is_better: true - - id: accessible_sgp - label: Singapore accessible - description: "Fraction of probe cycles from Singapore where the venue's market listing endpoint returned HTTP 200 OK." - metric: 100 * avg_over_time(pm_geo_accessible{region="sgp"}[24h]) - label_key: venue - unit: pct - higher_is_better: true - -providers: - - slug: polymarket - name: Polymarket - tag: International exchange geoblocked for US IPs since 2022 CFTC settlement - formula: "Fraction of 15-minute probe cycles returning HTTP 200 OK from each probe region, averaged over 24 hours. US probes return 403 by design (CFTC settlement geoblock)." - queries: - p50: 100 * avg_over_time(pm_geo_accessible{venue="polymarket"}[24h]) - p90: 100 * avg_over_time(pm_geo_accessible{venue="polymarket",region="eu-west"}[24h]) - p99: 100 * avg_over_time(pm_geo_accessible{venue="polymarket",region="eu-west"}[24h]) - mean: 100 * avg_over_time(pm_geo_accessible{venue="polymarket"}[24h]) - success: vector(1) - sample_size: increase(pm_geo_probe_total{venue="polymarket"}[24h]) - series: 100 * avg_over_time(pm_geo_accessible{venue="polymarket"}[1h]) - - - slug: kalshi - name: Kalshi - tag: CFTC-regulated, accessible to US users, all regions - formula: "Fraction of 15-minute probe cycles returning HTTP 200 OK from each probe region, averaged over 24 hours." - queries: - p50: 100 * avg_over_time(pm_geo_accessible{venue="kalshi"}[24h]) - p90: 100 * avg_over_time(pm_geo_accessible{venue="kalshi",region="eu-west"}[24h]) - p99: 100 * avg_over_time(pm_geo_accessible{venue="kalshi",region="eu-west"}[24h]) - mean: 100 * avg_over_time(pm_geo_accessible{venue="kalshi"}[24h]) - success: vector(1) - sample_size: increase(pm_geo_probe_total{venue="kalshi"}[24h]) - series: 100 * avg_over_time(pm_geo_accessible{venue="kalshi"}[1h]) - - - slug: limitless - name: Limitless - tag: CLOB venue, no documented geographic restrictions - formula: "Fraction of 15-minute probe cycles returning HTTP 200 OK from each probe region, averaged over 24 hours." - queries: - p50: 100 * avg_over_time(pm_geo_accessible{venue="limitless"}[24h]) - p90: 100 * avg_over_time(pm_geo_accessible{venue="limitless",region="eu-west"}[24h]) - p99: 100 * avg_over_time(pm_geo_accessible{venue="limitless",region="eu-west"}[24h]) - mean: 100 * avg_over_time(pm_geo_accessible{venue="limitless"}[24h]) - success: vector(1) - sample_size: increase(pm_geo_probe_total{venue="limitless"}[24h]) - series: 100 * avg_over_time(pm_geo_accessible{venue="limitless"}[1h]) - - - slug: forecastex - name: ForecastEx - tag: CFTC-regulated by Interactive Brokers, US users only - formula: "Fraction of 15-minute probe cycles returning HTTP 200 OK from each probe region, averaged over 24 hours." - queries: - p50: 100 * avg_over_time(pm_geo_accessible{venue="forecastex"}[24h]) - p90: 100 * avg_over_time(pm_geo_accessible{venue="forecastex",region="eu-west"}[24h]) - p99: 100 * avg_over_time(pm_geo_accessible{venue="forecastex",region="eu-west"}[24h]) - mean: 100 * avg_over_time(pm_geo_accessible{venue="forecastex"}[24h]) - success: vector(1) - sample_size: increase(pm_geo_probe_total{venue="forecastex"}[24h]) - series: 100 * avg_over_time(pm_geo_accessible{venue="forecastex"}[1h]) - - - slug: myriad - name: Myriad - tag: Single-region US East, no documented geographic restrictions - formula: "Fraction of 15-minute probe cycles returning HTTP 200 OK from each probe region, averaged over 24 hours." - queries: - p50: 100 * avg_over_time(pm_geo_accessible{venue="myriad"}[24h]) - p90: 100 * avg_over_time(pm_geo_accessible{venue="myriad",region="eu-west"}[24h]) - p99: 100 * avg_over_time(pm_geo_accessible{venue="myriad",region="eu-west"}[24h]) - mean: 100 * avg_over_time(pm_geo_accessible{venue="myriad"}[24h]) - success: vector(1) - sample_size: increase(pm_geo_probe_total{venue="myriad"}[24h]) - series: 100 * avg_over_time(pm_geo_accessible{venue="myriad"}[1h]) diff --git a/harnesses/pm-fee-comparison/Dockerfile b/harnesses/pm-fee-comparison/Dockerfile deleted file mode 100644 index 01d7ac64..00000000 --- a/harnesses/pm-fee-comparison/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -FROM golang:1.24-alpine AS builder - -WORKDIR /app -RUN apk add --no-cache git - -COPY go.mod go.sum* ./ -RUN go mod download || true - -COPY . . - -RUN CGO_ENABLED=0 GOOS=linux go build -o /app/pm-fee-comparison ./cmd/script - -FROM debian:bookworm-slim - -WORKDIR /app -RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* - -COPY --from=builder /app/pm-fee-comparison /app/pm-fee-comparison - -EXPOSE 2112 - -CMD ["/app/pm-fee-comparison"] diff --git a/harnesses/pm-fee-comparison/cmd/script/fees.go b/harnesses/pm-fee-comparison/cmd/script/fees.go deleted file mode 100644 index f735f40f..00000000 --- a/harnesses/pm-fee-comparison/cmd/script/fees.go +++ /dev/null @@ -1,140 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "sort" - "time" -) - -const ( - feesLimitlessBase = "https://api.limitless.exchange" - feesUA = "OpenChainBench/1.0 (+https://openchainbench.com/methodology; contact@mobula.io)" -) - -var httpClientFees = &http.Client{Timeout: 20 * time.Second} - -// setStaticFees publishes known fixed fees once at startup. These are refreshed -// only when the published fee schedules change (manual update required). -func setStaticFees() { - now := float64(time.Now().Unix()) - - // Polymarket: 2% fee deducted from potential payout ($1 resolution). - // Fee = 2% × $1 = $0.02/contract → 200 bps of $1 gross notional. - pmFeeTakerBps.WithLabelValues("polymarket").Set(200) - pmFeeLastRefresh.WithLabelValues("polymarket").Set(now) - - // Kalshi: per-contract fee schedule, approximately $0.07/contract at $0.50 price. - // $0.07 / $1 resolution value = 7% = 700 bps. Source: kalshi.com published schedule. - pmFeeTakerBps.WithLabelValues("kalshi").Set(700) - pmFeeLastRefresh.WithLabelValues("kalshi").Set(now) - - // Manifold: play-money (mana) only, no real-money fee. - pmFeeTakerBps.WithLabelValues("manifold").Set(0) - pmFeeLastRefresh.WithLabelValues("manifold").Set(now) - - // Myriad: 2% protocol settlement fee = 200 bps. - pmFeeTakerBps.WithLabelValues("myriad").Set(200) - pmFeeLastRefresh.WithLabelValues("myriad").Set(now) - - fmt.Println("[fees] static: polymarket=200 kalshi=700 manifold=0 myriad=200 (bps)") -} - -// tradePricesSlice deserializes tradePrices.buy.market / tradePrices.sell.market. -// Each slice element is the price for one outcome (YES=index 0, NO=index 1). -type tradePricesBranch struct { - Market []float64 `json:"market"` -} - -type tradePrices struct { - Buy tradePricesBranch `json:"buy"` - Sell tradePricesBranch `json:"sell"` -} - -type limitlessFeeMarket struct { - Status string `json:"status"` - TradePrices tradePrices `json:"tradePrices"` - CollateralToken struct { - Symbol string `json:"symbol"` - } `json:"collateralToken"` - WinningOutcomeIndex *int `json:"winningOutcomeIndex"` -} - -type limitlessFeeResp struct { - Data []limitlessFeeMarket `json:"data"` -} - -// fetchLimitlessFee polls /markets/active page 1, computes the median -// half-spread in basis points across active USDC binary markets, and sets -// the pm_fee_taker_bps{venue="limitless"} gauge. -// -// Half-spread bps = ((bestAsk - bestBid) / 2) × 10000, where prices are -// expressed as fractions of the $1 resolution value. This is directly -// comparable to Polymarket's 200 bps (2% of $1 payout). -func fetchLimitlessFee() { - url := fmt.Sprintf("%s/markets/active?page=1&limit=25&sortBy=newest", feesLimitlessBase) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("User-Agent", feesUA) - req.Header.Set("Accept", "application/json") - - resp, err := httpClientFees.Do(req) - if err != nil { - pmFeeFetchErrors.WithLabelValues("limitless", "network").Inc() - fmt.Printf("[fees][limitless] request error: %v\n", err) - return - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - - if resp.StatusCode != 200 { - pmFeeFetchErrors.WithLabelValues("limitless", fmt.Sprintf("http_%d", resp.StatusCode)).Inc() - fmt.Printf("[fees][limitless] status %d\n", resp.StatusCode) - return - } - - var r limitlessFeeResp - if err := json.Unmarshal(body, &r); err != nil { - pmFeeFetchErrors.WithLabelValues("limitless", "parse").Inc() - fmt.Printf("[fees][limitless] parse error: %v\n", err) - return - } - - var halfSpreads []float64 - for _, m := range r.Data { - if m.CollateralToken.Symbol != "USDC" { - continue - } - if m.WinningOutcomeIndex != nil { - continue - } - buy := m.TradePrices.Buy.Market - sell := m.TradePrices.Sell.Market - if len(buy) == 0 || len(sell) == 0 { - continue - } - ask := buy[0] - bid := sell[0] - // Sanity: bid < ask, both in (0, 1) - if bid <= 0 || ask <= 0 || bid >= ask || ask > 1 { - continue - } - halfSpreadBps := ((ask - bid) / 2) * 10000 - halfSpreads = append(halfSpreads, halfSpreadBps) - } - - if len(halfSpreads) == 0 { - fmt.Printf("[fees][limitless] no valid spread data (markets=%d)\n", len(r.Data)) - return - } - - sort.Float64s(halfSpreads) - median := halfSpreads[len(halfSpreads)/2] - - pmFeeTakerBps.WithLabelValues("limitless").Set(median) - pmFeeLastRefresh.WithLabelValues("limitless").Set(float64(time.Now().Unix())) - - fmt.Printf("[fees][limitless] markets_with_spread=%d median_half_spread_bps=%.1f\n", - len(halfSpreads), median) -} diff --git a/harnesses/pm-fee-comparison/cmd/script/main.go b/harnesses/pm-fee-comparison/cmd/script/main.go deleted file mode 100644 index 733b4394..00000000 --- a/harnesses/pm-fee-comparison/cmd/script/main.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/signal" - "syscall" - "time" -) - -func main() { - fmt.Println("=== pm-fee-comparison harness ===") - fmt.Println("Effective taker fees in basis points per venue. Static for most venues; live spread for Limitless.") - fmt.Println("Exposes /metrics on :2112.") - - go func() { - if err := StartMetricsServer(":2112"); err != nil { - fmt.Printf("[pm-fee-comparison] metrics server error: %v\n", err) - os.Exit(1) - } - }() - - // Set static fees immediately at startup. - setStaticFees() - // Fetch Limitless live spread at startup then every hour. - fetchLimitlessFee() - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - ticker := time.NewTicker(1 * time.Hour) - defer ticker.Stop() - - for { - select { - case <-sigChan: - fmt.Println("[pm-fee-comparison] shutting down") - return - case <-ticker.C: - fetchLimitlessFee() - } - } -} diff --git a/harnesses/pm-fee-comparison/cmd/script/metrics.go b/harnesses/pm-fee-comparison/cmd/script/metrics.go deleted file mode 100644 index 46a921a4..00000000 --- a/harnesses/pm-fee-comparison/cmd/script/metrics.go +++ /dev/null @@ -1,58 +0,0 @@ -package main - -import ( - "net/http" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" -) - -var ( - // pm_fee_taker_bps{venue} is the effective taker fee in basis points - // of gross notional ($1 resolution value per contract). - // - // Static venues (set once at startup): - // polymarket = 200 bps (flat 2% of potential payout) - // kalshi = 700 bps (approx $0.07/contract at $0.50; from published schedule) - // manifold = 0 bps (play-money, no real fee) - // myriad = 200 bps (2% protocol settlement fee) - // - // Live venue (refreshed hourly): - // limitless = median half-spread across active USDC markets (from tradePrices API) - pmFeeTakerBps = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "pm_fee_taker_bps", - Help: "Effective taker fee in basis points of gross notional ($1 resolution value per contract). Polymarket=200 (2% of payout, flat). Kalshi=700 (approx $0.07/contract at ATM, from published schedule). Limitless=live median half-spread across active USDC markets. Manifold=0 (play-money). Myriad=200 (2% settlement protocol fee).", - }, - []string{"venue"}, - ) - - pmFeeLastRefresh = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "pm_fee_last_refresh_seconds", - Help: "Unix timestamp of the last successful fee measurement per venue.", - }, - []string{"venue"}, - ) - - pmFeeFetchErrors = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "pm_fee_fetch_errors_total", - Help: "Total fetch failures per venue and error type.", - }, - []string{"venue", "error_type"}, - ) -) - -func init() { - prometheus.MustRegister(pmFeeTakerBps, pmFeeLastRefresh, pmFeeFetchErrors) -} - -func StartMetricsServer(addr string) error { - mux := http.NewServeMux() - mux.Handle("/metrics", promhttp.Handler()) - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("OK")) - }) - return http.ListenAndServe(addr, mux) -} diff --git a/harnesses/pm-fee-comparison/go.mod b/harnesses/pm-fee-comparison/go.mod deleted file mode 100644 index 1c3a0392..00000000 --- a/harnesses/pm-fee-comparison/go.mod +++ /dev/null @@ -1,17 +0,0 @@ -module github.com/mobula/pm-fee-comparison - -go 1.24 - -require github.com/prometheus/client_golang v1.20.5 - -require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/klauspost/compress v1.17.9 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.55.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect - golang.org/x/sys v0.22.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect -) diff --git a/harnesses/pm-fee-comparison/go.sum b/harnesses/pm-fee-comparison/go.sum deleted file mode 100644 index d5318cf8..00000000 --- a/harnesses/pm-fee-comparison/go.sum +++ /dev/null @@ -1,24 +0,0 @@ -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= -github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= diff --git a/harnesses/pm-geographic-access/Dockerfile b/harnesses/pm-geographic-access/Dockerfile deleted file mode 100644 index a2cdfb58..00000000 --- a/harnesses/pm-geographic-access/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -FROM golang:1.24-alpine AS builder - -WORKDIR /app -RUN apk add --no-cache git - -COPY go.mod go.sum* ./ -RUN go mod download || true - -COPY . . - -RUN CGO_ENABLED=0 GOOS=linux go build -o /app/pm-geographic-access ./cmd/script - -FROM debian:bookworm-slim - -WORKDIR /app -RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* - -COPY --from=builder /app/pm-geographic-access /app/pm-geographic-access - -EXPOSE 2112 - -CMD ["/app/pm-geographic-access"] diff --git a/harnesses/pm-geographic-access/cmd/script/main.go b/harnesses/pm-geographic-access/cmd/script/main.go deleted file mode 100644 index 945e8af5..00000000 --- a/harnesses/pm-geographic-access/cmd/script/main.go +++ /dev/null @@ -1,72 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/signal" - "strings" - "syscall" - "time" -) - -func main() { - region := loadRegion() - fmt.Printf("=== pm-geographic-access harness (region=%s) ===\n", region) - fmt.Println("HTTP probes to each venue's market listing endpoint. 200 OK = accessible, 4xx/error = blocked.") - fmt.Println("Exposes /metrics on :2112.") - - go func() { - if err := StartMetricsServer(":2112"); err != nil { - fmt.Printf("[pm-geographic-access] metrics server error: %v\n", err) - os.Exit(1) - } - }() - - // First probe immediately at startup. - runAllProbes(region) - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - ticker := time.NewTicker(15 * time.Minute) - defer ticker.Stop() - - for { - select { - case <-sigChan: - fmt.Println("[pm-geographic-access] shutting down") - return - case <-ticker.C: - runAllProbes(region) - } - } -} - -// loadRegion reads the probe region from env vars. Same resolution order -// as pm-rate-limits so the Prometheus label space is consistent. -func loadRegion() string { - if r := strings.TrimSpace(os.Getenv("REGION")); r != "" { - return r - } - if r := normalizeRailwayRegion(os.Getenv("RAILWAY_REPLICA_REGION")); r != "" { - return r - } - return "eu-west" -} - -func normalizeRailwayRegion(raw string) string { - raw = strings.ToLower(strings.TrimSpace(raw)) - if raw == "" { - return "" - } - switch { - case strings.HasPrefix(raw, "us-"), strings.HasPrefix(raw, "northamerica"): - return "us-east" - case strings.HasPrefix(raw, "europe"), strings.HasPrefix(raw, "eu-"): - return "eu-west" - case strings.HasPrefix(raw, "asia"), strings.HasPrefix(raw, "ap-"): - return "sgp" - default: - return raw - } -} diff --git a/harnesses/pm-geographic-access/cmd/script/metrics.go b/harnesses/pm-geographic-access/cmd/script/metrics.go deleted file mode 100644 index 47d0ff8a..00000000 --- a/harnesses/pm-geographic-access/cmd/script/metrics.go +++ /dev/null @@ -1,58 +0,0 @@ -package main - -import ( - "net/http" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" -) - -var ( - // pm_geo_accessible{venue, region} = 1 if the venue's public market - // listing endpoint returned HTTP 200 from the probe region, 0 otherwise. - // Probe frequency: every 15 minutes. - pmGeoAccessible = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "pm_geo_accessible", - Help: "1 if the venue's public market listing endpoint returned HTTP 200 from the probe region, 0 if blocked (403), redirected to a block page, or timed out. Probed every 15 minutes. Does not assess legal access rights.", - }, - []string{"venue", "region"}, - ) - - pmGeoLastRefresh = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "pm_geo_last_refresh_seconds", - Help: "Unix timestamp of the last probe attempt per venue and region.", - }, - []string{"venue", "region"}, - ) - - pmGeoProbeTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "pm_geo_probe_total", - Help: "Total probe attempts per venue and region.", - }, - []string{"venue", "region"}, - ) - - pmGeoFetchErrors = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "pm_geo_fetch_errors_total", - Help: "Total probe failures (network errors, timeouts) per venue and error type.", - }, - []string{"venue", "error_type"}, - ) -) - -func init() { - prometheus.MustRegister(pmGeoAccessible, pmGeoLastRefresh, pmGeoProbeTotal, pmGeoFetchErrors) -} - -func StartMetricsServer(addr string) error { - mux := http.NewServeMux() - mux.Handle("/metrics", promhttp.Handler()) - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("OK")) - }) - return http.ListenAndServe(addr, mux) -} diff --git a/harnesses/pm-geographic-access/cmd/script/probe.go b/harnesses/pm-geographic-access/cmd/script/probe.go deleted file mode 100644 index f32c45ce..00000000 --- a/harnesses/pm-geographic-access/cmd/script/probe.go +++ /dev/null @@ -1,109 +0,0 @@ -package main - -import ( - "fmt" - "io" - "net/http" - "time" -) - -const ( - probeUA = "OpenChainBench/1.0 (+https://openchainbench.com/methodology; contact@mobula.io)" - probeTimeout = 10 * time.Second -) - -type venueProbe struct { - Venue string - URL string -} - -// venueProbes is the list of market-listing endpoints to probe. -// Each URL returns a 200 when the venue is accessible from the probe region, -// a 403 when geo-blocked, or a redirect to a block page. -// -// Manifold is intentionally excluded: it is play-money (no real money at -// stake) and has no documented geographic restrictions — it would always -// score 100% from every region, making it uninteresting for this bench. -// ForecastEx (Interactive Brokers CFTC-regulated exchange) is included -// because it is US-regulated and may exhibit geo restrictions from non-US -// regions, mirroring the Kalshi vs Polymarket story for a third regulated venue. -var venueProbes = []venueProbe{ - { - Venue: "polymarket", - URL: "https://gamma-api.polymarket.com/markets?closed=false&active=true&limit=1", - }, - { - Venue: "kalshi", - URL: "https://api.elections.kalshi.com/trade-api/v2/markets/?limit=1", - }, - { - Venue: "limitless", - URL: "https://api.limitless.exchange/markets/active?page=1&limit=1&sortBy=newest", - }, - { - Venue: "myriad", - URL: "https://api-v2.myriadprotocol.com/markets?state=open&limit=1", - }, - { - // ForecastEx (Interactive Brokers CFTC-regulated exchange). The root - // domain returns 200 when accessible, or a geo-block response for - // regions where IBKR restricts access. No public unauthenticated API - // endpoint exists; we probe the market listing page directly. - Venue: "forecastex", - URL: "https://forecastex.com/markets", - }, -} - -var httpClientProbe = &http.Client{ - Timeout: probeTimeout, - // Follow redirects but detect block pages (redirect to geo-block URL). - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if len(via) >= 5 { - return http.ErrUseLastResponse - } - return nil - }, -} - -// runAllProbes fires one HTTP GET per venue and updates the accessibility gauge. -func runAllProbes(region string) { - for _, p := range venueProbes { - runProbe(p, region) - } -} - -func runProbe(p venueProbe, region string) { - pmGeoProbeTotal.WithLabelValues(p.Venue, region).Inc() - - req, err := http.NewRequest("GET", p.URL, nil) - if err != nil { - pmGeoAccessible.WithLabelValues(p.Venue, region).Set(0) - pmGeoFetchErrors.WithLabelValues(p.Venue, "request_build").Inc() - return - } - req.Header.Set("User-Agent", probeUA) - req.Header.Set("Accept", "application/json") - - resp, err := httpClientProbe.Do(req) - if err != nil { - pmGeoAccessible.WithLabelValues(p.Venue, region).Set(0) - pmGeoLastRefresh.WithLabelValues(p.Venue, region).Set(float64(time.Now().Unix())) - pmGeoFetchErrors.WithLabelValues(p.Venue, "network").Inc() - fmt.Printf("[geo][%s][%s] network error: %v\n", p.Venue, region, err) - return - } - defer resp.Body.Close() - // Drain body to reuse connection. - _, _ = io.Copy(io.Discard, resp.Body) - - accessible := 0.0 - if resp.StatusCode == 200 { - accessible = 1.0 - } - - pmGeoAccessible.WithLabelValues(p.Venue, region).Set(accessible) - pmGeoLastRefresh.WithLabelValues(p.Venue, region).Set(float64(time.Now().Unix())) - - fmt.Printf("[geo][%s][%s] status=%d accessible=%.0f\n", - p.Venue, region, resp.StatusCode, accessible) -} diff --git a/harnesses/pm-geographic-access/go.mod b/harnesses/pm-geographic-access/go.mod deleted file mode 100644 index 5ce85e9d..00000000 --- a/harnesses/pm-geographic-access/go.mod +++ /dev/null @@ -1,17 +0,0 @@ -module github.com/mobula/pm-geographic-access - -go 1.24 - -require github.com/prometheus/client_golang v1.20.5 - -require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/klauspost/compress v1.17.9 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.55.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect - golang.org/x/sys v0.22.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect -) diff --git a/harnesses/pm-geographic-access/go.sum b/harnesses/pm-geographic-access/go.sum deleted file mode 100644 index d5318cf8..00000000 --- a/harnesses/pm-geographic-access/go.sum +++ /dev/null @@ -1,24 +0,0 @@ -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= -github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= diff --git a/public/logos/predictit.svg b/public/logos/predictit.svg index 843795b6..4adac07c 100644 --- a/public/logos/predictit.svg +++ b/public/logos/predictit.svg @@ -1,4 +1,23 @@ - - - PI + + + + + + + + + + + + + + + + + + diff --git a/public/logos/smarkets.jpg b/public/logos/smarkets.jpg new file mode 100644 index 0000000000000000000000000000000000000000..81b82878132e88124c65a23ca59123c54f15bd1e GIT binary patch literal 87741 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 - - S - diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index f1691a86..96dcca7b 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -67,7 +67,7 @@ const RAW: Record = { manifold: "/logos/manifold.svg", myriad: "/logos/myriad.png", predictit: "/logos/predictit.svg", - smarkets: "/logos/smarkets.svg", + smarkets: "/logos/smarkets.jpg", // ─── Perp DEX cohort (perps hub /perps) ─── drift: "/logos/drift.png", From ba28bd703f3c2d91f5fd1377ffa2f86b8fad3d56 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:59:58 +0200 Subject: [PATCH 10/96] add trade pub lag to pm-ws-latency, drop pm-data-freshness bench and harness (#1665) --- benchmarks/pm-data-freshness.yml | 138 -------- benchmarks/pm-ws-latency.yml | 16 +- harnesses/pm-freshness-bench/.env.example | 18 - harnesses/pm-freshness-bench/Dockerfile | 13 - harnesses/pm-freshness-bench/README.md | 84 ----- .../pm-freshness-bench/cmd/script/codex.go | 323 ------------------ .../cmd/script/codex_auth.go | 151 -------- .../pm-freshness-bench/cmd/script/config.go | 43 --- .../cmd/script/correlator.go | 199 ----------- .../pm-freshness-bench/cmd/script/kalshi.go | 270 --------------- .../cmd/script/log_buffer.go | 58 ---- .../pm-freshness-bench/cmd/script/main.go | 142 -------- .../pm-freshness-bench/cmd/script/markets.go | 220 ------------ .../pm-freshness-bench/cmd/script/metrics.go | 59 ---- .../pm-freshness-bench/cmd/script/mobula.go | 198 ----------- .../cmd/script/polymarket.go | 216 ------------ harnesses/pm-freshness-bench/go.mod | 18 - harnesses/pm-freshness-bench/go.sum | 22 -- harnesses/pm-rate-limits/cmd/script/main.go | 3 + .../pm-rate-limits/cmd/script/metrics.go | 6 + .../cmd/script/trade_pub_lag.go | 126 +++++++ harnesses/pm-rate-limits/cmd/script/ws.go | 42 +++ 22 files changed, 192 insertions(+), 2173 deletions(-) delete mode 100644 benchmarks/pm-data-freshness.yml delete mode 100644 harnesses/pm-freshness-bench/.env.example delete mode 100644 harnesses/pm-freshness-bench/Dockerfile delete mode 100644 harnesses/pm-freshness-bench/README.md delete mode 100644 harnesses/pm-freshness-bench/cmd/script/codex.go delete mode 100644 harnesses/pm-freshness-bench/cmd/script/codex_auth.go delete mode 100644 harnesses/pm-freshness-bench/cmd/script/config.go delete mode 100644 harnesses/pm-freshness-bench/cmd/script/correlator.go delete mode 100644 harnesses/pm-freshness-bench/cmd/script/kalshi.go delete mode 100644 harnesses/pm-freshness-bench/cmd/script/log_buffer.go delete mode 100644 harnesses/pm-freshness-bench/cmd/script/main.go delete mode 100644 harnesses/pm-freshness-bench/cmd/script/markets.go delete mode 100644 harnesses/pm-freshness-bench/cmd/script/metrics.go delete mode 100644 harnesses/pm-freshness-bench/cmd/script/mobula.go delete mode 100644 harnesses/pm-freshness-bench/cmd/script/polymarket.go delete mode 100644 harnesses/pm-freshness-bench/go.mod delete mode 100644 harnesses/pm-freshness-bench/go.sum create mode 100644 harnesses/pm-rate-limits/cmd/script/trade_pub_lag.go diff --git a/benchmarks/pm-data-freshness.yml b/benchmarks/pm-data-freshness.yml deleted file mode 100644 index c6fb7bd1..00000000 --- a/benchmarks/pm-data-freshness.yml +++ /dev/null @@ -1,138 +0,0 @@ -# OpenChainBench. Bench № 032 - -slug: pm-data-freshness -number: "032" -title: Fastest prediction market data API, live freshness across venues -seo_title: "Fastest prediction market data API 2026" -seo_description: "Fastest prediction market data API ranked live across Polymarket and Kalshi. Milliseconds Codex and Predexon lag the venue gateway publish on top markets." -subtitle: Per event delay between provider arrival and the venue gateway publish, measured every minute on the top markets by 24 hour volume across Polymarket and Kalshi. - -category: Aggregators -status: live -metric: Freshness delta vs venue -unit: ms -higher_is_better: false - -seo_intro: | - Prediction markets generate the most time sensitive event stream in crypto. - An election market settles in seconds, a sports book moves on every play. - Builders that integrate Polymarket or Kalshi through a data provider - rather than hitting each venue directly need to know how many - milliseconds that provider adds between when the venue itself publishes - a trade and when the provider relays the same trade to its WebSocket - subscribers. This benchmark measures exactly that. The harness - subscribes to the same basket of top volume markets on each venue's own - canonical source (T0) and on Codex GraphQL subscriptions. Each trade - is cross correlated across the streams, and - the per provider lag versus the venue gateway publish time is recorded - as a Prometheus histogram. The leaderboard sorts by p50 freshness delta - in milliseconds, lower is better. Use the venue tab at the top of the - page to switch between Polymarket and Kalshi. - -abstract: | - Three WebSocket subscribers ride the same rotating basket of ~20 top - volume markets simultaneously on each venue. For every trade event - published on the venue's own canonical WebSocket gateway (the T0), we - record the moment it lands and the moment each provider relays the - same trade. The signature used to match a trade across providers is a - tuple based on the market identifier, price and size of the fill, and - a small time bucket, which is robust against the minor clock skew - between gateways. Providers that fail to relay a trade within 90 - seconds are not counted toward their p50, only toward their receive - total, so a provider can look fresh on the leaderboard while silently - dropping events. The success rate column flags that. Coverage today: - Codex on Polymarket and Kalshi. - -methodology: - - "Anchor rows: on each venue tab the venue itself (Polymarket, Kalshi) is the canonical T0, so its row reads as the harness network round trip, near zero by construction. It is kept on the board to document the reference point, not as a competing feed; the comparison rows are the data providers measured against it." - - "Polymarket T0. `wss://ws-subscriptions-clob.polymarket.com/ws/market` is public, no auth, sub 50ms gateway publish latency from EU West. Cross correlation uses (conditionId, priceUSD rounded to 3 decimals, sizeUSD micros, 5s time bucket)." - - "Kalshi T0. `https://api.elections.kalshi.com/v1/social/trades` (REST), polled every 5s with cursor pagination. Every trade carries `create_date` at microsecond precision; that timestamp is the canonical T0. Poll cadence affects only correlation timing, not the freshness number. The official Kalshi WebSocket needs RSA PSS signed headers from a US KYC account and 403s from non US IPs, not viable for a public benchmark." - - "Codex GraphQL subscriptions. `wss://graph.codex.io/graphql` with the `graphql-transport-ws` subprotocol. Firehose `onPredictionTradesCreated` subscription filtered client side to the venue's marketIds. Covers both Polymarket and Kalshi." - - "Basket. Top 20 active markets by 24h volume per venue, refreshed every 5 minutes. Polymarket pulls from `gamma-api.polymarket.com`, Kalshi pulls from `api.elections.kalshi.com/trade-api/v2/markets`." - - "Histogram buckets: 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000 ms. The venue's own arrivals always land in the smallest bucket because by construction we measure other providers against it." - - "Region. `eu-west` (Railway europe-west4). Both venue gateways are geographically distributed; deltas reflect EU client to EU gateway latency." - - "Venue clock asymmetry. Polymarket CLOB trades carry no venue timestamp so T0 is when our harness receives the WS event; the Polymarket-to-harness RTT cancels inside the Codex delta. Kalshi's REST `create_date` is venue-side so the Codex Kalshi delta includes a Codex-to-harness RTT (50 to 100 ms) the Polymarket math cancels out. Kalshi rows are slightly stricter for the same provider, not more favorable; the gap is small versus the multi-second deltas the chart highlights." - -findings: - - "Each venue's own gateway is by definition the freshest source on the leaderboard for that venue. The lag versus its own publish time is on the order of the network round trip from the harness to the gateway, typically below 100 ms p50 from EU West." - - "{{name:codex}} sits at p50 {{p50:codex}} on the Polymarket tab because Codex indexes the chain event (Polygon block confirmation) rather than the off chain orderbook publish. The lag includes Polygon block time (~2s) plus ingestion. On Kalshi Codex ingests Kalshi's own WebSocket feed directly, so the delta is purely pipeline latency." - - "The spread between providers reflects integration depth: native gateway vs chain indexed pipeline. None of these is wrong, they answer different questions. For live trading UIs the gateway path is the only viable one." - -faq: - - q: "Which prediction market data API has the lowest latency right now?" - a: "{{best_name}} currently leads at {{best_p50}} (p50 over the last 24h) measured as time from the venue's own canonical WebSocket publish to provider relay. The leaderboard re sorts every minute on fresh Prometheus samples, so the answer reflects the actual measured lag on the active market basket, not a marketing claim. Switch the venue tab at the top to see Polymarket vs Kalshi independently." - - q: "What does 'freshness delta' mean for a prediction market API?" - a: "We connect to the venue's own canonical WebSocket and to the provider's WebSocket simultaneously, subscribe to the same markets, and for every trade event we record how many milliseconds the provider takes to relay the event after the venue itself publishes it. Lower is better. The venue's own gateway publish time is the canonical T0 because by construction nothing downstream can be faster than the source." - - q: "Why don't you include Polymarket REST polling on this benchmark?" - a: "When the Polymarket tab is selected, freshness is a WebSocket question. REST polling at 1s would have a floor freshness around 500ms (poll interval / 2) plus RTT, dominated by how often you poll. The Polymarket gateway WebSocket exists for exactly this reason, to avoid that floor. Adding REST as a row would make the leaderboard noisy without changing the conclusion: WebSocket beats polling by definition for real time data." - - q: "What does the Kalshi T0 source measure?" - a: "Kalshi's own public WebSocket at `wss://external-api-ws.kalshi.com/trade-api/ws/v2`, channel `trade`. The harness reads the `msg.ts_ms` field of each fill as the canonical publish timestamp and uses it as T0 for every Kalshi side comparison. Just like Polymarket's CLOB WebSocket on the Polymarket tab, it is the source against which provider latency is measured." - - q: "Are these numbers comparable to Kalshi or Limitless?" - a: "Kalshi is now its own tab on this page, click the Venue selector at the top to switch. Each venue uses its own canonical WebSocket as T0, so the absolute numbers are not directly comparable across tabs (different gateways, different network paths), but the relative provider ordering within each tab is honest. Limitless is not yet covered, we will add a tab if and when meaningful provider coverage exists for it." - - q: "How does OpenChainBench measure freshness?" - a: "Three WebSocket clients run in parallel inside the harness, all subscribed to the same basket of top volume markets on the active venue. Every 5 minutes we refresh the basket from the venue's own markets API. For each trade event, we compute a signature based on market id, price, size and a 5 second time bucket, and record the wall clock receive time on each provider. The freshness delta is `recv_provider - recv_venue` for the same signature. We export the histogram to Prometheus, the leaderboard reads the 24h p50." - -source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/pm-freshness-bench - -prometheus: - window: 24h - expected_freshness_seconds: 300 - -# Venue selector. tabs at the top of the page. Server injects -# `venue="X"` into every PromQL query for the active tab. There is no -# "all" entry because cross venue averaging would mix two different -# canonical T0 gateways, which is not a meaningful comparison; the -# default landing tab is Polymarket (the first entry below). -dimensions: - venue: - - { value: polymarket, label: Polymarket } - - { value: kalshi, label: Kalshi } - -providers: - - slug: polymarket - name: Polymarket - tag: Native gateway WebSocket, no auth, sub 50ms publish latency - formula: "Polymarket gateway publish time is the canonical T0 on the Polymarket tab. The near zero row is the network round trip from the harness to the gateway, not a comparison against another source." - queries: - # Polymarket is T0 by construction on the Polymarket tab. Multiply - # the 0.5 ms floor by pm_health{provider="polymarket"} so the - # dimension framework's injected venue label gates the row: - # pm_health{provider="polymarket", venue="polymarket"} = 1 -> 0.5, - # pm_health{provider="polymarket", venue="kalshi"} = 0 -> 0, - # and the leaderboard hides empty rows. - p50: pm_health{provider="polymarket"} * 0.5 - p90: pm_health{provider="polymarket"} * 0.5 - p99: pm_health{provider="polymarket"} * 0.5 - mean: pm_health{provider="polymarket"} * 0.5 - success: avg_over_time(pm_health{provider="polymarket"}[24h]) - sample_size: sum(increase(pm_events_total{provider="polymarket"}[24h])) - series: pm_health{provider="polymarket"} * 0.5 - - - slug: kalshi - name: Kalshi - tag: Native venue REST, create_date is canonical T0 - formula: "Kalshi gateway publish time is the canonical T0 on the Kalshi tab. The near zero row is the network round trip from the harness to the Kalshi endpoint, not a comparison against another source." - queries: - # Symmetric to the Polymarket row, gated by pm_health{provider="kalshi"} - # so the venue label injected at runtime zeros out the row on the - # Polymarket tab. - p50: pm_health{provider="kalshi"} * 0.5 - p90: pm_health{provider="kalshi"} * 0.5 - p99: pm_health{provider="kalshi"} * 0.5 - mean: pm_health{provider="kalshi"} * 0.5 - success: avg_over_time(pm_health{provider="kalshi"}[24h]) - sample_size: sum(increase(pm_events_total{provider="kalshi"}[24h])) - series: pm_health{provider="kalshi"} * 0.5 - - - slug: codex - name: Codex - tag: Chain indexer on Polymarket, native ingestion on Kalshi - formula: "Median ms lag versus the venue gateway. On Polymarket Codex indexes the on chain Polygon settlement of each trade (block time ~2s plus ingestion). On Kalshi Codex ingests Kalshi's own WebSocket directly so the lag is pure pipeline latency." - queries: - p50: histogram_quantile(0.50, sum(rate(pm_freshness_delta_ms_bucket{provider="codex",kind="trade"}[24h])) by (le)) - p90: histogram_quantile(0.90, sum(rate(pm_freshness_delta_ms_bucket{provider="codex",kind="trade"}[24h])) by (le)) - p99: histogram_quantile(0.99, sum(rate(pm_freshness_delta_ms_bucket{provider="codex",kind="trade"}[24h])) by (le)) - mean: sum(rate(pm_freshness_delta_ms_sum{provider="codex",kind="trade"}[24h])) / sum(rate(pm_freshness_delta_ms_count{provider="codex",kind="trade"}[24h])) - success: clamp_max(sum(rate(pm_matched_total{provider="codex",kind="trade"}[24h])) / sum(rate(pm_events_total{provider="polymarket",kind="trade"}[24h])), 1) - sample_size: sum(increase(pm_matched_total{provider="codex",kind="trade"}[24h])) - series: histogram_quantile(0.50, sum(rate(pm_freshness_delta_ms_bucket{provider="codex",kind="trade"}[1h])) by (le)) diff --git a/benchmarks/pm-ws-latency.yml b/benchmarks/pm-ws-latency.yml index 992b388f..cd7accfb 100644 --- a/benchmarks/pm-ws-latency.yml +++ b/benchmarks/pm-ws-latency.yml @@ -5,7 +5,7 @@ number: "114" title: Polymarket WebSocket latency, live connection and update speed from 3 regions seo_title: "Polymarket WebSocket latency 2026: connection time and update speed" seo_description: "How fast does the Polymarket WebSocket connect and deliver market updates? Live p50 from us-east, eu-west and Singapore measured continuously by OpenChainBench." -subtitle: Time from WebSocket dial to first market snapshot plus inter-message update rate on the Polymarket CLOB feed, probed from three regions 24 hours a day. +subtitle: Connect-to-snapshot latency, inter-message update rate, and trade publication lag on the Polymarket and Kalshi WebSocket feeds, probed from three regions 24 hours a day. category: Trading status: live @@ -99,6 +99,20 @@ metric_panels: label_key: venue unit: ms higher_is_better: false + - id: trade_pub_lag_p50 + label: Trade pub lag p50 + description: "Median delay between venue server-side trade creation and WS subscriber receipt. Polymarket: CLOB WS timestamp field vs receive time (all regions). Kalshi: REST create_date vs WS ticker receive time (us-east only). Lower is better." + metric: 1000 * histogram_quantile(0.50, sum by (venue) (rate(pmapi_ws_trade_pub_lag_seconds_bucket{source="direct",venue=~"polymarket|kalshi"}[24h]))) + label_key: venue + unit: ms + higher_is_better: false + - id: trade_pub_lag_p90 + label: Trade pub lag p90 + description: "90th percentile trade publication lag. The tail cost a subscriber pays waiting for the venue to push a confirmed trade. Kalshi us-east only." + metric: 1000 * histogram_quantile(0.90, sum by (venue) (rate(pmapi_ws_trade_pub_lag_seconds_bucket{source="direct",venue=~"polymarket|kalshi"}[24h]))) + label_key: venue + unit: ms + higher_is_better: false - id: disconnect_rate label: Disconnects 24h description: "Number of WebSocket disconnects recorded across all regions in the last 24 hours. Kalshi probes us-east only; Polymarket probes all three regions." diff --git a/harnesses/pm-freshness-bench/.env.example b/harnesses/pm-freshness-bench/.env.example deleted file mode 100644 index 53c28cc7..00000000 --- a/harnesses/pm-freshness-bench/.env.example +++ /dev/null @@ -1,18 +0,0 @@ -# Codex (defined.fi) JWT scraping. Cookie rotated every ~7 days, see -# aggregator-latency-benchmark/CODEX_SESSION_GUIDE.md for refresh. -DEFINED_SESSION_COOKIE= - -# Webshare rotating proxy — required for Codex JWT mint (Vercel bans direct IPs). -HTTP_PROXY= -HTTPS_PROXY= - -# Mobula PM API key. Needs Growth or Enterprise plan for PM streams. -MOBULA_API_KEY= - -# Optional: token-gate /logs and /debug endpoints. -LOGS_TOKEN= - -# Optional: bench cadence. Defaults are sane. -# REFRESH_MARKETS_INTERVAL_SEC=300 # how often to re-poll gamma-api for the basket -# BASKET_SIZE=20 # how many top-vol markets to subscribe per provider -# KALSHI_POLL_INTERVAL_SEC=5 # /v1/social/trades cadence (CloudFront max-age=10) diff --git a/harnesses/pm-freshness-bench/Dockerfile b/harnesses/pm-freshness-bench/Dockerfile deleted file mode 100644 index d8ce6a93..00000000 --- a/harnesses/pm-freshness-bench/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM golang:1.24-alpine AS build -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -COPY cmd ./cmd -RUN CGO_ENABLED=0 GOOS=linux go build -o /out/script ./cmd/script - -FROM alpine:3.20 -RUN apk add --no-cache ca-certificates && update-ca-certificates -WORKDIR /app -COPY --from=build /out/script /app/script -EXPOSE 2112 -ENTRYPOINT ["/app/script"] diff --git a/harnesses/pm-freshness-bench/README.md b/harnesses/pm-freshness-bench/README.md deleted file mode 100644 index 1477a774..00000000 --- a/harnesses/pm-freshness-bench/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# pm-freshness-bench - -Bench №032 — prediction market data freshness across providers. - -Subscribes to the same basket of high-volume Polymarket markets on three -data providers simultaneously, cross-correlates each trade event by -`(conditionId, outcomeId, price, size, time-window)` and measures how -many milliseconds each provider lags the canonical Polymarket CLOB WS -gateway. - -## Providers (v2) - -The bench now compares per-venue. Polymarket events flow through 3 providers; -Kalshi events flow through 2 (no Mobula Kalshi coverage). - -| Venue | Provider | Endpoint | Auth | Notes | -|---|---|---|---|---| -| Polymarket | Polymarket CLOB | `wss://ws-subscriptions-clob.polymarket.com/ws/market` | none | Canonical T0 for Polymarket | -| Polymarket | Codex / defined.fi | `wss://graph.codex.io/graphql` | scraped JWT + Webshare proxy | Firehose, branch on marketId suffix | -| Polymarket | Mobula PM | `wss://pm-api-prod-eu.mobula.io` | API key | Browser UA required | -| Kalshi | Kalshi public REST | `https://api.elections.kalshi.com/v1/social/trades` | none | Canonical T0 via `create_date` (venue clock, µs precision) | -| Kalshi | Codex / defined.fi | (same firehose) | (same auth) | Filtered to `:Kalshi` marketIds | - -## Run locally - -```bash -cp .env.example .env -# fill in keys -go build ./cmd/script -./script -``` - -## Endpoints - -- `:2112/metrics` — Prometheus scrape (hardcoded — Railway $PORT ignored) -- `:2112/logs?tail=N` — last N log lines (token-gated by `LOGS_TOKEN` when set) - -## Metrics - -All metrics now carry a `venue` label (`polymarket` | `kalshi`). The T0 -reference clock per venue is the direct venue feed (`polymarket` for -Polymarket events, `kalshi` for Kalshi events). - -- `pm_freshness_delta_ms_bucket{provider, venue, kind}` histogram — per-event - delta vs that venue's T0, kind ∈ {trade, price} -- `pm_events_total{provider, venue, kind}` counter — raw events received -- `pm_matched_total{provider, venue, kind}` counter — events successfully matched to T0 -- `pm_health{provider, venue}` gauge — 1 if (provider, venue) published at least - one event in the last 60 s, else 0 -- `pm_fetch_errors_total{provider, venue, error_type}` counter -- `pm_basket_size{venue}` gauge - -## Kalshi support - -Kalshi runs as a second venue alongside Polymarket. Codex's -prediction-trades firehose already carries Kalshi trades; we just stopped -filtering them out. The Kalshi T0 (canonical publish time) comes from the -public `/v1/social/trades` REST endpoint — the same one that powers the -trade ticker on kalshi.com homepage. No account, no KYC, no key. - -The freshness measurement uses the `create_date` field embedded in the -Kalshi response, which is the venue's own publish timestamp at microsecond -precision. The poll cadence (default 5s, tunable via -`KALSHI_POLL_INTERVAL_SEC`) only governs when correlation happens, not -the measurement itself: a Codex relay event that lands at T+100ms is -still credited with a 100ms delta even if our poller surfaces the matching -Kalshi trade up to 5 seconds later. - -CloudFront fronts the endpoint with `max-age=10s`; polling faster than -~5s just hits cache. Tested live from Paris with 200ms RTT, no geo -block, no auth header required. - -## Methodology - -Every 5 minutes the harness polls `gamma-api.polymarket.com` for the top-20 -active markets by 24h volume, then opens / updates subscriptions on all -three providers simultaneously. Trades are indexed in memory for 60 s -after first arrival; deltas are computed against the earliest receive -time for that `(conditionId, outcomeId, price, size)` signature, which -is always the Polymarket T0 if Polymarket emitted the trade at all. - -Polymarket trades that no other provider relays in the 60 s window are -counted in `pm_events_total{provider="polymarket"}` but not in any -matched counter. diff --git a/harnesses/pm-freshness-bench/cmd/script/codex.go b/harnesses/pm-freshness-bench/cmd/script/codex.go deleted file mode 100644 index b4db8351..00000000 --- a/harnesses/pm-freshness-bench/cmd/script/codex.go +++ /dev/null @@ -1,323 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "sync" - "time" - - "nhooyr.io/websocket" -) - -const codexWS = "wss://graph.codex.io/graphql" - -func runCodex(ctx context.Context, cfg Config, basketCh <-chan []Market, kalshiBasketCh <-chan []Market) { - if cfg.DefinedSessionCookie == "" { - appendLog("[codex] DEFINED_SESSION_COOKIE not set — skipping Codex provider") - return - } - - // Codex uses its own composite marketId format. For Polymarket events: - // :Polymarket:: - // For Kalshi events the trailing segment is `:Kalshi` and the leading - // segment is the market_ticker. We branch on the suffix to pick venue - // and which basket to check against. - - var ( - cidMu sync.RWMutex - knownPoly = map[string]bool{} - knownKalshi = map[string]bool{} - ) - updateKnownPoly := func(ms []Market) { - cidMu.Lock() - defer cidMu.Unlock() - knownPoly = map[string]bool{} - for _, m := range ms { - knownPoly[m.ConditionId] = true - } - } - updateKnownKalshi := func(ms []Market) { - cidMu.Lock() - defer cidMu.Unlock() - knownKalshi = map[string]bool{} - for _, m := range ms { - knownKalshi[m.ConditionId] = true - } - } - isKnown := func(venue, cid string) bool { - cidMu.RLock() - defer cidMu.RUnlock() - if venue == "kalshi" { - return knownKalshi[cid] - } - return knownPoly[cid] - } - - updateKnownPoly(currentBasket()) - updateKnownKalshi(currentKalshiBasket()) - - // basket update consumers — Polymarket + Kalshi basket channels are - // independent because each venue has its own refresh loop. - go func() { - for { - select { - case <-ctx.Done(): - return - case ms := <-basketCh: - updateKnownPoly(ms) - case ms := <-kalshiBasketCh: - updateKnownKalshi(ms) - } - } - }() - - backoff := 10 * time.Second - consecutiveFails := 0 - for ctx.Err() == nil { - err := codexConnect(ctx, cfg, isKnown) - if ctx.Err() != nil { - return - } - consecutiveFails++ - errType := classify(err) - // Errors are reported on the polymarket venue to keep historical - // series; Codex transport errors don't have a per-venue semantic. - fetchErrors.WithLabelValues("codex", "polymarket", errType).Inc() - appendLog("[codex] disconnected: %v — backing off %v", err, backoff) - - // Mirror the head-lag bench's recovery rules. - if errType == "auth" || errType == "rate_limit" { - InvalidateCodexJWT() - backoff = 30 * time.Second - } - if consecutiveFails >= 10 { - InvalidateCodexJWT() - backoff = 10 * time.Second - consecutiveFails = 0 - } - - select { - case <-ctx.Done(): - return - case <-time.After(backoff): - } - if backoff < 60*time.Second { - backoff *= 2 - } - } -} - -func codexConnect(ctx context.Context, cfg Config, isKnown func(venue, cid string) bool) error { - jwt, err := GetCodexJWT(cfg.DefinedSessionCookie) - if err != nil { - return fmt.Errorf("mint: %w", err) - } - - opts := &websocket.DialOptions{ - Subprotocols: []string{"graphql-transport-ws"}, - HTTPHeader: http.Header{ - "Origin": []string{"https://www.defined.fi"}, - "User-Agent": []string{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0"}, - }, - } - conn, _, err := websocket.Dial(ctx, codexWS, opts) - if err != nil { - return fmt.Errorf("dial: %w", err) - } - defer conn.Close(websocket.StatusInternalError, "") - conn.SetReadLimit(8 * 1024 * 1024) - - init := map[string]interface{}{ - "type": "connection_init", - "payload": map[string]interface{}{"Authorization": "Bearer " + jwt}, - } - ib, _ := json.Marshal(init) - if err := conn.Write(ctx, websocket.MessageText, ib); err != nil { - return fmt.Errorf("init: %w", err) - } - _, ackB, err := conn.Read(ctx) - if err != nil { - return fmt.Errorf("ack read: %w", err) - } - var ack struct { - Type string `json:"type"` - } - json.Unmarshal(ackB, &ack) - if ack.Type != "connection_ack" { - return fmt.Errorf("expected connection_ack got %s", ack.Type) - } - appendLog("[codex] connection_ack") - - // Firehose subscription. We accept both Polymarket AND Kalshi trades - // client-side because Codex's input doesn't take a protocol filter on - // the subscription itself (only on the discovery query). The volume is - // manageable — observed ~33 msg/sec mixing both venues. - subQuery := `subscription Fire { onPredictionTradesCreated { marketId trades { timestamp transactionHash priceUsd amountUsd tradeType outcomeId outcomeLabel } } }` - subMsg := map[string]interface{}{ - "type": "subscribe", - "id": "firehose", - "payload": map[string]interface{}{"query": subQuery, "variables": map[string]interface{}{}}, - } - sb, _ := json.Marshal(subMsg) - if err := conn.Write(ctx, websocket.MessageText, sb); err != nil { - return fmt.Errorf("subscribe write: %w", err) - } - - pingCtx, pingCancel := context.WithCancel(ctx) - defer pingCancel() - go func() { - t := time.NewTicker(20 * time.Second) - defer t.Stop() - for { - select { - case <-pingCtx.Done(): - return - case <-t.C: - conn.Write(pingCtx, websocket.MessageText, []byte(`{"type":"ping"}`)) - } - } - }() - - for { - _, b, err := conn.Read(ctx) - if err != nil { - return err - } - var m struct { - Type string `json:"type"` - Id string `json:"id"` - Payload json.RawMessage `json:"payload"` - } - json.Unmarshal(b, &m) - if m.Type != "next" { - continue - } - var p struct { - Data struct { - OnPredictionTradesCreated struct { - MarketId string `json:"marketId"` - Trades []struct { - Timestamp int64 `json:"timestamp"` - TransactionHash string `json:"transactionHash"` - PriceUsd string `json:"priceUsd"` - AmountUsd string `json:"amountUsd"` - TradeType string `json:"tradeType"` - } `json:"trades"` - } `json:"onPredictionTradesCreated"` - } `json:"data"` - } - if err := json.Unmarshal(m.Payload, &p); err != nil { - continue - } - marketId := p.Data.OnPredictionTradesCreated.MarketId - venue, cid := codexParseMarketId(marketId) - logCodexMarketIDSample(marketId, venue, cid, isKnown(venue, cid)) - if cid == "" || venue == "" || !isKnown(venue, cid) { - continue // out-of-basket, unknown venue, or unparsable id - } - health.WithLabelValues("codex", venue).Set(1) - markAlive("codex", venue) - now := nowSec() - for _, t := range p.Data.OnPredictionTradesCreated.Trades { - eventsTotal.WithLabelValues("codex", venue, "trade").Inc() - sig := EventSig{ - Venue: venue, - ConditionId: cid, - PriceMilli: priceToMilli(t.PriceUsd), - BucketSec: bucketSec(float64(t.Timestamp)), - } - logSigSample("codex", sig) - correlator.Add(sig, Arrival{Provider: "codex", Kind: "trade", RecvUnix: now}) - } - } -} - -// codexShapesMu + codexShapesLogged track which composite marketId -// shapes we've already logged a sample for. The goal is to surface every -// distinct shape Codex's firehose can emit in the first few minutes after -// boot — particularly important for Kalshi where the suffix format wasn't -// verified live. We log each (venue, known) tuple once per unique cid -// prefix shape, capped at 50 samples total. -var ( - codexShapesMu sync.Mutex - codexShapesSeen = map[string]struct{}{} - codexShapesLogged int - codexShapesCap = 50 -) - -func logCodexMarketIDSample(marketId, venue, cid string, known bool) { - if marketId == "" { - return - } - codexShapesMu.Lock() - defer codexShapesMu.Unlock() - if codexShapesLogged >= codexShapesCap { - return - } - // Shape key = venue + known + shape(marketId). Shape collapses hex - // runs to "" and decimal runs to "" so we don't log every - // individual market. - shape := shapeMarketID(marketId) - key := fmt.Sprintf("%s|%t|%s", venue, known, shape) - if _, ok := codexShapesSeen[key]; ok { - return - } - codexShapesSeen[key] = struct{}{} - codexShapesLogged++ - appendLog("[codex-debug] marketId=%s venue=%q cid=%q known=%t (sample %d/%d, shape=%s)", - marketId, venue, cid, known, codexShapesLogged, codexShapesCap, shape) -} - -// shapeMarketID replaces hex and decimal runs with placeholders so two -// otherwise-equivalent marketIds collapse to one shape string. -func shapeMarketID(s string) string { - var b strings.Builder - i := 0 - for i < len(s) { - c := s[i] - switch { - case c == '0' && i+1 < len(s) && (s[i+1] == 'x' || s[i+1] == 'X'): - b.WriteString("") - i += 2 - for i < len(s) && isHexDigit(s[i]) { - i++ - } - case c >= '0' && c <= '9': - b.WriteString("") - for i < len(s) && s[i] >= '0' && s[i] <= '9' { - i++ - } - default: - b.WriteByte(c) - i++ - } - } - return b.String() -} - -func isHexDigit(c byte) bool { - return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') -} - -// codexParseMarketId splits Codex's composite marketId into (venue, id). -// Polymarket events look like 0x:Polymarket:0x: -// Kalshi events look like :Kalshi -// Returns ("", "") if the format isn't recognised. -func codexParseMarketId(s string) (venue string, id string) { - idx := strings.Index(s, ":") - if idx <= 0 { - return "", "" - } - id = strings.ToLower(s[:idx]) - rest := s[idx+1:] - switch { - case strings.HasPrefix(rest, "Polymarket"): - return "polymarket", id - case strings.HasPrefix(rest, "Kalshi"): - return "kalshi", id - } - return "", "" -} diff --git a/harnesses/pm-freshness-bench/cmd/script/codex_auth.go b/harnesses/pm-freshness-bench/cmd/script/codex_auth.go deleted file mode 100644 index 0720a305..00000000 --- a/harnesses/pm-freshness-bench/cmd/script/codex_auth.go +++ /dev/null @@ -1,151 +0,0 @@ -package main - -// JWT mint logic vendored from -// miniapps/aggregator-latency-benchmark/cmd/script/defined_auth.go -// keeping the same Webshare-proxy + 7-day-cookie flow. Reusing the working -// production pattern instead of re-inventing — the head-lag bench has been -// minting JWTs against defined.fi this way for months. - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "sync" - "time" -) - -type definedTokenResponse struct { - Data struct { - CreateApiTokens []struct { - Token string `json:"token"` - } `json:"createApiTokens"` - } `json:"data"` -} - -type definedTokenCache struct { - mu sync.RWMutex - token string - expiresAt time.Time - lastRefresh time.Time -} - -var codexTokenCache = &definedTokenCache{} - -func decodeJWTExpiration(token string) (time.Time, error) { - parts := strings.Split(token, ".") - if len(parts) != 3 { - return time.Time{}, fmt.Errorf("invalid JWT format") - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return time.Time{}, err - } - var claims struct { - Exp int64 `json:"exp"` - } - if err := json.Unmarshal(payload, &claims); err != nil { - return time.Time{}, err - } - if claims.Exp == 0 { - return time.Time{}, fmt.Errorf("no exp") - } - return time.Unix(claims.Exp, 0), nil -} - -// GetCodexJWT returns a cached short-lived JWT, minting a new one when the -// cached one is within 1h of expiry. -func GetCodexJWT(sessionCookie string) (string, error) { - codexTokenCache.mu.RLock() - if codexTokenCache.token != "" && time.Now().Before(codexTokenCache.expiresAt.Add(-1*time.Hour)) { - t := codexTokenCache.token - codexTokenCache.mu.RUnlock() - return t, nil - } - codexTokenCache.mu.RUnlock() - - codexTokenCache.mu.Lock() - defer codexTokenCache.mu.Unlock() - if codexTokenCache.token != "" && time.Now().Before(codexTokenCache.expiresAt.Add(-1*time.Hour)) { - return codexTokenCache.token, nil - } - tok, err := mintCodexJWT(sessionCookie) - if err != nil { - return "", err - } - exp, err := decodeJWTExpiration(tok) - if err != nil { - exp = time.Now().Add(24 * time.Hour) - } - codexTokenCache.token = tok - codexTokenCache.expiresAt = exp - codexTokenCache.lastRefresh = time.Now() - appendLog("[codex-auth] JWT refreshed, expires in %.1fh", time.Until(exp).Hours()) - return tok, nil -} - -func InvalidateCodexJWT() { - codexTokenCache.mu.Lock() - defer codexTokenCache.mu.Unlock() - codexTokenCache.token = "" - codexTokenCache.expiresAt = time.Time{} - appendLog("[codex-auth] JWT cache invalidated") -} - -func mintCodexJWT(sessionCookie string) (string, error) { - // CRITICAL: route through HTTP_PROXY / HTTPS_PROXY (Webshare). Direct - // Railway IPs get stuck in Vercel's bot-ban loop after a few mints. - tr := &http.Transport{DisableKeepAlives: true, Proxy: http.ProxyFromEnvironment} - client := &http.Client{Timeout: 15 * time.Second, Transport: tr} - - body := map[string]interface{}{ - "operationName": "CreateApiToken", - "query": "mutation CreateApiToken { createApiTokens(input: { count: 1 }) { token } }", - "variables": map[string]interface{}{}, - } - bb, _ := json.Marshal(body) - req, _ := http.NewRequest("POST", "https://www.defined.fi/api", bytes.NewBuffer(bb)) - req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "https://www.defined.fi") - req.Header.Set("Referer", "https://www.defined.fi/") - req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") - req.Header.Set("sec-ch-ua", `"Not_A Brand";v="8", "Chromium";v="131", "Google Chrome";v="131"`) - req.Header.Set("sec-ch-ua-mobile", "?0") - req.Header.Set("sec-ch-ua-platform", `"macOS"`) - req.Header.Set("sec-fetch-dest", "empty") - req.Header.Set("sec-fetch-mode", "cors") - req.Header.Set("sec-fetch-site", "same-origin") - req.AddCookie(&http.Cookie{Name: "session", Value: sessionCookie}) - - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - rb, _ := io.ReadAll(resp.Body) - if resp.StatusCode == 429 { - return "", fmt.Errorf("rate limited (429)") - } - if resp.StatusCode != 200 { - return "", fmt.Errorf("status=%d body=%s", resp.StatusCode, snippet(rb)) - } - var tr2 definedTokenResponse - if err := json.Unmarshal(rb, &tr2); err != nil { - return "", err - } - if len(tr2.Data.CreateApiTokens) == 0 { - return "", fmt.Errorf("no token in response") - } - return tr2.Data.CreateApiTokens[0].Token, nil -} - -func snippet(b []byte) string { - if len(b) > 200 { - return string(b[:200]) - } - return string(b) -} diff --git a/harnesses/pm-freshness-bench/cmd/script/config.go b/harnesses/pm-freshness-bench/cmd/script/config.go deleted file mode 100644 index fb5784e4..00000000 --- a/harnesses/pm-freshness-bench/cmd/script/config.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "os" - "strconv" - "strings" -) - -type Config struct { - DefinedSessionCookie string - MobulaApiKey string - LogsToken string - - // Kalshi T0 polls /v1/social/trades (public, unauthenticated). - // The default 5s cadence matches the upstream CloudFront max-age=10. - KalshiPollIntervalSec int - - RefreshMarketsIntervalSec int - BasketSize int -} - -func loadConfig() Config { - return Config{ - DefinedSessionCookie: strings.TrimSpace(os.Getenv("DEFINED_SESSION_COOKIE")), - MobulaApiKey: strings.TrimSpace(os.Getenv("MOBULA_API_KEY")), - LogsToken: strings.TrimSpace(os.Getenv("LOGS_TOKEN")), - KalshiPollIntervalSec: intEnv("KALSHI_POLL_INTERVAL_SEC", 5), - RefreshMarketsIntervalSec: intEnv("REFRESH_MARKETS_INTERVAL_SEC", 300), - BasketSize: intEnv("BASKET_SIZE", 20), - } -} - -func intEnv(key string, dflt int) int { - v := strings.TrimSpace(os.Getenv(key)) - if v == "" { - return dflt - } - n, err := strconv.Atoi(v) - if err != nil { - return dflt - } - return n -} diff --git a/harnesses/pm-freshness-bench/cmd/script/correlator.go b/harnesses/pm-freshness-bench/cmd/script/correlator.go deleted file mode 100644 index d825e265..00000000 --- a/harnesses/pm-freshness-bench/cmd/script/correlator.go +++ /dev/null @@ -1,199 +0,0 @@ -package main - -import ( - "fmt" - "math" - "strings" - "sync" - "time" -) - -// EventSig is the cross-provider signature we use to match a single trade -// across the 3 streams. After empirical sampling of live traffic we know: -// - transactionHash: not on Mobula's payload, can't use -// - outcomeId: encoding differs (Codex wraps in composite scheme) -// - amountUSD: SEMANTICS differ — Mobula appears to aggregate multiple -// fills into one event while Polymarket fires one event per fill, -// so the dollar amount on the same trade ranges 1.4× to 38× off -// -// What ALL three providers report consistently for the same market -// activity: conditionId + the 3-decimal trade price + a coarse 5s time -// bucket. That's the signature. Trade-off: two distinct trades in the -// same market at the same price within a 5-second window collapse into -// one key — acceptable because we record time-to-first-arrival per -// provider, which remains representative of the freshness even when -// collapsed. -type EventSig struct { - Venue string // "polymarket" or "kalshi" — distinct venues never match each other - ConditionId string // Polymarket conditionId OR Kalshi market_ticker, lowercased - PriceMilli int // priceUSD * 1000 rounded - BucketSec int64 // floor(trade time / 5s) — coarsens skew between providers -} - -func (s EventSig) Key() string { - v := s.Venue - if v == "" { - v = "polymarket" - } - return fmt.Sprintf("%s|%s|%d|%d", v, s.ConditionId, s.PriceMilli, s.BucketSec) -} - -// Arrival records one provider's receipt of an event keyed by EventSig. -type Arrival struct { - Provider string // "polymarket", "codex", "mobula" - Kind string // "trade" or "price" - RecvUnix float64 -} - -type Correlator struct { - mu sync.Mutex - // per-signature, per-provider earliest arrival within the retention window - byKey map[string]map[string]Arrival - // signature key -> T0 arrival time, where T0 is the direct-venue source - // (Polymarket CLOB for polymarket venue, Kalshi WS for kalshi venue). - // Used for fast delta computation when a follower reports later. - t0 map[string]float64 - // LRU eviction list (key + insertion time) - insertOrder []keyAt -} - -type keyAt struct { - Key string - At float64 -} - -const retention = 90.0 // seconds; trades that don't match within 90s are forgotten - -func NewCorrelator() *Correlator { - return &Correlator{ - byKey: map[string]map[string]Arrival{}, - t0: map[string]float64{}, - } -} - -// isT0Provider returns true when the given provider is the direct-venue -// source for the given venue (i.e. the reference clock we measure lag from). -func isT0Provider(provider, venue string) bool { - switch venue { - case "kalshi": - return provider == "kalshi" - default: - return provider == "polymarket" - } -} - -func (c *Correlator) Add(sig EventSig, a Arrival) { - c.mu.Lock() - defer c.mu.Unlock() - c.evictExpired(a.RecvUnix) - - venue := sig.Venue - if venue == "" { - venue = "polymarket" - } - - key := sig.Key() - if _, ok := c.byKey[key]; !ok { - c.byKey[key] = map[string]Arrival{} - c.insertOrder = append(c.insertOrder, keyAt{Key: key, At: a.RecvUnix}) - } - if existing, ok := c.byKey[key][a.Provider]; ok && existing.RecvUnix <= a.RecvUnix { - return - } - c.byKey[key][a.Provider] = a - - if isT0Provider(a.Provider, venue) { - c.t0[key] = a.RecvUnix - for prov, follower := range c.byKey[key] { - if isT0Provider(prov, venue) { - continue - } - observeDelta(prov, venue, follower.Kind, follower.RecvUnix-a.RecvUnix) - } - return - } - - if t0, ok := c.t0[key]; ok { - observeDelta(a.Provider, venue, a.Kind, a.RecvUnix-t0) - } -} - -func observeDelta(provider, venue, kind string, deltaSec float64) { - if deltaSec < 0 { - deltaSec = 0 - } - ms := deltaSec * 1000 - if math.IsNaN(ms) || math.IsInf(ms, 0) { - return - } - freshnessDelta.WithLabelValues(provider, venue, kind).Observe(ms) - matchedTotal.WithLabelValues(provider, venue, kind).Inc() -} - -func (c *Correlator) evictExpired(now float64) { - cutoff := now - retention - cut := 0 - for cut < len(c.insertOrder) && c.insertOrder[cut].At < cutoff { - k := c.insertOrder[cut].Key - delete(c.byKey, k) - delete(c.t0, k) - cut++ - } - if cut > 0 { - c.insertOrder = c.insertOrder[cut:] - } -} - -func priceToMilli(price string) int { - f := parseF(price) - return int(math.Round(f * 1000)) -} - -func amountToCents(s string) int64 { - return int64(math.Round(parseF(s) * 100)) -} - -func amountFloatToCents(f float64) int64 { - return int64(math.Round(f * 100)) -} - -func parseF(s string) float64 { - if s == "" { - return 0 - } - var f float64 - fmt.Sscanf(strings.TrimSpace(s), "%f", &f) - return f -} - -func bucketSec(t float64) int64 { - return int64(t / 5) -} - -func nowSec() float64 { - return float64(time.Now().UnixNano()) / 1e9 -} - -// logSigSample prints the first 5 signatures per provider so we can eyeball -// cross-provider alignment when matched_total stays at zero. Pure debug -// instrumentation, dropped from output once we trust the matching. -var ( - sigSampleMu sync.Mutex - sigSamples = map[string]int{} -) - -func logSigSample(provider string, s EventSig) { - sigSampleMu.Lock() - defer sigSampleMu.Unlock() - key := provider + "/" + s.Venue - if sigSamples[key] >= 5 { - return - } - sigSamples[key]++ - cid := s.ConditionId - if len(cid) > 14 { - cid = cid[:14] - } - appendLog("[sig %s/%s #%d] cond=%s price=%d bucket=%d", - provider, s.Venue, sigSamples[key], cid, s.PriceMilli, s.BucketSec) -} diff --git a/harnesses/pm-freshness-bench/cmd/script/kalshi.go b/harnesses/pm-freshness-bench/cmd/script/kalshi.go deleted file mode 100644 index 13d576f5..00000000 --- a/harnesses/pm-freshness-bench/cmd/script/kalshi.go +++ /dev/null @@ -1,270 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "sort" - "strings" - "sync" - "time" -) - -// Kalshi public trade feed. The /v1/social/trades endpoint is the same one -// kalshi.com homepage trade ticker hits; it is unauthenticated, CloudFront -// fronted (max-age=10s), no geo block on read, no auth header required. -// -// Why REST and not the official WebSocket: the WS at -// external-api-ws.kalshi.com requires RSA-PSS signed headers from an -// account with US KYC, and returns 403 from non-US IPs even with valid -// auth. The social/trades JSON carries `create_date` at microsecond -// precision -- that IS the venue T0, independent of our poll cadence. -// The poller's only job is to surface trade signatures so the correlator -// can match them against Codex's Kalshi stream. -// -// Calling without `series_ticker` returns a global feed of the most -// recent ~100 trades across all series, so a single HTTP call per cycle -// covers every active market. The Kalshi basket (used by codex.go to -// filter Kalshi events on its firehose) is built dynamically from the -// tickers actually seen in those trades, which auto-targets the active -// markets without needing a separate /markets call that returns zero -// volumes on most rows. -const kalshiSocialTradesURL = "https://api.elections.kalshi.com/v1/social/trades" - -func runKalshi(ctx context.Context, cfg Config, codexKalshiCh chan<- []Market) { - every := time.Duration(cfg.KalshiPollIntervalSec) * time.Second - if every < 2*time.Second { - every = 5 * time.Second - } - - seen := newSeenCache(8192) - tickerFreq := newTickerFreq(cfg.BasketSize) - - appendLog("[kalshi] REST poller started (global feed), interval=%s", every) - tick := time.NewTicker(every) - defer tick.Stop() - for { - select { - case <-ctx.Done(): - return - case <-tick.C: - } - if err := pollKalshiGlobal(ctx, seen, tickerFreq, codexKalshiCh); err != nil { - fetchErrors.WithLabelValues("kalshi", "kalshi", classify(err)).Inc() - health.WithLabelValues("kalshi", "kalshi").Set(0) - appendLog("[kalshi] poll err: %v", err) - } - } -} - -// pollKalshiGlobal pages through /v1/social/trades following the -// returned cursor until either every trade in a page is already in the -// seen cache (we caught up with last cycle) or we hit the page cap. -// One call returns ~100 trades covering ~3 seconds of venue time; with -// a 5 s poll cadence two pages cover the gap with overlap, the cap of -// 5 is generous headroom for traffic spikes. -const kalshiMaxPagesPerPoll = 5 - -func pollKalshiGlobal(ctx context.Context, seen *seenCache, freq *tickerFreq, codexKalshiCh chan<- []Market) error { - cursor := "" - for page := 0; page < kalshiMaxPagesPerPoll; page++ { - newOnThisPage, nextCursor, err := fetchKalshiPage(ctx, cursor, seen, freq) - if err != nil { - return fmt.Errorf("page %d: %w", page+1, err) - } - // Stop once a whole page returns zero new trades -- means we - // crossed back into the previous poll's window. - if newOnThisPage == 0 { - break - } - if nextCursor == "" { - break - } - cursor = nextCursor - } - // Push the latest observed-tickers basket so codex.go's isKnownKalshi - // accepts the same set of markets we're polling. The channel send is - // non-blocking; codex.go re-reads currentKalshiBasket on (re)connect - // anyway, so dropping an update is safe — the next poll will retry. - basket := freq.topTickers() - basketMu.Lock() - kalshiBasket = basket - basketMu.Unlock() - basketSize.WithLabelValues("kalshi").Set(float64(len(basket))) - if codexKalshiCh != nil && len(basket) > 0 { - select { - case codexKalshiCh <- basket: - default: - } - } - return nil -} - -func fetchKalshiPage(ctx context.Context, cursor string, seen *seenCache, freq *tickerFreq) (int, string, error) { - url := kalshiSocialTradesURL - if cursor != "" { - url += "?cursor=" + cursor - } - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "openchainbench-pm-freshness-bench") - client := &http.Client{Timeout: 8 * time.Second} - resp, err := client.Do(req) - if err != nil { - return 0, "", err - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return 0, "", fmt.Errorf("status_%d", resp.StatusCode) - } - var payload struct { - Trades []struct { - TradeID string `json:"trade_id"` - Ticker string `json:"ticker"` - Price int `json:"price"` - CreateDate string `json:"create_date"` - } `json:"trades"` - Cursor string `json:"cursor"` - } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return 0, "", err - } - newCount := 0 - for _, t := range payload.Trades { - if t.TradeID == "" || t.Ticker == "" { - continue - } - if seen.contains(t.TradeID) { - continue - } - seen.add(t.TradeID) - ts, err := time.Parse(time.RFC3339Nano, t.CreateDate) - if err != nil { - continue - } - newCount++ - eventsTotal.WithLabelValues("kalshi", "kalshi", "trade").Inc() - health.WithLabelValues("kalshi", "kalshi").Set(1) - markAlive("kalshi", "kalshi") - - tickerLower := strings.ToLower(t.Ticker) - freq.bump(tickerLower) - - sig := EventSig{ - Venue: "kalshi", - ConditionId: tickerLower, - PriceMilli: t.Price * 10, - BucketSec: bucketSec(float64(ts.UnixNano()) / 1e9), - } - logSigSample("kalshi", sig) - correlator.Add(sig, Arrival{Provider: "kalshi", Kind: "trade", RecvUnix: float64(ts.UnixNano()) / 1e9}) - } - return newCount, payload.Cursor, nil -} - -// tickerFreq tracks how often each market_ticker has appeared in the -// recent trade stream, with exponential decay so quiet markets fade. -// We expose the top N as the "active basket" -- Codex's firehose filter -// reads from this set. -type tickerFreq struct { - mu sync.Mutex - counts map[string]float64 - cap int - lastDec time.Time -} - -func newTickerFreq(cap int) *tickerFreq { - return &tickerFreq{counts: map[string]float64{}, cap: cap, lastDec: time.Now()} -} - -func (f *tickerFreq) bump(ticker string) { - f.mu.Lock() - defer f.mu.Unlock() - f.decayLocked() - f.counts[ticker]++ -} - -// decayLocked applies an exponential decay so a market that stops -// trading drops out of the top-N after a few minutes. Tuned so a market -// at "1 trade per minute" decays past a 0.1 floor in ~10 minutes. -func (f *tickerFreq) decayLocked() { - now := time.Now() - elapsed := now.Sub(f.lastDec).Seconds() - if elapsed < 30 { - return - } - f.lastDec = now - decay := 0.95 * (elapsed / 60) // ~5% per minute - if decay > 0.5 { - decay = 0.5 - } - factor := 1 - decay - for k, v := range f.counts { - nv := v * factor - if nv < 0.1 { - delete(f.counts, k) - } else { - f.counts[k] = nv - } - } -} - -func (f *tickerFreq) topTickers() []Market { - f.mu.Lock() - defer f.mu.Unlock() - f.decayLocked() - type kv struct { - ticker string - score float64 - } - all := make([]kv, 0, len(f.counts)) - for t, c := range f.counts { - all = append(all, kv{t, c}) - } - sort.Slice(all, func(i, j int) bool { return all[i].score > all[j].score }) - if len(all) > f.cap { - all = all[:f.cap] - } - out := make([]Market, len(all)) - for i, kv := range all { - out[i] = Market{Slug: kv.ticker, ConditionId: kv.ticker, Vol24h: kv.score} - } - return out -} - -// seenCache is a small FIFO set just enough to dedup trade_id across polls. -// Each poll typically returns ~100 trades and we re-fetch every 5s, so 8k -// entries covers ~7 minutes of unique IDs at peak. -type seenCache struct { - mu sync.Mutex - set map[string]struct{} - order []string - cap int -} - -func newSeenCache(cap int) *seenCache { - return &seenCache{set: make(map[string]struct{}, cap), cap: cap} -} - -func (s *seenCache) contains(k string) bool { - s.mu.Lock() - defer s.mu.Unlock() - _, ok := s.set[k] - return ok -} - -func (s *seenCache) add(k string) { - s.mu.Lock() - defer s.mu.Unlock() - if _, ok := s.set[k]; ok { - return - } - s.set[k] = struct{}{} - s.order = append(s.order, k) - if len(s.order) > s.cap { - old := s.order[0] - s.order = s.order[1:] - delete(s.set, old) - } -} diff --git a/harnesses/pm-freshness-bench/cmd/script/log_buffer.go b/harnesses/pm-freshness-bench/cmd/script/log_buffer.go deleted file mode 100644 index 2fb7e3df..00000000 --- a/harnesses/pm-freshness-bench/cmd/script/log_buffer.go +++ /dev/null @@ -1,58 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "sync" - "time" -) - -// Ring buffer mirroring the pattern used in the aggregator-latency-benchmark -// and wallet-labels miniapps. Captures the last ~5000 log lines for the -// `/logs?tail=N` endpoint. -const ringCapacity = 5000 - -type logEntry struct { - At time.Time - Line string -} - -var ( - ringMu sync.Mutex - ring = make([]logEntry, 0, ringCapacity) -) - -func appendLog(format string, args ...interface{}) { - line := fmt.Sprintf(format, args...) - ts := time.Now().UTC().Format("15:04:05.000") - fmt.Printf("[%s] %s\n", ts, line) - ringMu.Lock() - if len(ring) >= ringCapacity { - ring = ring[1:] - } - ring = append(ring, logEntry{At: time.Now(), Line: line}) - ringMu.Unlock() -} - -func setupLogsEndpoint(mux *http.ServeMux, token string) { - mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { - if token != "" && r.Header.Get("X-Logs-Token") != token { - http.Error(w, "forbidden", http.StatusForbidden) - return - } - tail := 200 - if q := r.URL.Query().Get("tail"); q != "" { - fmt.Sscanf(q, "%d", &tail) - } - ringMu.Lock() - defer ringMu.Unlock() - start := 0 - if len(ring) > tail { - start = len(ring) - tail - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - for _, e := range ring[start:] { - fmt.Fprintf(w, "[%s] %s\n", e.At.UTC().Format("15:04:05.000"), e.Line) - } - }) -} diff --git a/harnesses/pm-freshness-bench/cmd/script/main.go b/harnesses/pm-freshness-bench/cmd/script/main.go deleted file mode 100644 index 75ce8e63..00000000 --- a/harnesses/pm-freshness-bench/cmd/script/main.go +++ /dev/null @@ -1,142 +0,0 @@ -package main - -import ( - "context" - "os" - "os/signal" - "strings" - "sync" - "syscall" - "time" -) - -var correlator = NewCorrelator() - -func main() { - cfg := loadConfig() - appendLog("[boot] pm-freshness-bench starting (basket=%d, refresh=%ds)", cfg.BasketSize, cfg.RefreshMarketsIntervalSec) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // graceful shutdown - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - go func() { - <-sigCh - appendLog("[boot] shutdown signal received") - cancel() - }() - - // fan-out: every Polymarket basket refresh broadcasts on these per-consumer channels. - polyCh := make(chan []Market, 4) - codexPolyCh := make(chan []Market, 4) - mobulaCh := make(chan []Market, 4) - onChange := func(ms []Market) { - for _, ch := range []chan []Market{polyCh, codexPolyCh, mobulaCh} { - select { - case ch <- ms: - default: - // channel full — provider will pick up the change on the next refresh - } - } - } - - // Kalshi has its own basket which is now built dynamically by the - // Kalshi T0 client itself from the global /v1/social/trades feed. The - // /trade-api/v2/markets endpoint returns zero volume on every row so - // the previous markets-based refresh produced a basket of random - // inactive series; the observation-based basket auto-targets the - // active markets instead. - codexKalshiCh := make(chan []Market, 4) - - // health gauges decay: any (provider, venue) pair that hasn't published in 60s drops to 0. - go healthDecayLoop(ctx) - - var wg sync.WaitGroup - wg.Add(5) - go func() { - defer wg.Done() - refreshLoop(ctx, time.Duration(cfg.RefreshMarketsIntervalSec)*time.Second, cfg.BasketSize, onChange) - }() - go func() { defer wg.Done(); runPolymarket(ctx, polyCh) }() - go func() { defer wg.Done(); runCodex(ctx, cfg, codexPolyCh, codexKalshiCh) }() - go func() { defer wg.Done(); runMobula(ctx, cfg, mobulaCh) }() - go func() { defer wg.Done(); runKalshi(ctx, cfg, codexKalshiCh) }() - - // :2112 hardcoded per the OCB convention — Railway $PORT is intentionally ignored - // so the shared Prom can scrape every harness on the same well-known port. - go func() { - if err := startMetricsServer(":2112", cfg.LogsToken); err != nil { - appendLog("[metrics] server err: %v", err) - } - }() - - wg.Wait() -} - -// lastEventAt is updated by every WS client when it observes a real event, -// keyed by (provider, venue). The decay loop reads it and drops the -// corresponding health gauge to 0 when nothing has arrived in the last 60s. -type provVenue struct{ provider, venue string } - -var ( - lastEventMu sync.Mutex - lastEventAt = map[provVenue]time.Time{} -) - -// healthPairs is the static list of (provider, venue) tuples we track. -// Polymarket venue: 3 providers (polymarket T0, codex follower, mobula follower). -// Kalshi venue: 2 providers (kalshi T0, codex follower). -var healthPairs = []provVenue{ - {"polymarket", "polymarket"}, - {"codex", "polymarket"}, - {"mobula", "polymarket"}, - {"kalshi", "kalshi"}, - {"codex", "kalshi"}, -} - -func markAlive(provider, venue string) { - lastEventMu.Lock() - lastEventAt[provVenue{provider, venue}] = time.Now() - lastEventMu.Unlock() -} - -func healthDecayLoop(ctx context.Context) { - t := time.NewTicker(10 * time.Second) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - lastEventMu.Lock() - for _, pv := range healthPairs { - last, ok := lastEventAt[pv] - if !ok || time.Since(last) > 60*time.Second { - health.WithLabelValues(pv.provider, pv.venue).Set(0) - } - } - lastEventMu.Unlock() - } - } -} - -func classify(err error) string { - if err == nil { - return "none" - } - s := strings.ToLower(err.Error()) - switch { - case strings.Contains(s, "429") || strings.Contains(s, "rate limited"): - return "rate_limit" - case strings.Contains(s, "401") || strings.Contains(s, "403") || strings.Contains(s, "4403") || strings.Contains(s, "auth"): - return "auth" - case strings.Contains(s, "timeout"): - return "timeout" - case strings.Contains(s, "eof") || strings.Contains(s, "reset"): - return "conn_drop" - default: - return "other" - } -} diff --git a/harnesses/pm-freshness-bench/cmd/script/markets.go b/harnesses/pm-freshness-bench/cmd/script/markets.go deleted file mode 100644 index 2fd4d799..00000000 --- a/harnesses/pm-freshness-bench/cmd/script/markets.go +++ /dev/null @@ -1,220 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "sort" - "strings" - "sync" - "time" -) - -// A market we subscribe to across every provider. Each Polymarket market -// has a conditionId (0x-prefixed hex) shared by both outcomes, and a pair -// of clobTokenIds (decimal strings) — one per Yes/No outcome. -// For Kalshi the ConditionId field holds the market_ticker and ClobTokenIds -// is empty — the rest of the pipeline only needs ConditionId for matching. -type Market struct { - Slug string - ConditionId string // Polymarket conditionId OR Kalshi market_ticker (lowercased) - ClobTokenIds []string // 2 ids, the Polymarket WS `asset_id` per outcome (Polymarket only) - Vol24h float64 -} - -var ( - basketMu sync.RWMutex - basket []Market // Polymarket basket - kalshiBasket []Market -) - -// currentBasket returns a snapshot of the currently-subscribed Polymarket -// markets. Callers should treat the slice as read-only. -func currentBasket() []Market { - basketMu.RLock() - defer basketMu.RUnlock() - out := make([]Market, len(basket)) - copy(out, basket) - return out -} - -// currentKalshiBasket returns a snapshot of the currently-subscribed Kalshi -// markets. Same read-only contract as currentBasket. -func currentKalshiBasket() []Market { - basketMu.RLock() - defer basketMu.RUnlock() - out := make([]Market, len(kalshiBasket)) - copy(out, kalshiBasket) - return out -} - -// refreshLoop polls gamma-api on an interval and updates the shared basket. -// Connections are not torn down here — providers re-subscribe diff-style -// on every refresh (see their respective files). -func refreshLoop(ctx context.Context, every time.Duration, size int, onChange func([]Market)) { - tick := time.NewTicker(every) - defer tick.Stop() - if err := refreshBasket(ctx, size, onChange); err != nil { - appendLog("[markets] initial refresh err: %v", err) - } - for { - select { - case <-ctx.Done(): - return - case <-tick.C: - if err := refreshBasket(ctx, size, onChange); err != nil { - appendLog("[markets] refresh err: %v", err) - } - } - } -} - -func refreshBasket(ctx context.Context, size int, onChange func([]Market)) error { - url := fmt.Sprintf("https://gamma-api.polymarket.com/markets?active=true&closed=false&order=volume24hr&ascending=false&limit=%d", size) - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { - return fmt.Errorf("gamma-api status=%d", resp.StatusCode) - } - var raw []struct { - Slug string `json:"slug"` - ConditionId string `json:"conditionId"` - ClobTokenIds string `json:"clobTokenIds"` // JSON-encoded array of strings - Volume24hr float64 `json:"volume24hr"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return err - } - out := make([]Market, 0, len(raw)) - for _, r := range raw { - if r.ConditionId == "" || r.ClobTokenIds == "" { - continue - } - var ids []string - if err := json.Unmarshal([]byte(r.ClobTokenIds), &ids); err != nil { - continue - } - if len(ids) < 2 { - continue - } - out = append(out, Market{ - Slug: r.Slug, - ConditionId: strings.ToLower(r.ConditionId), - ClobTokenIds: ids, - Vol24h: r.Volume24hr, - }) - } - - basketMu.Lock() - basket = out - basketMu.Unlock() - basketSize.WithLabelValues("polymarket").Set(float64(len(out))) - appendLog("[markets] refreshed basket: %d markets, top vol=$%.0fk", len(out), firstVol(out)/1000) - if onChange != nil { - onChange(out) - } - return nil -} - -func firstVol(ms []Market) float64 { - if len(ms) == 0 { - return 0 - } - return ms[0].Vol24h -} - -// refreshKalshiLoop mirrors refreshLoop for Kalshi. Kalshi's public markets -// endpoint is a separate host so we drive it on its own goroutine. -func refreshKalshiLoop(ctx context.Context, every time.Duration, size int, onChange func([]Market)) { - tick := time.NewTicker(every) - defer tick.Stop() - if err := refreshKalshiBasket(ctx, size, onChange); err != nil { - appendLog("[kalshi-markets] initial refresh err: %v", err) - } - for { - select { - case <-ctx.Done(): - return - case <-tick.C: - if err := refreshKalshiBasket(ctx, size, onChange); err != nil { - appendLog("[kalshi-markets] refresh err: %v", err) - } - } - } -} - -func refreshKalshiBasket(ctx context.Context, size int, onChange func([]Market)) error { - // Kalshi's elections host is the public unauthenticated read endpoint. - // We pull a wider page than `size` because the API doesn't expose a - // sort-by-volume parameter — we rank client-side and trim. - url := "https://api.elections.kalshi.com/trade-api/v2/markets?status=open&limit=200" - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { - return fmt.Errorf("kalshi markets status=%d", resp.StatusCode) - } - var raw struct { - Markets []struct { - Ticker string `json:"ticker"` - Volume24h float64 `json:"volume_24h"` - Volume float64 `json:"volume"` - LastPrice float64 `json:"last_price"` - } `json:"markets"` - } - if err := json.Unmarshal(body, &raw); err != nil { - return err - } - // Rank by 24h volume when available, fall back to all-time volume. - type scored struct { - Market - score float64 - } - scoredList := make([]scored, 0, len(raw.Markets)) - for _, r := range raw.Markets { - if r.Ticker == "" { - continue - } - s := r.Volume24h - if s == 0 { - s = r.Volume - } - scoredList = append(scoredList, scored{ - Market: Market{ - Slug: r.Ticker, - ConditionId: strings.ToLower(r.Ticker), - Vol24h: r.Volume24h, - }, - score: s, - }) - } - sort.Slice(scoredList, func(i, j int) bool { return scoredList[i].score > scoredList[j].score }) - if len(scoredList) > size { - scoredList = scoredList[:size] - } - out := make([]Market, len(scoredList)) - for i, s := range scoredList { - out[i] = s.Market - } - - basketMu.Lock() - kalshiBasket = out - basketMu.Unlock() - basketSize.WithLabelValues("kalshi").Set(float64(len(out))) - appendLog("[kalshi-markets] refreshed basket: %d markets, top vol24h=$%.0fk", len(out), firstVol(out)/1000) - if onChange != nil { - onChange(out) - } - return nil -} diff --git a/harnesses/pm-freshness-bench/cmd/script/metrics.go b/harnesses/pm-freshness-bench/cmd/script/metrics.go deleted file mode 100644 index ce9090f4..00000000 --- a/harnesses/pm-freshness-bench/cmd/script/metrics.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "net/http" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/prometheus/client_golang/prometheus/promhttp" -) - -var commonLabels = prometheus.Labels{"benchmark": "pm-freshness"} - -var ( - // Freshness delta vs Polymarket T0, in ms. Buckets sized for sub-second - // to ~30s lag (Codex p50 is around 4s). - freshnessDelta = promauto.NewHistogramVec(prometheus.HistogramOpts{ - Name: "pm_freshness_delta_ms", - Help: "Per-event delta in ms between provider arrival and venue T0 arrival, matched by (conditionId, price, time-bucket, venue).", - Buckets: []float64{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000}, - ConstLabels: commonLabels, - }, []string{"provider", "venue", "kind"}) - - eventsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ - Name: "pm_events_total", - Help: "Raw events received per provider/venue/kind.", - ConstLabels: commonLabels, - }, []string{"provider", "venue", "kind"}) - - matchedTotal = promauto.NewCounterVec(prometheus.CounterOpts{ - Name: "pm_matched_total", - Help: "Provider events that matched a venue T0 event by signature.", - ConstLabels: commonLabels, - }, []string{"provider", "venue", "kind"}) - - fetchErrors = promauto.NewCounterVec(prometheus.CounterOpts{ - Name: "pm_fetch_errors_total", - Help: "Provider errors (connection, auth, parse).", - ConstLabels: commonLabels, - }, []string{"provider", "venue", "error_type"}) - - health = promauto.NewGaugeVec(prometheus.GaugeOpts{ - Name: "pm_health", - Help: "1 if the (provider, venue) published at least one event in the last 60s.", - ConstLabels: commonLabels, - }, []string{"provider", "venue"}) - - basketSize = promauto.NewGaugeVec(prometheus.GaugeOpts{ - Name: "pm_basket_size", - Help: "Number of markets currently subscribed per venue.", - ConstLabels: commonLabels, - }, []string{"venue"}) -) - -func startMetricsServer(addr, logsToken string) error { - mux := http.NewServeMux() - mux.Handle("/metrics", promhttp.Handler()) - setupLogsEndpoint(mux, logsToken) - return http.ListenAndServe(addr, mux) -} diff --git a/harnesses/pm-freshness-bench/cmd/script/mobula.go b/harnesses/pm-freshness-bench/cmd/script/mobula.go deleted file mode 100644 index 65a73c5c..00000000 --- a/harnesses/pm-freshness-bench/cmd/script/mobula.go +++ /dev/null @@ -1,198 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "strings" - "sync" - "time" - - "nhooyr.io/websocket" -) - -const mobulaWS = "wss://pm-api-prod-eu.mobula.io" - -// Cloudflare on the Mobula PM gateway blocks the default Go-http-client/1.1 -// User-Agent silently — the subscribe ack comes back but no data frame -// ever fires. Spoofing a normal browser UA on the WS upgrade unblocks it. -const mobulaUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - -func runMobula(ctx context.Context, cfg Config, basketCh <-chan []Market) { - if cfg.MobulaApiKey == "" { - appendLog("[mobula] MOBULA_API_KEY not set — skipping Mobula provider") - return - } - - backoff := 5 * time.Second - for ctx.Err() == nil { - err := mobulaConnect(ctx, cfg.MobulaApiKey, basketCh) - if ctx.Err() != nil { - return - } - appendLog("[mobula] disconnected: %v — backing off %v", err, backoff) - fetchErrors.WithLabelValues("mobula", "polymarket", classify(err)).Inc() - select { - case <-ctx.Done(): - return - case <-time.After(backoff): - } - if backoff < 60*time.Second { - backoff *= 2 - } - } -} - -func mobulaConnect(ctx context.Context, apiKey string, basketCh <-chan []Market) error { - opts := &websocket.DialOptions{ - HTTPHeader: http.Header{ - "User-Agent": []string{mobulaUA}, - "Origin": []string{"https://mobula.io"}, - }, - } - conn, _, err := websocket.Dial(ctx, mobulaWS, opts) - if err != nil { - return fmt.Errorf("dial: %w", err) - } - defer conn.Close(websocket.StatusInternalError, "") - conn.SetReadLimit(8 * 1024 * 1024) - - var ( - subMu sync.Mutex - subscribed = map[string]bool{} // conditionId - ) - subscribeMissing := func(ms []Market) { - subMu.Lock() - defer subMu.Unlock() - for _, m := range ms { - if subscribed[m.ConditionId] { - continue - } - subscribed[m.ConditionId] = true - for _, channel := range []string{"pm-market-trade", "pm-market-price"} { - sub := map[string]interface{}{ - "event": channel, - "data": map[string]interface{}{ - "platform": "polymarket", - "marketId": m.ConditionId, - "authorization": apiKey, - }, - } - sb, _ := json.Marshal(sub) - if err := conn.Write(ctx, websocket.MessageText, sb); err != nil { - appendLog("[mobula] subscribe err: %v", err) - return - } - // pace the writes so Mobula doesn't drop them - time.Sleep(40 * time.Millisecond) - } - } - appendLog("[mobula] subscribed total=%d markets (trade+price)", len(subscribed)) - } - - if initial := currentBasket(); len(initial) > 0 { - subscribeMissing(initial) - } - go func() { - for { - select { - case <-ctx.Done(): - return - case ms := <-basketCh: - subscribeMissing(ms) - } - } - }() - - for { - _, b, err := conn.Read(ctx) - if err != nil { - return err - } - var m map[string]interface{} - if err := json.Unmarshal(b, &m); err != nil { - continue - } - evt, _ := m["event"].(string) - switch evt { - case "subscribed": - continue - case "error": - s := string(b) - if len(s) > 200 { - s = s[:200] - } - fetchErrors.WithLabelValues("mobula", "polymarket", "server_error").Inc() - appendLog("[mobula] error frame: %s", s) - continue - case "pm-market-trade": - handleMobulaTrade(m) - case "pm-market-price": - handleMobulaPrice(m) - } - } -} - -// Mobula's pm-market-trade payload is a flat object (no nested `data` on -// outbound), e.g. -// {"event":"pm-market-trade","marketId":"0xabc...","outcomeId":"60526...", -// "type":"buy","priceUSD":0.7,"sizeToken":12.34,"amountUsd":"8.64","date":1780...} -// Mobula's pm-market-price is similar but carries priceUSD/bestBid/bestAsk -// without size — we still record it as a "price" arrival for correlation -// against Polymarket `price_change`. -func handleMobulaTrade(m map[string]interface{}) { - eventsTotal.WithLabelValues("mobula", "polymarket", "trade").Inc() - health.WithLabelValues("mobula", "polymarket").Set(1) - markAlive("mobula", "polymarket") - - mid, _ := m["marketId"].(string) - cid := strings.ToLower(mid) - if cid == "" { - return - } - priceUSD, _ := m["priceUSD"].(float64) - ts := readMs(m, "date", "timestamp") - now := nowSec() - sig := EventSig{ - Venue: "polymarket", - ConditionId: cid, - PriceMilli: int(priceUSD*1000 + 0.5), - BucketSec: bucketSec(ts / 1000.0), - } - logSigSample("mobula", sig) - correlator.Add(sig, Arrival{Provider: "mobula", Kind: "trade", RecvUnix: now}) -} - -func handleMobulaPrice(m map[string]interface{}) { - eventsTotal.WithLabelValues("mobula", "polymarket", "price").Inc() - health.WithLabelValues("mobula", "polymarket").Set(1) - markAlive("mobula", "polymarket") - - mid, _ := m["marketId"].(string) - cid := strings.ToLower(mid) - if cid == "" { - return - } - priceUSD, _ := m["priceUSD"].(float64) - ts := readMs(m, "timestamp", "date") - now := nowSec() - correlator.Add(EventSig{ - Venue: "polymarket", - ConditionId: cid, - PriceMilli: int(priceUSD*1000 + 0.5), - BucketSec: bucketSec(ts / 1000.0), - }, Arrival{Provider: "mobula", Kind: "price", RecvUnix: now}) -} - -func readMs(m map[string]interface{}, keys ...string) float64 { - for _, k := range keys { - if v, ok := m[k].(float64); ok { - return v - } - if s, ok := m[k].(string); ok { - return parseF(s) - } - } - return 0 -} diff --git a/harnesses/pm-freshness-bench/cmd/script/polymarket.go b/harnesses/pm-freshness-bench/cmd/script/polymarket.go deleted file mode 100644 index 16a00953..00000000 --- a/harnesses/pm-freshness-bench/cmd/script/polymarket.go +++ /dev/null @@ -1,216 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "strings" - "sync" - "time" - - "nhooyr.io/websocket" -) - -const polymarketWS = "wss://ws-subscriptions-clob.polymarket.com/ws/market" - -func runPolymarket(ctx context.Context, basketCh <-chan []Market) { - // Maps clobTokenId → conditionId, refreshed from each basket update. - // Polymarket WS keys events by `asset_id` (= clobTokenId). The correlator - // keys by conditionId, so we need this mapping in O(1) on the hot path. - var ( - idMu sync.RWMutex - assetToCID = map[string]string{} - ) - - updateMap := func(ms []Market) { - idMu.Lock() - defer idMu.Unlock() - assetToCID = map[string]string{} - for _, m := range ms { - for _, aid := range m.ClobTokenIds { - assetToCID[aid] = strings.ToLower(m.ConditionId) - } - } - } - - lookupCID := func(assetId string) string { - idMu.RLock() - defer idMu.RUnlock() - return assetToCID[assetId] - } - - // Reconnect loop. On every connection we read the current basket and - // resubscribe with the full asset list. - backoff := 5 * time.Second - for ctx.Err() == nil { - err := dialAndStream(ctx, basketCh, updateMap, lookupCID) - if ctx.Err() != nil { - return - } - appendLog("[poly] connection ended: %v — reconnecting in %v", err, backoff) - fetchErrors.WithLabelValues("polymarket", "polymarket", classify(err)).Inc() - select { - case <-ctx.Done(): - return - case <-time.After(backoff): - } - if backoff < 30*time.Second { - backoff *= 2 - } - } -} - -func dialAndStream(ctx context.Context, basketCh <-chan []Market, onBasket func([]Market), lookupCID func(string) string) error { - conn, _, err := websocket.Dial(ctx, polymarketWS, nil) - if err != nil { - return fmt.Errorf("dial: %w", err) - } - defer conn.Close(websocket.StatusInternalError, "") - conn.SetReadLimit(8 * 1024 * 1024) - - subscribed := map[string]bool{} // assetId -> true - - subscribeMissing := func(ms []Market) { - assetIds := []string{} - for _, m := range ms { - for _, aid := range m.ClobTokenIds { - if !subscribed[aid] { - assetIds = append(assetIds, aid) - subscribed[aid] = true - } - } - } - if len(assetIds) == 0 { - return - } - sub := map[string]interface{}{ - "assets_ids": assetIds, - "type": "market", - "custom_feature_enabled": true, - } - sb, _ := json.Marshal(sub) - if err := conn.Write(ctx, websocket.MessageText, sb); err != nil { - appendLog("[poly] subscribe write err: %v", err) - return - } - appendLog("[poly] subscribed +%d assets (total=%d)", len(assetIds), len(subscribed)) - } - - // initial basket - initial := currentBasket() - if len(initial) > 0 { - onBasket(initial) - subscribeMissing(initial) - } - - // ping loop (server expects literal text "PING" every 10s) - pingCtx, pingCancel := context.WithCancel(ctx) - defer pingCancel() - go func() { - t := time.NewTicker(10 * time.Second) - defer t.Stop() - for { - select { - case <-pingCtx.Done(): - return - case <-t.C: - if err := conn.Write(pingCtx, websocket.MessageText, []byte("PING")); err != nil { - return - } - } - } - }() - - // basket-update goroutine — react to new markets coming in - go func() { - for { - select { - case <-pingCtx.Done(): - return - case ms := <-basketCh: - onBasket(ms) - subscribeMissing(ms) - } - } - }() - - for { - _, b, err := conn.Read(ctx) - if err != nil { - return err - } - if bytes.Equal(b, []byte("PONG")) { - continue - } - t := bytes.TrimSpace(b) - var arr []map[string]interface{} - if len(t) > 0 && t[0] == '[' { - json.Unmarshal(b, &arr) - } else { - var one map[string]interface{} - if json.Unmarshal(b, &one) == nil { - arr = []map[string]interface{}{one} - } - } - for _, ev := range arr { - eventType, _ := ev["event_type"].(string) - tsMs, _ := parseTimestampMsField(ev["timestamp"]) - assetId, _ := ev["asset_id"].(string) - cid := lookupCID(assetId) - if cid == "" { - continue - } - now := nowSec() - switch eventType { - case "last_trade_price": - eventsTotal.WithLabelValues("polymarket", "polymarket", "trade").Inc() - price, _ := ev["price"].(string) - health.WithLabelValues("polymarket", "polymarket").Set(1) - markAlive("polymarket", "polymarket") - sig := EventSig{ - Venue: "polymarket", - ConditionId: cid, - PriceMilli: priceToMilli(price), - BucketSec: bucketSec(tsMs / 1000.0), - } - logSigSample("polymarket", sig) - correlator.Add(sig, Arrival{Provider: "polymarket", Kind: "trade", RecvUnix: now}) - case "price_change": - eventsTotal.WithLabelValues("polymarket", "polymarket", "price").Inc() - health.WithLabelValues("polymarket", "polymarket").Set(1) - markAlive("polymarket", "polymarket") - if changes, ok := ev["price_changes"].([]interface{}); ok { - for _, ch := range changes { - chm, _ := ch.(map[string]interface{}) - if chm == nil { - continue - } - price, _ := chm["price"].(string) - correlator.Add(EventSig{ - Venue: "polymarket", - ConditionId: cid, - PriceMilli: priceToMilli(price), - BucketSec: bucketSec(tsMs / 1000.0), - }, Arrival{Provider: "polymarket", Kind: "price", RecvUnix: now}) - } - } - } - } - } -} - -// Polymarket emits `timestamp` as a string of ms-since-epoch. Codex emits -// it as an int seconds-since-epoch. Mobula emits it as a number of ms. -// Normalize to ms float here. -func parseTimestampMsField(v interface{}) (float64, error) { - switch t := v.(type) { - case string: - return parseF(t), nil - case float64: - return t, nil - case int64: - return float64(t), nil - } - return 0, fmt.Errorf("unknown timestamp type") -} diff --git a/harnesses/pm-freshness-bench/go.mod b/harnesses/pm-freshness-bench/go.mod deleted file mode 100644 index b2c2cec0..00000000 --- a/harnesses/pm-freshness-bench/go.mod +++ /dev/null @@ -1,18 +0,0 @@ -module pm-freshness-bench - -go 1.22 - -require ( - github.com/prometheus/client_golang v1.19.1 - nhooyr.io/websocket v1.8.11 -) - -require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/prometheus/client_model v0.5.0 // indirect - github.com/prometheus/common v0.48.0 // indirect - github.com/prometheus/procfs v0.12.0 // indirect - golang.org/x/sys v0.17.0 // indirect - google.golang.org/protobuf v1.33.0 // indirect -) diff --git a/harnesses/pm-freshness-bench/go.sum b/harnesses/pm-freshness-bench/go.sum deleted file mode 100644 index 0874f900..00000000 --- a/harnesses/pm-freshness-bench/go.sum +++ /dev/null @@ -1,22 +0,0 @@ -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= -github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= -github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= -github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= -github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= -github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= -github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= -github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0= -nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/harnesses/pm-rate-limits/cmd/script/main.go b/harnesses/pm-rate-limits/cmd/script/main.go index bd9eb9ec..e046a7b0 100644 --- a/harnesses/pm-rate-limits/cmd/script/main.go +++ b/harnesses/pm-rate-limits/cmd/script/main.go @@ -51,6 +51,9 @@ func main() { if v.Slug == "kalshi" && cfg.KalshiAPIKeyID != "" && cfg.KalshiPrivateKeyPEM != "" { go runKalshiWS(ctx, v.state, cfg.KalshiAPIKeyID, cfg.KalshiPrivateKeyPEM) } + if v.Slug == "kalshi" && cfg.KalshiAPIKeyID != "" && cfg.KalshiPrivateKeyPEM != "" { + go runKalshiTradeRestPoll(ctx, v.state) + } } // Aggregator probes. One goroutine per (venue, source) pair; reuses diff --git a/harnesses/pm-rate-limits/cmd/script/metrics.go b/harnesses/pm-rate-limits/cmd/script/metrics.go index 9545699e..139dfcfa 100644 --- a/harnesses/pm-rate-limits/cmd/script/metrics.go +++ b/harnesses/pm-rate-limits/cmd/script/metrics.go @@ -67,6 +67,12 @@ var ( Help: "WebSocket connections dropped after being established.", }, []string{"venue", "region", "source"}) + wsTradePublishLag = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "pmapi_ws_trade_pub_lag_seconds", + Help: "Delay between venue server-side trade creation and WS subscriber receipt. Polymarket: CLOB WS timestamp field vs recv time. Kalshi: REST create_date vs WS ticker recv time (us-east only).", + Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30}, + }, []string{"venue", "region", "source"}) + rampDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: "pmapi_ramp_request_duration_seconds", Help: "Round-trip during the daily rate-limit ramp, by tier (requests per 10 s window). Successful requests only.", diff --git a/harnesses/pm-rate-limits/cmd/script/trade_pub_lag.go b/harnesses/pm-rate-limits/cmd/script/trade_pub_lag.go new file mode 100644 index 00000000..b5188f65 --- /dev/null +++ b/harnesses/pm-rate-limits/cmd/script/trade_pub_lag.go @@ -0,0 +1,126 @@ +package main + +// Kalshi correlation state + REST poller for trade pub lag. +// Polymarket pub lag is computed inline in wsSession (timestamp in WS frame). + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "sync" + "time" +) + +type wsTickerEvent struct { + priceCents int + recvAt time.Time +} + +var kalshiWSTickerState = struct { + mu sync.Mutex + byMkt map[string][]wsTickerEvent +}{byMkt: map[string][]wsTickerEvent{}} + +func recordKalshiWSTick(mkt string, priceCents int, recvAt time.Time) { + kalshiWSTickerState.mu.Lock() + defer kalshiWSTickerState.mu.Unlock() + cutoff := recvAt.Add(-30 * time.Second) + existing := kalshiWSTickerState.byMkt[mkt] + pruned := existing[:0] + for _, e := range existing { + if e.recvAt.After(cutoff) { + pruned = append(pruned, e) + } + } + kalshiWSTickerState.byMkt[mkt] = append(pruned, wsTickerEvent{priceCents: priceCents, recvAt: recvAt}) +} + +func findKalshiWSMatch(mkt string, priceCents int, createDate time.Time) (time.Time, bool) { + kalshiWSTickerState.mu.Lock() + defer kalshiWSTickerState.mu.Unlock() + lo := createDate.Add(-2 * time.Second) + hi := createDate.Add(15 * time.Second) + for _, e := range kalshiWSTickerState.byMkt[mkt] { + if e.priceCents == priceCents && e.recvAt.After(lo) && e.recvAt.Before(hi) { + return e.recvAt, true + } + } + return time.Time{}, false +} + +func runKalshiTradeRestPoll(ctx context.Context, st *venueState) { + if currentRegion != "us-east" { + return + } + client := &http.Client{Timeout: 8 * time.Second} + seen := map[string]struct{}{} + var seenOrder []string + + for ctx.Err() == nil { + select { + case <-ctx.Done(): + return + case <-time.After(3 * time.Second): + } + + pin := st.getPin() + if pin.Market == "" { + continue + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.elections.kalshi.com/v1/social/trades", nil) + if err != nil { + continue + } + req.Header.Set("User-Agent", userAgent) + + resp, err := client.Do(req) + if err != nil { + continue + } + + var body struct { + Trades []struct { + TradeID string `json:"trade_id"` + Ticker string `json:"ticker"` + Price int `json:"price"` + CreateDate string `json:"create_date"` + } `json:"trades"` + Cursor string `json:"cursor"` + } + if json.NewDecoder(resp.Body).Decode(&body) != nil { + resp.Body.Close() + continue + } + resp.Body.Close() + + for _, t := range body.Trades { + if _, dup := seen[t.TradeID]; dup { + continue + } + seen[t.TradeID] = struct{}{} + seenOrder = append(seenOrder, t.TradeID) + if len(seenOrder) > 4096 { + delete(seen, seenOrder[0]) + seenOrder = seenOrder[1:] + } + + if !strings.EqualFold(t.Ticker, pin.Market) { + continue + } + createDate, err := time.Parse(time.RFC3339Nano, t.CreateDate) + if err != nil { + continue + } + recvAt, ok := findKalshiWSMatch(t.Ticker, t.Price, createDate) + if !ok { + continue + } + delta := recvAt.Sub(createDate).Seconds() + if delta >= 0 && delta <= 30 { + wsTradePublishLag.WithLabelValues("kalshi", currentRegion, sourceDirect).Observe(delta) + } + } + } +} diff --git a/harnesses/pm-rate-limits/cmd/script/ws.go b/harnesses/pm-rate-limits/cmd/script/ws.go index 6ed3746d..2dca8864 100644 --- a/harnesses/pm-rate-limits/cmd/script/ws.go +++ b/harnesses/pm-rate-limits/cmd/script/ws.go @@ -113,6 +113,32 @@ func wsSession(ctx context.Context, st *venueState, token string) bool { wsInterarrival.WithLabelValues("polymarket", currentRegion, sourceDirect).Observe(now.Sub(lastMsg).Seconds()) } lastMsg = now + var frames []json.RawMessage + if data[0] == '[' { + _ = json.Unmarshal(data, &frames) + } else { + frames = []json.RawMessage{data} + } + for _, frame := range frames { + var ev struct { + EventType string `json:"event_type"` + Timestamp string `json:"timestamp"` + } + if err := json.Unmarshal(frame, &ev); err != nil { + continue + } + if ev.EventType != "last_trade_price" || ev.Timestamp == "" { + continue + } + tsMs, err := strconv.ParseFloat(ev.Timestamp, 64) + if err != nil { + continue + } + delta := now.Sub(time.UnixMilli(int64(tsMs))).Seconds() + if delta >= 0 && delta <= 30 { + wsTradePublishLag.WithLabelValues("polymarket", currentRegion, sourceDirect).Observe(delta) + } + } } } @@ -261,5 +287,21 @@ func kalshiWSSession(ctx context.Context, st *venueState, ticker, keyID, private wsInterarrival.WithLabelValues("kalshi", currentRegion, sourceDirect).Observe(now.Sub(lastMsg).Seconds()) } lastMsg = now + var tick struct { + Msg struct { + LastPrice int `json:"last_price"` + YesBid int `json:"yes_bid"` + YesAsk int `json:"yes_ask"` + } `json:"msg"` + } + if err := json.Unmarshal(data, &tick); err == nil { + price := tick.Msg.LastPrice + if price <= 0 && tick.Msg.YesBid > 0 && tick.Msg.YesAsk > 0 { + price = (tick.Msg.YesBid + tick.Msg.YesAsk) / 2 + } + if price > 0 { + recordKalshiWSTick(ticker, price, now) + } + } } } From e609afb10f2cbeaec550f64f6e2e5bdbb0558eed Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:20:25 +0200 Subject: [PATCH 11/96] cleanup: remove all pm-data-freshness and dead Codex refs (#1666) --- benchmarks/pm-api-latency.yml | 16 +++--- benchmarks/pm-rate-limits.yml | 4 +- src/app/benchmarks/[slug]/page.tsx | 2 +- src/app/prediction-markets/page.tsx | 10 ++-- src/app/products/[slug]/page.tsx | 13 +---- src/components/pm-data-feeds-leaderboard.tsx | 9 ++- src/data/bench-published.json | 2 +- src/lib/format.ts | 5 +- src/lib/pm-stats.ts | 58 +++----------------- src/lib/pm-venue-context.ts | 16 +++--- src/lib/search-featured.ts | 2 +- 11 files changed, 39 insertions(+), 98 deletions(-) diff --git a/benchmarks/pm-api-latency.yml b/benchmarks/pm-api-latency.yml index b318746a..b022741a 100644 --- a/benchmarks/pm-api-latency.yml +++ b/benchmarks/pm-api-latency.yml @@ -41,11 +41,9 @@ abstract: | this API up" is answered by direct measurement from three regions rather than by user reports. Samples that fail because our pinned market expired are classified probe_invalid and never count against the venue. - Third party data aggregators that resell venue prices (Codex) - are tracked separately in the pm-data-freshness bench and on - the data feeds tab of the prediction markets hub. This page ranks the - seven venues' own APIs only, so the leaderboard answers one clean - question: which prediction market venue API is fastest right now. + This page ranks the venues' own APIs only, so the leaderboard + answers one clean question: which prediction market venue API is + fastest right now. methodology: - "Price endpoint per venue. Polymarket: CLOB `/midpoint`. Kalshi: `/markets/{ticker}`. Limitless: `/markets/{slug}`. Manifold: `/v0/market/{id}`. Myriad: `/markets/{slug}`. This is the hot path of real integrations: one market, one quote, polled in a loop." @@ -53,10 +51,10 @@ methodology: - "Every sample carries a cache flag read from cf-cache-status, x-cache and age headers. Latency aggregates exclude cache hits: a CDN hit measures the nearest edge, not the venue's API, and including Manifold's residual cache hits would flatter it by design." - "Uptime is the average of a per venue, per region health gauge (pmapi_health) computed by the harness from probe outcomes. 100 percent means every probe cycle in the window succeeded from that region. The 24h and 7d panels below are the live answer to whether each API is up." - "Outcome classification: ok, timeout (8s), http_4xx, http_5xx, throttled, probe_invalid, net_error. probe_invalid covers failures that are our pin's fault, such as a market that resolved intraday; those are excluded from both latency and uptime. The pinned market is re selected daily at 00:00 UTC and immediately on probe_invalid." - - "Division of labor with the sibling benches: how each venue behaves as request rates climb, including throttle onset and 429 handling, is measured in pm-rate-limits (bench 037). How fresh third party data providers relay Polymarket data is measured in pm-data-freshness (bench 032). This bench ranks the venues' own APIs on latency and uptime at a polite request rate." + - "Division of labor with the sibling benches: how each venue behaves as request rates climb, including throttle onset and 429 handling, is measured in pm-rate-limits (bench 037). WebSocket connect-to-snapshot and trade publication lag are measured in pm-ws-latency (bench 114). This bench ranks the venues' own APIs on latency and uptime at a polite request rate." - "Myriad's origin is a single region US East deployment behind Heroku, so its latency from eu-west and sgp is dominated by geography. Reported as measured, called out per region." - "All five venues are probed by the same open source harness as bench 037, from the same processes, so the two benches share one probe budget and one identifying User-Agent: `OpenChainBench/1.0 (+https://openchainbench.com/methodology; contact@openchainbench.com)`. We publish latency and uptime measurements only, never market data." - - "Aggregators that resell venue data (Codex) are deliberately excluded from this leaderboard. They are not venue APIs, they are relays on top of venue APIs, and ranking them next to the venues they relay would compare two different products on one axis. Their freshness lag against the Polymarket CLOB T0 stream is the relevant metric, measured in the pm-data-freshness bench. The prediction markets hub data feeds tab lists each aggregator with the venues it covers." + - "Data aggregators that resell venue prices are deliberately excluded from this leaderboard. They are relays on top of venue APIs, and ranking them next to the venues they relay would compare two different products on one axis. The prediction markets hub data feeds tab lists each aggregator with the venues it covers." - "Regions: us-east, eu-west, sgp (Railway). Histogram buckets 25ms to 10s." findings: @@ -65,7 +63,7 @@ findings: - "{{name:manifold}} would look faster than it is if cache hits counted: its whole API sits behind a 5 second CDN cache. With cache hits excluded its origin answers at {{p50:manifold}} p50, an honest number a trading bot polling fresh quotes will actually see." - "{{name:myriad}} serves every region from a single US East origin, so its cohort worst p50 of {{p50:myriad}} is mostly geography. Switch the region dimension to us-east to see the API itself rather than the speed of light." - "{{name:polymarket}} answers its midpoint endpoint at {{p50:polymarket}} p50. Because we probe it every 5 seconds from three regions, the uptime panel doubles as a Polymarket API status check that updates continuously instead of waiting for user reports." - - "For builders who consume venue data through a managed relay, the relevant question is freshness lag, not request latency. Codex is measured in the pm-data-freshness bench (T0 against the Polymarket CLOB stream). The prediction markets hub data feeds tab lists each aggregator with the venues it covers." + - "For builders who consume venue data through a managed relay, the relevant question is freshness lag and WebSocket latency, not REST request latency. The pm-ws-latency bench measures trade publication lag on the Polymarket CLOB WebSocket. The prediction markets hub data feeds tab lists each aggregator with the venues it covers." disclaimer: "Uptime here means our probes succeeded from three specific regions at a polite request rate. A venue can be up for us and degraded for you, especially during regional network incidents, and a brief blip between probe cycles can go unrecorded. Treat the panel as a measured signal, not a guarantee." @@ -83,7 +81,7 @@ faq: - q: "Why do latency numbers exclude CDN cache hits?" a: "Because a cache hit measures the distance to the nearest edge node, not the venue's API. Manifold serves its entire API behind a 5 second cache and Kalshi's market list sits on CloudFront, so a naive average would mostly rank CDN proximity. Every sample carries a cache flag read from the response headers, and the headline aggregates only non cached, successful requests. The number you see is what a client polling for fresh quotes actually experiences at the origin." - q: "Does this benchmark cover throttling or data providers?" - a: "No, deliberately. What happens to each venue's API as request rates climb, including 429 onset and queueing behaviour, is its own benchmark, pm-rate-limits, which runs a careful daily ramp. And if you consume venue data through a provider such as Codex rather than calling the venue directly, the relevant number is relay freshness, measured in pm-data-freshness. This page answers two narrower questions: which venue API is fastest at a polite request rate, and is it up right now." + a: "No, deliberately. What happens to each venue's API as request rates climb, including 429 onset and queueing behaviour, is its own benchmark, pm-rate-limits, which runs a careful daily ramp. WebSocket latency and trade publication lag are covered in pm-ws-latency. This page answers two narrower questions: which venue API is fastest at a polite request rate, and is it up right now." per_chain_explainer: - slug: polymarket diff --git a/benchmarks/pm-rate-limits.yml b/benchmarks/pm-rate-limits.yml index 27e41b86..12c50271 100644 --- a/benchmarks/pm-rate-limits.yml +++ b/benchmarks/pm-rate-limits.yml @@ -79,7 +79,7 @@ faq: - q: "What is the Kalshi API rate limit?" a: "Kalshi documents tiered rate limits per access level, with the basic read tier around 20 requests per second at the time of writing. It is the only venue in this cohort with a documented token bucket. Our ramp stops at the first 429 on Kalshi out of respect for that documented contract, and the tier where it happens (or does not) is recorded in the ramp panel." - q: "Which prediction market has the fastest API?" - a: "{{best_name}} currently has the lowest warm book endpoint latency at {{best_p50}} (p50 over the last 24h). Note what this does and does not measure: it is the latency of the venue's own public API over a warm connection at a polite request rate, per region. It is not data freshness via third party providers, which is a separate benchmark (pm-data-freshness)." + a: "{{best_name}} currently has the lowest warm book endpoint latency at {{best_p50}} (p50 over the last 24h). Note what this does and does not measure: it is the latency of the venue's own public API over a warm connection at a polite request rate, per region. For data freshness and WebSocket latency, see the pm-api-latency and pm-ws-latency benches." - q: "Does Manifold have an API rate limit?" a: "Yes, 500 requests per minute per IP, documented, and Manifold explicitly welcomes bots as long as they stay on a single IP. Be aware that the whole API is served behind a cache with max-age=5 and stale-while-revalidate=10, so polling faster than every 6 seconds mostly returns cached responses. Our probes space out to 7 seconds per URL and label every sample that still comes back from cache." - q: "What are the Limitless API rate limits?" @@ -87,7 +87,7 @@ faq: - q: "How do you test rate limits without abusing the APIs?" a: "Four guardrails. Tiers stay well inside documented budgets where they exist (at most 7 percent of Polymarket's book allowance). Every run aborts as soon as throttled plus server errors exceed 1 percent of a 10 second window, and Kalshi stops at the first 429. Only one region ramps a venue at a time, on disjoint UTC hours. And every request carries an identifying User-Agent with a contact address, so a venue can reach us or filter us selectively. The measurement is throttle onset, not stress to failure." - q: "Why measure the venue APIs directly instead of a data provider?" - a: "They answer different questions. If you build directly on a venue, its native API latency and throttle behaviour set your floor, and that is what this benchmark measures. If you consume the venue through a data provider such as Codex or Predexon, what matters is how fresh the provider's relay is, which is measured separately in the pm-data-freshness benchmark. Both pages link each other so you can compare the paths." + a: "They answer different questions. If you build directly on a venue, its native API latency and throttle behaviour set your floor, and that is what this benchmark measures. For API latency at a polite rate, see pm-api-latency. For WebSocket connect and trade publication lag, see pm-ws-latency." per_chain_explainer: - slug: polymarket diff --git a/src/app/benchmarks/[slug]/page.tsx b/src/app/benchmarks/[slug]/page.tsx index fd197b5d..de1a044d 100644 --- a/src/app/benchmarks/[slug]/page.tsx +++ b/src/app/benchmarks/[slug]/page.tsx @@ -78,7 +78,7 @@ type Params = { slug: string }; // Rendered ON DEMAND (first request, then ISR-cached). Prerendering the // 25+ bench pages at build pushed the full multi-bench Prom load through // the CI runner, whose DNS resolver throttles under hundreds of lookups; -// observed 2026-06-11: /benchmarks/pm-data-freshness failing 3×60s +// observed 2026-06-11: a bench page failing 3×60s // export attempts and killing the deploy. Without the embedded variant // matrix an on-demand first render is a few seconds once per deploy per // slug, then the CDN serves it. diff --git a/src/app/prediction-markets/page.tsx b/src/app/prediction-markets/page.tsx index 79f966c1..3e8953db 100644 --- a/src/app/prediction-markets/page.tsx +++ b/src/app/prediction-markets/page.tsx @@ -8,7 +8,7 @@ import { SITE } from "@/data/site"; /** * Hub landing page for the prediction markets cohort. SSR'd against * the `pm-cohort-stats` harness gauges plus the existing PM bench - * gauges (pm-api-latency, pm-resolution-delay, pm-data-freshness). One + * gauges (pm-api-latency, pm-resolution-delay, pm-ws-latency). One * server fetch, one client tab swap between Venues and Data feeds. * * The page positions OCB as the neutral cross venue measurement layer: @@ -138,7 +138,7 @@ export default async function PredictionMarketsHubPage() { pm-resolution-delay Bench - pm-data-freshness + pm-ws-latency ,{" "} - - /benchmarks/pm-data-freshness + + /benchmarks/pm-ws-latency {" "} and{" "} diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 9486f262..f1abb55d 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -575,23 +575,12 @@ export default async function ProviderPage({ jump from a venue/data provider product page to the PM coverage hub. Hard-coded slug list, same shape as the HL companion treatment elsewhere. */} - {/* Prediction-market pill: 6 native venues + Codex. - Codex is the aggregator measured on pm-data-freshness - alongside Polymarket + Kalshi as the third data - provider indexing PM feeds; retaining the pill lets - readers jump from /products/codex to the PM hub - where its freshness ranking surfaces. Mobula was - previously in this list but does not currently - appear on any PM bench and the pill read as a - routing bug on its data-API profile. */} {( p.slug === "polymarket" || p.slug === "kalshi" || p.slug === "limitless" || - p.slug === "manifold" || p.slug === "myriad" || - p.slug === "predexon" || - p.slug === "codex" + p.slug === "predexon" ) && ( <> · diff --git a/src/components/pm-data-feeds-leaderboard.tsx b/src/components/pm-data-feeds-leaderboard.tsx index 8a8b6630..d1e73b31 100644 --- a/src/components/pm-data-feeds-leaderboard.tsx +++ b/src/components/pm-data-feeds-leaderboard.tsx @@ -8,11 +8,10 @@ import { ProviderLogo } from "@/components/provider-logo"; import type { PmDataFeedRow } from "@/lib/pm-stats"; /** - * Data feeds leaderboard for /prediction-markets. Ranks every relay - * tracked by the pm-data-freshness bench by p50 freshness lag against - * the T0 reference (the venue itself). The reference row is rendered - * with a "T0 reference" badge so it never claims a 0 ms freshness - * lead over itself. + * Data feeds leaderboard for /prediction-markets. Lists every tracked + * relay with p50 freshness lag when available. The reference row is + * rendered with a "T0 reference" badge so it never claims a 0 ms + * freshness lead over itself. */ type SortKey = "freshnessP50Ms" | "freshnessP99Ms" | "uptime24h"; diff --git a/src/data/bench-published.json b/src/data/bench-published.json index e4506e1e..1780b3b4 100644 --- a/src/data/bench-published.json +++ b/src/data/bench-published.json @@ -42,8 +42,8 @@ "perp-open-interest": "2026-06-24T13:14:37.000Z", "perp-volume-share": "2026-06-24T13:14:37.000Z", "pm-api-latency": "2026-06-16T11:48:27.000Z", - "pm-data-freshness": "2026-06-04T17:50:08.000Z", "pm-rate-limits": "2026-06-16T11:48:27.000Z", + "pm-ws-latency": "2026-07-29T00:00:00.000Z", "polygon-rpc": "2026-07-03T14:33:15.000Z", "polymarket-resolution-delay": "2026-06-16T11:48:27.000Z", "portfolio-chain-coverage": "2026-07-06T13:47:16.000Z", diff --git a/src/lib/format.ts b/src/lib/format.ts index 77705c6a..7879d82c 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -117,9 +117,8 @@ export function fmtUnit(value: number, unit: string) { // fmtUnit produced but unitSuffix independently returned " min". if (value >= 60000) return `${(value / 60000).toFixed(1)} min`; if (value >= 1000) return `${(value / 1000).toFixed(2)} s`; - // Sub-millisecond values keep one decimal: pm-data-freshness's 0.5 ms - // anchor rendered "1 ms", contradicting the 0.5 published by the - // citable API (2x apart, flagged by the coherence audit). + // Sub-millisecond values keep one decimal: 0.5 ms would render as + // "1 ms" without this, contradicting the published citable value. if (value > 0 && value < 1) return `${value.toFixed(1)} ms`; return `${value.toFixed(0)} ms`; } diff --git a/src/lib/pm-stats.ts b/src/lib/pm-stats.ts index 8ee00198..c59f4a8c 100644 --- a/src/lib/pm-stats.ts +++ b/src/lib/pm-stats.ts @@ -1,16 +1,9 @@ /** * Server-side helper for the /prediction-markets hub page. Reads the - * `pm_venue_*` gauges exposed by the `pm-cohort-stats` harness, plus a - * handful of cross-bench histograms already in prod: + * `pm_venue_*` gauges exposed by the `pm-cohort-stats` harness, plus + * cross-bench histograms: * - pmapi_request_duration_seconds_bucket (bench pm-api-latency) - * - pm_health (bench pm-data-freshness, uptime) * - pmres_resolution_delay_seconds_bucket (bench pm-resolution-delay) - * - pm_freshness_delta_ms_bucket (bench pm-data-freshness) - * - * The freshness bench publishes a histogram keyed by - * `{provider, venue, kind}` (NOT a pre-aggregated p50/p99 gauge), so we - * compute the quantile client-side via `histogram_quantile()` and key - * the data-feed rows by the `provider` label. * * Shape mirrors `hl-builder-stats.ts` exactly so the page composes the * same way as /hyperliquid: one server fetch, one client tab swap. @@ -88,10 +81,6 @@ const PM_VENUES: VenueSeed[] = [ ]; const PM_DATA_FEEDS: DataFeedSeed[] = [ - // Mobula dropped 2026-07-19: they stopped serving the PM WebSocket - // relay, so the hub cohort and pm-data-freshness bench no longer - // rank a dead endpoint. - { slug: "codex", name: "Codex", coverage: ["polymarket", "kalshi"], isReference: false }, { slug: "predexon", name: "Predexon", coverage: ["polymarket", "kalshi", "limitless"], isReference: false }, ]; @@ -136,9 +125,6 @@ export async function fetchPmCohortFresh(): Promise { marketsAbove1m, apiLatencyP50, resolutionDelayP50, - freshnessP50, - freshnessP99, - uptime24h, ] = await Promise.all([ queryVector(prom, `pm_venue_volume_30d_usd`), queryVector(prom, `pm_venue_volume_24h_usd`), @@ -160,18 +146,6 @@ export async function fetchPmCohortFresh(): Promise { prom, `histogram_quantile(0.5, sum by (le) (rate(pmres_resolution_delay_seconds_bucket[30d])))`, ), - queryVector( - prom, - `histogram_quantile(0.5, sum by (provider, le) (rate(pm_freshness_delta_ms_bucket{venue="polymarket",provider!="polymarket"}[1h])))`, - ), - queryVector( - prom, - `histogram_quantile(0.99, sum by (provider, le) (rate(pm_freshness_delta_ms_bucket{venue="polymarket",provider!="polymarket"}[1h])))`, - ), - queryVector( - prom, - `avg_over_time(pm_health{venue="polymarket",provider!="polymarket"}[24h])`, - ), ]); // Map raw vectors onto the venue rows. Order of the seed list is kept @@ -228,31 +202,15 @@ export async function fetchPmCohortFresh(): Promise { row.medianResolutionDelayMin = Number.isFinite(s.value) ? s.value / 60 : null; } - // Data feeds. Coverage stays static (taken from the seed); the live - // values come from the freshness bench, which keys by `provider`. The - // T0 reference (Polymarket CLOB) is excluded from PM_DATA_FEEDS since - // measuring it against itself would always yield zero lag. - const freshnessP50By = new Map(); - const freshnessP99By = new Map(); - const uptimeBy = new Map(); - for (const s of freshnessP50 ?? []) { - const r = s.labels.provider; - if (r) freshnessP50By.set(r, s.value); - } - for (const s of freshnessP99 ?? []) { - const r = s.labels.provider; - if (r) freshnessP99By.set(r, s.value); - } - for (const s of uptime24h ?? []) { - const r = s.labels.provider; - if (r) uptimeBy.set(r, s.value); - } + // Data feeds. Coverage stays static (taken from the seed). The + // freshness bench was retired in 2026-07, so all numeric fields are null + // until a replacement harness ships. const dataFeeds: PmDataFeedRow[] = PM_DATA_FEEDS.map((f) => ({ slug: f.slug, name: f.name, - freshnessP50Ms: f.isReference ? null : (freshnessP50By.get(f.slug) ?? null), - freshnessP99Ms: f.isReference ? null : (freshnessP99By.get(f.slug) ?? null), - uptime24h: f.isReference ? null : (uptimeBy.get(f.slug) ?? null), + freshnessP50Ms: null, + freshnessP99Ms: null, + uptime24h: null, coverage: f.coverage, isReference: f.isReference, })); diff --git a/src/lib/pm-venue-context.ts b/src/lib/pm-venue-context.ts index bf9c10fd..a3d061c5 100644 --- a/src/lib/pm-venue-context.ts +++ b/src/lib/pm-venue-context.ts @@ -22,8 +22,7 @@ export const PM_VENUE_META: Record polymarket: { url: "https://polymarket.com", chainLabel: "Polygon" }, kalshi: { url: "https://kalshi.com", chainLabel: "Offchain US" }, limitless: { url: "https://limitless.exchange", chainLabel: "Base" }, - manifold: { url: "https://manifold.markets", chainLabel: "Offchain play money" }, - myriad: { url: "https://myriad.markets", chainLabel: "Offchain multi chain" }, + myriad: { url: "https://myriad.markets", chainLabel: "Abstract L2" }, }; // Logo paths come from the central manifest (`src/lib/logo-manifest.ts`) @@ -34,7 +33,6 @@ export const PM_VENUE_META: Record // mobula, and predexon. export const PM_FEED_META: Record = { "polymarket-clob": { url: "https://polymarket.com" }, - codex: { url: "https://codex.io" }, predexon: { url: "https://predexon.com" }, }; @@ -128,9 +126,9 @@ export function benchRowsForVenue( tone: "cyan", }, { - benchSlug: "pm-data-freshness", - label: "Data freshness", - blurb: "Third party relays vs the venue's canonical T0 stream.", + benchSlug: "pm-ws-latency", + label: "WS latency", + blurb: "Connect-to-snapshot and trade publication lag on the venue WebSocket.", rank: null, cohortSize, value: null, @@ -153,9 +151,9 @@ export function benchRowsForVenue( export function benchRowsForDataFeed(feed: PmDataFeedRow): PmVenueBenchRow[] { return [ { - benchSlug: "pm-data-freshness", - label: "Freshness vs T0", - blurb: "Lag between this relay and Polymarket CLOB T0.", + benchSlug: "pm-ws-latency", + label: "WS latency", + blurb: "Venue WebSocket connect-to-snapshot and trade publication lag.", rank: null, cohortSize: 0, value: feed.isReference ? null : fmtMs(feed.freshnessP50Ms), diff --git a/src/lib/search-featured.ts b/src/lib/search-featured.ts index 86403404..73253daf 100644 --- a/src/lib/search-featured.ts +++ b/src/lib/search-featured.ts @@ -19,7 +19,7 @@ import { loadSnapshotFromBlob } from "@/lib/bench-blob"; import { leader, fieldValue } from "@/lib/citation"; const FEATURED_BENCH_SLUGS = [ - "pm-data-freshness", + "pm-ws-latency", "aggregator-head-lag", "l1-finality", "rpc-capabilities", From a6152176193dd02aa2cf6bc41e7c2b723f3c06fb Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:25:33 +0200 Subject: [PATCH 12/96] audit: regenerate bench-published (93 entries), drop dead manifold code (#1667) --- src/components/pm-venues-leaderboard.tsx | 17 ---------- src/data/bench-published.json | 43 ++++++++++++++++++++---- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/src/components/pm-venues-leaderboard.tsx b/src/components/pm-venues-leaderboard.tsx index 1c9515dc..fe36c143 100644 --- a/src/components/pm-venues-leaderboard.tsx +++ b/src/components/pm-venues-leaderboard.tsx @@ -151,7 +151,6 @@ export function PmVenuesLeaderboard({ rows }: { rows: PmVenueRow[] }) { {r.name} - {r.slug === "manifold" && } @@ -175,8 +172,6 @@ export function PmVenuesLeaderboard({ rows }: { rows: PmVenueRow[] }) { tip={ r.slug === "polymarket" || r.slug === "limitless" ? "Proxy: DefiLlama protocol TVL (Polymarket gamma openInterest is deprecated)" - : r.slug === "manifold" - ? "Play-money: totalLiquidity (mana) × charity rate 0.001" : undefined } > @@ -205,18 +200,6 @@ export function PmVenuesLeaderboard({ rows }: { rows: PmVenueRow[] }) { ); } -function PlayMoneyBadge() { - return ( - - Play-money - - ); -} - function TypeBadge({ type }: { type: "onchain" | "offchain" }) { const label = type === "onchain" ? "On chain" : "Off chain"; return ( diff --git a/src/data/bench-published.json b/src/data/bench-published.json index 1780b3b4..0da710e1 100644 --- a/src/data/bench-published.json +++ b/src/data/bench-published.json @@ -1,40 +1,55 @@ { "aggregator-head-lag": "2026-04-28T13:37:44.000Z", "arbitrum-rpc": "2026-07-03T14:33:15.000Z", + "asset-registry-coverage": "2026-07-24T16:00:55.000Z", "avalanche-rpc": "2026-07-03T14:33:15.000Z", + "axelar-gmp-latency": "2026-07-25T23:58:45.000Z", "base-rpc": "2026-07-03T14:33:15.000Z", "berachain-rpc": "2026-07-03T22:44:42.000Z", "blast-rpc": "2026-07-03T22:44:42.000Z", "bnb-rpc": "2026-07-03T14:33:15.000Z", "bridge-fee": "2026-04-28T13:20:36.000Z", "bridge-quote-latency": "2026-04-28T13:20:36.000Z", - "bridge-revenue": "2026-06-02T16:07:35.000Z", "buyback-audit": "2026-05-21T17:54:38.000Z", "celo-rpc": "2026-07-03T22:44:42.000Z", + "chainlink-ccip-latency": "2026-07-25T23:25:52.000Z", + "cosmos-hub-rpc": "2026-07-24T12:35:13.000Z", "cronos-rpc": "2026-07-03T22:44:42.000Z", + "dex-network-coverage": "2026-07-24T16:00:55.000Z", "ethereum-rpc": "2026-07-03T14:33:15.000Z", + "evm-block-builders": "2026-07-16T16:01:49.000Z", "evm-quote-latency": "2026-06-26T12:41:12.000Z", "explorer-chain-coverage": "2026-07-07T14:11:20.000Z", "fraxtal-rpc": "2026-07-03T22:44:42.000Z", "gas-estimation": "2026-05-20T18:43:56.000Z", "gnosis-rpc": "2026-07-03T22:44:42.000Z", + "hyperlane-message-latency": "2026-07-25T23:43:50.000Z", "hyperliquid-frontends": "2026-06-02T16:07:35.000Z", "hyperliquid-hip3-deployers": "2026-06-10T23:44:26.000Z", + "hyperliquid-rpc": "2026-07-24T02:36:11.000Z", + "immutable-rpc": "2026-07-26T14:46:53.000Z", "indexing-freshness": "2026-07-07T21:42:57.000Z", + "injective-rpc": "2026-07-24T12:35:13.000Z", + "ink-rpc": "2026-07-24T12:35:13.000Z", + "kaia-rpc": "2026-07-24T12:35:13.000Z", "l1-finality": "2026-05-06T19:31:46.000Z", "l2-block-time": "2026-05-19T18:59:50.000Z", + "layerzero-message-latency": "2026-07-25T23:35:46.000Z", "linea-rpc": "2026-07-03T14:33:15.000Z", "mantle-rpc": "2026-07-03T14:33:15.000Z", "megaeth-rpc": "2026-07-08T07:36:17.000Z", "metadata-coverage": "2026-05-01T15:22:33.000Z", + "mev-protect-rpc": "2026-07-10T16:15:14.000Z", + "mode-rpc": "2026-07-26T14:46:53.000Z", "monad-rpc": "2026-07-08T07:36:17.000Z", "moonbeam-rpc": "2026-07-03T22:44:42.000Z", - "asset-registry-coverage": "2026-05-05T14:15:05.000Z", - "dex-network-coverage": "2026-07-24T00:00:00.000Z", "network-fees": "2026-06-02T13:30:08.000Z", + "neutron-rpc": "2026-07-24T12:35:13.000Z", "nft-collection-metadata": "2026-06-15T15:07:26.000Z", + "opbnb-rpc": "2026-07-24T12:35:13.000Z", "optimism-rpc": "2026-07-03T14:33:15.000Z", "oracle-deviation": "2026-05-21T17:54:38.000Z", + "osmosis-rpc": "2026-07-23T18:06:49.000Z", "perp-execution-quality": "2026-07-03T15:11:55.000Z", "perp-fees": "2026-05-07T14:44:23.000Z", "perp-funding": "2026-06-11T16:01:46.000Z", @@ -43,24 +58,38 @@ "perp-volume-share": "2026-06-24T13:14:37.000Z", "pm-api-latency": "2026-06-16T11:48:27.000Z", "pm-rate-limits": "2026-06-16T11:48:27.000Z", - "pm-ws-latency": "2026-07-29T00:00:00.000Z", + "pm-ws-latency": "2026-07-28T21:59:34.000Z", + "polkadot-rpc": "2026-07-15T17:14:34.000Z", "polygon-rpc": "2026-07-03T14:33:15.000Z", "polymarket-resolution-delay": "2026-06-16T11:48:27.000Z", "portfolio-chain-coverage": "2026-07-06T13:47:16.000Z", + "ronin-rpc": "2026-07-26T14:46:53.000Z", "rpc-capabilities": "2026-05-20T18:43:56.000Z", "rpc-keyed-latency": "2026-07-07T16:12:22.000Z", + "rpc-reliability": "2026-07-16T16:01:49.000Z", + "rwa-yield-accuracy": "2026-07-24T13:56:11.000Z", "scroll-rpc": "2026-07-03T14:33:15.000Z", - "solana-dex-quote-latency": "2026-06-02T16:07:35.000Z", - "solana-tx-landing": "2026-05-21T12:25:08.000Z", - "solana-tx-landing-latency": "2026-05-21T17:54:38.000Z", + "sei-rpc": "2026-07-26T14:46:53.000Z", + "solana-rpc": "2026-07-12T15:37:15.000Z", "soneium-rpc": "2026-07-03T22:44:42.000Z", "sonic-rpc": "2026-07-03T22:44:42.000Z", "stablecoin-peg": "2026-05-20T18:43:56.000Z", "stablecoin-peg-usdt-anchored": "2026-05-20T21:20:02.000Z", "taiko-rpc": "2026-07-03T22:44:42.000Z", "token-deployment-cost": "2026-06-09T15:34:12.000Z", + "token-quote-coverage": "2026-07-24T23:30:18.000Z", + "tokenized-stock-peg": "2026-07-14T12:09:41.000Z", + "tokenized-stock-weekend-drift": "2026-07-14T12:09:41.000Z", + "tron-rpc": "2026-07-24T02:36:11.000Z", "unichain-rpc": "2026-07-03T22:44:42.000Z", + "usdy-nav-basis": "2026-07-14T16:34:18.000Z", "validator-yield": "2026-05-21T17:54:38.000Z", "wallet-labels-coverage": "2026-05-08T20:37:49.000Z", + "world-chain-rpc": "2026-07-24T12:35:13.000Z", + "wormhole-vaa-latency": "2026-07-24T15:13:59.000Z", + "ws-head-latency-base": "2026-07-16T11:03:31.000Z", + "ws-head-latency-ethereum": "2026-07-16T12:20:29.000Z", + "ws-head-latency-solana": "2026-07-16T11:03:31.000Z", + "xstocks-peg": "2026-07-14T16:34:18.000Z", "zksync-rpc": "2026-07-03T22:44:42.000Z" } From 8ad65be847712e90b5480fa9e7c67a033f32209c Mon Sep 17 00:00:00 2001 From: Florent Tapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:51:33 +0200 Subject: [PATCH 13/96] =?UTF-8?q?restore:=20reports=20pages/nav/sitemap=20?= =?UTF-8?q?dropped=20in=20dev=E2=86=90main=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/reports/[category]/[slug]/page.tsx | 432 ++++++++++++++++++ src/app/reports/[category]/page.tsx | 119 +++++ src/app/reports/[category]/rss.xml/route.ts | 71 +++ src/app/reports/page.tsx | 146 ++++++ src/app/reports/rss.xml/route.ts | 66 +++ src/components/reports/region-winners.tsx | 155 +++++++ src/components/reports/report-card.tsx | 119 +++++ src/components/reports/stat-table.tsx | 167 +++++++ src/components/site-footer.tsx | 1 + src/components/site-header.tsx | 5 + .../rpc/2026-08-state-of-public-rpc.mdx | 200 ++++++++ src/lib/reports/loader.ts | 105 +++++ src/lib/sitemap-builder.ts | 34 ++ 13 files changed, 1620 insertions(+) create mode 100644 src/app/reports/[category]/[slug]/page.tsx create mode 100644 src/app/reports/[category]/page.tsx create mode 100644 src/app/reports/[category]/rss.xml/route.ts create mode 100644 src/app/reports/page.tsx create mode 100644 src/app/reports/rss.xml/route.ts create mode 100644 src/components/reports/region-winners.tsx create mode 100644 src/components/reports/report-card.tsx create mode 100644 src/components/reports/stat-table.tsx create mode 100644 src/content/reports/rpc/2026-08-state-of-public-rpc.mdx create mode 100644 src/lib/reports/loader.ts diff --git a/src/app/reports/[category]/[slug]/page.tsx b/src/app/reports/[category]/[slug]/page.tsx new file mode 100644 index 00000000..e6c21e83 --- /dev/null +++ b/src/app/reports/[category]/[slug]/page.tsx @@ -0,0 +1,432 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { MDXRemote } from "next-mdx-remote/rsc"; +import remarkGfm from "remark-gfm"; +import { StatTable } from "@/components/reports/stat-table"; +import { RegionWinners } from "@/components/reports/region-winners"; +import { + getAllReports, + getReport, + REPORT_CATEGORY_META, +} from "@/lib/reports/loader"; +import { safeJsonLd } from "@/lib/jsonld"; +import { PERSON_ID } from "@/lib/hub-jsonld"; +import { SITE } from "@/data/site"; + +export const revalidate = 3600; + +const SERIF: React.CSSProperties = { fontFamily: "var(--font-serif)" }; + +type Props = { params: Promise<{ category: string; slug: string }> }; + +export async function generateStaticParams() { + const reports = getAllReports(); + return reports.map((r) => ({ category: r.categorySlug, slug: r.slug })); +} + +export async function generateMetadata({ params }: Props): Promise { + const { category, slug } = await params; + const report = getReport(category, slug); + if (!report) return {}; + + const canonical = `${SITE.url}/reports/${category}/${slug}`; + const ogImage = report.ogImage ?? `${SITE.url}/opengraph-image`; + const social = `${report.title} · OpenChainBench`; + + return { + title: report.title, + description: report.summary, + alternates: { canonical }, + openGraph: { + title: social, + description: report.summary, + url: canonical, + type: "article", + siteName: "OpenChainBench", + publishedTime: new Date(report.publishedAt).toISOString(), + authors: [report.author], + images: [{ url: ogImage, width: 1200, height: 630 }], + }, + twitter: { + card: "summary_large_image", + title: social, + description: report.summary, + site: "@OpenChainBench", + images: [ogImage], + }, + }; +} + +function slugify(text: string): string { + return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); +} + +const MDX_COMPONENTS = { + StatTable, + RegionWinners, + + // Table of contents — pipe-separated section names, auto-generates anchor hrefs + TOC: ({ sections }: { sections: string }) => { + const items = sections.split("|").map((s) => s.trim()); + return ( + + ); + }, + + // Summary box — key findings list at article top + Summary: ({ children }: { children: React.ReactNode }) => ( + + ), + + // Orange highlighter on key sentences + Mark: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + + // Decision Framework card — used explicitly in MDX + UseCase: ({ label, children }: { label: string; children: React.ReactNode }) => ( +
+

+ {label} +

+
+ {children} +
+
+ ), + + // Economist-style section header with scroll-target ID for TOC anchors + h2: (props: React.HTMLAttributes) => { + const text = typeof props.children === "string" ? props.children : ""; + return ( +
+

+ {props.children} +

+
+ ); + }, + + h3: (props: React.HTMLAttributes) => ( +

+ ), + + p: (props: React.HTMLAttributes) => ( +

+ ), + + ul: (props: React.HTMLAttributes) => ( +