diff --git a/benchmarks/pm-fee-comparison.yml b/benchmarks/pm-fee-comparison.yml index 42b34b35a..cc5aa26d1 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 8950a03cb..2f037139d 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 000000000..01d7ac64a --- /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 000000000..f735f40f7 --- /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 000000000..733b43948 --- /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 000000000..46a921a43 --- /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 000000000..1c3a03921 --- /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 000000000..d5318cf86 --- /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 000000000..a2cdfb58d --- /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 000000000..945e8af5e --- /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 000000000..47d0ff8a9 --- /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 000000000..5dea1887c --- /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 000000000..5ce85e9dd --- /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 000000000..d5318cf86 --- /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=