diff --git a/benchmarks/pm-api-latency.yml b/benchmarks/pm-api-latency.yml index b022741a..ef7d1e2b 100644 --- a/benchmarks/pm-api-latency.yml +++ b/benchmarks/pm-api-latency.yml @@ -105,6 +105,12 @@ per_chain_explainer: - slug: smarkets h2: Smarkets API latency body: "Smarkets is a UK-regulated peer-to-peer betting exchange with a public CLOB order book endpoint. The harness probes /v3/markets/{id}/ for price (5s interval) and /v3/markets/{id}/quotes/ for the book (5s interval). No authentication is required for either. No visible CDN layer sits between the probe and origin, so latency reflects the API server response time directly. Smarkets is the only non-US venue in the cohort and its primary user base and server infrastructure are UK-centric, which affects the relative latency from US East vs EU West probe nodes." + - slug: metaculus + h2: Metaculus API latency + body: "Metaculus is a public forecasting aggregator with no real-money markets, probed via its /api2/questions/{id}/ endpoint every 5 seconds from three regions. No authentication is required and no CDN cache layer was detected on the single-question endpoint. Metaculus has no order book and no WebSocket: all data access is REST. The uptime panel shows the live health gauge for the question API; the latency reflects the Django origin server without a CDN buffer. For builders querying aggregate community forecasts rather than market prices, Metaculus is the only venue in this cohort with a purely public read API at sub-second latency." + - slug: betfair + h2: Betfair API latency + body: "Betfair is the world's largest regulated betting exchange, probed via its Exchange API listMarketBook endpoint (POST with JSON body). Unlike every other venue in this cohort, all Betfair API calls are POST and require X-Application (developer AppKey) plus X-Authentication (session token). Betfair's infrastructure is UK-centric, so latency from us-east and sgp is higher than from eu-west. The latency shown is the full round trip of a listMarketBook call on a pinned binary market. For how Betfair behaves under rising request rates, see the pm-rate-limits bench." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/pm-rate-limits @@ -112,7 +118,7 @@ prometheus: window: 24h expected_freshness_seconds: 300 -rank_matrix_query: 1000 * label_replace(histogram_quantile(0.50, sum by (venue, region, le) (rate(pmapi_request_duration_seconds_bucket{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad|predictit|smarkets",conn="warm",class="price",cache!="hit"}[24h]))), "provider", "$1", "venue", "(.+)") +rank_matrix_query: 1000 * label_replace(histogram_quantile(0.50, sum by (venue, region, le) (rate(pmapi_request_duration_seconds_bucket{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad|predictit|smarkets|metaculus|betfair",conn="warm",class="price",cache!="hit"}[24h]))), "provider", "$1", "venue", "(.+)") dimensions: venue: @@ -124,6 +130,8 @@ dimensions: - { value: myriad, label: Myriad } - { value: predictit, label: PredictIt } - { value: smarkets, label: Smarkets } + - { value: metaculus, label: Metaculus } + - { value: betfair, label: Betfair } region: - { value: all, label: All regions } - { value: us-east, label: US East } @@ -134,21 +142,21 @@ metric_panels: - id: uptime_24h label: Uptime 24h description: "Share of probe cycles that succeeded over the last 24 hours, averaged across the three probe regions. 100 percent means every 5 second probe of the venue's API came back healthy." - metric: 100 * avg(avg_over_time(pmapi_health{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad|predictit|smarkets"}[24h])) + metric: 100 * avg(avg_over_time(pmapi_health{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad|predictit|smarkets|metaculus|betfair"}[24h])) label_key: venue unit: pct higher_is_better: true - id: uptime_7d label: Uptime 7d description: "Same health gauge averaged over 7 days. Short outages that vanish from the 24h figure stay visible here for a week." - metric: 100 * avg(avg_over_time(pmapi_health{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad|predictit|smarkets"}[7d])) + metric: 100 * avg(avg_over_time(pmapi_health{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad|predictit|smarkets|metaculus|betfair"}[7d])) label_key: venue unit: pct higher_is_better: true - id: uptime_30d label: Uptime 30d description: "Same health gauge averaged over 30 days. Feeds the ledger's 30d window toggle so a one-hour outage that's already aged out of the 24h panel still shows up in the table over a month." - metric: 100 * avg(avg_over_time(pmapi_health{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad|predictit|smarkets"}[30d])) + metric: 100 * avg(avg_over_time(pmapi_health{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad|predictit|smarkets|metaculus|betfair"}[30d])) label_key: venue unit: pct higher_is_better: true @@ -156,7 +164,7 @@ metric_panels: - id: cold_connect_p50 label: Cold connect description: "TCP plus TLS handshake time on the once a minute cold probe with keep alives disabled. The startup cost a brand new client pays before its first request." - metric: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_connect_seconds_bucket{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad|predictit|smarkets"}[24h])) by (le)) + metric: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_connect_seconds_bucket{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad|predictit|smarkets|metaculus|betfair"}[24h])) by (le)) label_key: venue unit: ms higher_is_better: false @@ -259,3 +267,29 @@ providers: success: clamp_max(sum(rate(pmapi_requests_total{venue="smarkets",source="direct",class="price",conn="warm",outcome="ok"}[24h])) / sum(rate(pmapi_requests_total{venue="smarkets",source="direct",class="price",conn="warm",outcome!="probe_invalid"}[24h])), 1) sample_size: sum(increase(pmapi_requests_total{venue="smarkets",source="direct",class="price",conn="warm",outcome!="probe_invalid"}[24h])) series: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{venue="smarkets",source="direct",class="price",conn="warm",cache!="hit"}[1h])) by (le)) + + - slug: metaculus + name: Metaculus + tag: "Public forecasting platform, no auth, no order book, binary questions probed via /api2/questions/{id}/" + formula: "p50 of warm round trips against the single question endpoint /api2/questions/{id}/, successful requests only, 24h window. No CDN cache layer detected." + queries: + p50: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{venue="metaculus",source="direct",class="price",conn="warm",cache!="hit"}[24h])) by (le)) + p90: 1000 * histogram_quantile(0.90, sum(rate(pmapi_request_duration_seconds_bucket{venue="metaculus",source="direct",class="price",conn="warm",cache!="hit"}[24h])) by (le)) + p99: 1000 * histogram_quantile(0.99, sum(rate(pmapi_request_duration_seconds_bucket{venue="metaculus",source="direct",class="price",conn="warm",cache!="hit"}[24h])) by (le)) + mean: 1000 * sum(rate(pmapi_request_duration_seconds_sum{venue="metaculus",source="direct",class="price",conn="warm",cache!="hit"}[24h])) / sum(rate(pmapi_request_duration_seconds_count{venue="metaculus",source="direct",class="price",conn="warm",cache!="hit"}[24h])) + success: clamp_max(sum(rate(pmapi_requests_total{venue="metaculus",source="direct",class="price",conn="warm",outcome="ok"}[24h])) / sum(rate(pmapi_requests_total{venue="metaculus",source="direct",class="price",conn="warm",outcome!="probe_invalid"}[24h])), 1) + sample_size: sum(increase(pmapi_requests_total{venue="metaculus",source="direct",class="price",conn="warm",outcome!="probe_invalid"}[24h])) + series: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{venue="metaculus",source="direct",class="price",conn="warm",cache!="hit"}[1h])) by (le)) + + - slug: betfair + name: Betfair + tag: "UK FCA-regulated exchange, auth required, price endpoint is listMarketBook POST" + formula: "p50 of warm round trips against the Exchange API listMarketBook endpoint (POST, pinned binary market), successful requests only, 24h window. Requires BETFAIR_APP_KEY + credentials to run." + queries: + p50: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{venue="betfair",source="direct",class="price",conn="warm",cache!="hit"}[24h])) by (le)) + p90: 1000 * histogram_quantile(0.90, sum(rate(pmapi_request_duration_seconds_bucket{venue="betfair",source="direct",class="price",conn="warm",cache!="hit"}[24h])) by (le)) + p99: 1000 * histogram_quantile(0.99, sum(rate(pmapi_request_duration_seconds_bucket{venue="betfair",source="direct",class="price",conn="warm",cache!="hit"}[24h])) by (le)) + mean: 1000 * sum(rate(pmapi_request_duration_seconds_sum{venue="betfair",source="direct",class="price",conn="warm",cache!="hit"}[24h])) / sum(rate(pmapi_request_duration_seconds_count{venue="betfair",source="direct",class="price",conn="warm",cache!="hit"}[24h])) + success: clamp_max(sum(rate(pmapi_requests_total{venue="betfair",source="direct",class="price",conn="warm",outcome="ok"}[24h])) / sum(rate(pmapi_requests_total{venue="betfair",source="direct",class="price",conn="warm",outcome!="probe_invalid"}[24h])), 1) + sample_size: sum(increase(pmapi_requests_total{venue="betfair",source="direct",class="price",conn="warm",outcome!="probe_invalid"}[24h])) + series: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{venue="betfair",source="direct",class="price",conn="warm",cache!="hit"}[1h])) by (le)) diff --git a/benchmarks/pm-rate-limits.yml b/benchmarks/pm-rate-limits.yml index 12c50271..e0451a35 100644 --- a/benchmarks/pm-rate-limits.yml +++ b/benchmarks/pm-rate-limits.yml @@ -111,6 +111,12 @@ per_chain_explainer: - slug: smarkets h2: Smarkets rate limits body: "Smarkets showed no visible rate limiting during reconnaissance probes at up to 6 rapid requests per second. The daily ramp uses conservative tiers (10, 25, 50 requests per 10 seconds) with the standard abort guard. The CLOB quotes endpoint returns full bid-offer depth for the pinned market on every request. Unlike Polymarket which queues bursts under Cloudflare, Smarkets returns direct origin responses, so the ramp records actual latency scaling rather than queue inflation." + - slug: metaculus + h2: Metaculus rate limits + body: "Metaculus does not document API rate limits. The harness uses the most conservative ramp in the cohort (5/10/20 requests per 10 seconds) with the standard abort guard. There is no order book endpoint and no WebSocket. Because Metaculus is a forecasting aggregator rather than a betting exchange, its API is designed for low-frequency access; polling faster than once per 5 seconds on a single question likely hits undocumented limits. Warm price endpoint p50: {{p50:metaculus}}." + - slug: betfair + h2: Betfair rate limits + body: "Betfair's Exchange API documents a rate limit of 5 requests per second per AppKey for the listMarketBook endpoint, enforced with 429 responses. The daily ramp uses tiers of 10/25/50 requests per 10 seconds and stops at the first 429, consistent with the documented contract. Unlike Polymarket, Betfair does not queue under Cloudflare — it applies a hard token bucket. All API calls are POST with JSON body and require X-Application (AppKey) plus X-Authentication (session token). Betfair is the only venue in this cohort where the price probe is a POST request. Warm book p50: {{p50:betfair}}." source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/pm-rate-limits @@ -118,7 +124,7 @@ prometheus: window: 24h expected_freshness_seconds: 300 -rank_matrix_query: 1000 * label_replace(histogram_quantile(0.50, sum by (venue, region, le) (rate(pmapi_request_duration_seconds_bucket{cache!="hit",conn="warm",class="book"}[24h]) or rate(pmapi_request_duration_seconds_bucket{cache!="hit",conn="warm",class="price",venue=~"myriad|predictit"}[24h]))), "provider", "$1", "venue", "(.+)") +rank_matrix_query: 1000 * label_replace(histogram_quantile(0.50, sum by (venue, region, le) (rate(pmapi_request_duration_seconds_bucket{cache!="hit",conn="warm",class="book"}[24h]) or rate(pmapi_request_duration_seconds_bucket{cache!="hit",conn="warm",class="price",venue=~"myriad|predictit|metaculus"}[24h]))), "provider", "$1", "venue", "(.+)") dimensions: venue: @@ -130,6 +136,8 @@ dimensions: - { value: myriad, label: Myriad } - { value: predictit, label: PredictIt } - { value: smarkets, label: Smarkets } + - { value: metaculus, label: Metaculus } + - { value: betfair, label: Betfair } region: - { value: all, label: All regions } - { value: us-east, label: US East } @@ -140,7 +148,7 @@ metric_panels: - id: ramp_added_latency label: Ramp added latency description: "Worst tier of the daily ramp: p50 latency during the burst minus the warm baseline p50 of the previous hour, same region. Venues that queue under load instead of returning 429 show up here." - metric: 1000 * max(max_over_time(pmapi_ramp_added_latency_seconds{}[24h])) + metric: 1000 * max(max_over_time(pmapi_ramp_added_latency_seconds{venue!="predictit",venue!="myriad",venue!="metaculus"}[24h])) label_key: venue unit: ms higher_is_better: false @@ -267,3 +275,29 @@ providers: success: clamp_max(sum(rate(pmapi_requests_total{venue="smarkets",class="book",conn="warm",outcome="ok"}[24h])) / sum(rate(pmapi_requests_total{venue="smarkets",class="book",conn="warm",outcome!="probe_invalid"}[24h])), 1) sample_size: sum(increase(pmapi_requests_total{venue="smarkets",class="book",conn="warm",outcome!="probe_invalid"}[24h])) series: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{cache!="hit",venue="smarkets",class="book",conn="warm"}[1h])) by (le)) + + - slug: metaculus + name: Metaculus + tag: "Public forecasting platform, no auth, no book endpoint, conservative ramp 5/10/20 per 10s" + formula: "p50 of warm price requests against /api2/questions/{id}/, successful requests only, 24h window. No book endpoint. Ramp at 5/10/20 per 10s (limits undocumented)." + queries: + p50: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{cache!="hit",venue="metaculus",class="price",conn="warm"}[24h])) by (le)) + p90: 1000 * histogram_quantile(0.90, sum(rate(pmapi_request_duration_seconds_bucket{cache!="hit",venue="metaculus",class="price",conn="warm"}[24h])) by (le)) + p99: 1000 * histogram_quantile(0.99, sum(rate(pmapi_request_duration_seconds_bucket{cache!="hit",venue="metaculus",class="price",conn="warm"}[24h])) by (le)) + mean: 1000 * sum(rate(pmapi_request_duration_seconds_sum{cache!="hit",venue="metaculus",class="price",conn="warm"}[24h])) / sum(rate(pmapi_request_duration_seconds_count{cache!="hit",venue="metaculus",class="price",conn="warm"}[24h])) + success: clamp_max(sum(rate(pmapi_requests_total{venue="metaculus",class="price",conn="warm",outcome="ok"}[24h])) / sum(rate(pmapi_requests_total{venue="metaculus",class="price",conn="warm",outcome!="probe_invalid"}[24h])), 1) + sample_size: sum(increase(pmapi_requests_total{venue="metaculus",class="price",conn="warm",outcome!="probe_invalid"}[24h])) + series: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{cache!="hit",venue="metaculus",class="price",conn="warm"}[1h])) by (le)) + + - slug: betfair + name: Betfair + tag: "UK FCA-regulated exchange, auth required, listMarketBook POST, token bucket rate limiting" + formula: "p50 of warm listMarketBook POST requests against the Exchange API for the pinned binary market, successful requests only, 24h window. Requires BETFAIR_APP_KEY + credentials. Ramp at 10/25/50 per 10s, stops on first 429." + queries: + p50: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{cache!="hit",venue="betfair",class="book",conn="warm"}[24h])) by (le)) + p90: 1000 * histogram_quantile(0.90, sum(rate(pmapi_request_duration_seconds_bucket{cache!="hit",venue="betfair",class="book",conn="warm"}[24h])) by (le)) + p99: 1000 * histogram_quantile(0.99, sum(rate(pmapi_request_duration_seconds_bucket{cache!="hit",venue="betfair",class="book",conn="warm"}[24h])) by (le)) + mean: 1000 * sum(rate(pmapi_request_duration_seconds_sum{cache!="hit",venue="betfair",class="book",conn="warm"}[24h])) / sum(rate(pmapi_request_duration_seconds_count{cache!="hit",venue="betfair",class="book",conn="warm"}[24h])) + success: clamp_max(sum(rate(pmapi_requests_total{venue="betfair",class="book",conn="warm",outcome="ok"}[24h])) / sum(rate(pmapi_requests_total{venue="betfair",class="book",conn="warm",outcome!="probe_invalid"}[24h])), 1) + sample_size: sum(increase(pmapi_requests_total{venue="betfair",class="book",conn="warm",outcome!="probe_invalid"}[24h])) + series: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{cache!="hit",venue="betfair",class="book",conn="warm"}[1h])) by (le)) diff --git a/harnesses/pm-rate-limits/cmd/script/betfair_auth.go b/harnesses/pm-rate-limits/cmd/script/betfair_auth.go new file mode 100644 index 00000000..98882669 --- /dev/null +++ b/harnesses/pm-rate-limits/cmd/script/betfair_auth.go @@ -0,0 +1,160 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// Package-level Betfair session state. Managed by initBetfairAuth. +// betfairRequestMutator() reads from these; pinBetfair reads from these. +var ( + betfairMu sync.RWMutex + betfairToken string + betfairAppKey__ string +) + +// initBetfairAuth starts the background goroutine that keeps the Betfair +// session token alive. Call once at startup when credentials are present. +func initBetfairAuth(cfg Config) { + betfairMu.Lock() + betfairAppKey__ = cfg.BetfairAppKey + if cfg.BetfairSessionToken != "" { + betfairToken = cfg.BetfairSessionToken + } + betfairMu.Unlock() + + if cfg.BetfairSessionToken != "" { + // Pre-supplied token: keepalive only + go betfairKeepaliveLoop(cfg) + return + } + if cfg.BetfairUsername != "" && cfg.BetfairPassword != "" { + go betfairLoginLoop(cfg) + } +} + +func betfairLoginLoop(cfg Config) { + for { + token, err := betfairLogin(cfg.BetfairUsername, cfg.BetfairPassword, cfg.BetfairAppKey) + if err != nil { + log.Printf("[betfair-auth] login failed: %v (retry in 60s)", err) + time.Sleep(60 * time.Second) + continue + } + betfairMu.Lock() + betfairToken = token + betfairMu.Unlock() + log.Printf("[betfair-auth] session token refreshed") + time.Sleep(20 * time.Hour) // tokens valid 24h; refresh after 20h + } +} + +func betfairKeepaliveLoop(cfg Config) { + t := time.NewTicker(15 * time.Minute) + defer t.Stop() + for range t.C { + if err := betfairKeepalive(cfg.BetfairAppKey); err != nil { + log.Printf("[betfair-auth] keepalive failed: %v", err) + if cfg.BetfairUsername != "" && cfg.BetfairPassword != "" { + if token, err2 := betfairLogin(cfg.BetfairUsername, cfg.BetfairPassword, cfg.BetfairAppKey); err2 == nil { + betfairMu.Lock() + betfairToken = token + betfairMu.Unlock() + log.Printf("[betfair-auth] re-logged in after keepalive failure") + } + } + } + } +} + +func betfairLogin(username, password, appKey string) (string, error) { + data := url.Values{} + data.Set("username", username) + data.Set("password", password) + + req, err := http.NewRequest(http.MethodPost, + "https://identitysso.betfair.com/api/login", + strings.NewReader(data.Encode())) + if err != nil { + return "", err + } + req.Header.Set("X-Application", appKey) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result struct { + Token string `json:"token"` + Status string `json:"status"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("decode: %w", err) + } + if result.Status != "SUCCESS" { + return "", fmt.Errorf("status=%q error=%q", result.Status, result.Error) + } + return result.Token, nil +} + +func betfairKeepalive(appKey string) error { + betfairMu.RLock() + token := betfairToken + betfairMu.RUnlock() + + req, err := http.NewRequest(http.MethodGet, + "https://identitysso.betfair.com/api/keepAlive", nil) + if err != nil { + return err + } + req.Header.Set("X-Application", appKey) + req.Header.Set("X-Authentication", token) + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + var result struct { + Status string `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decode: %w", err) + } + if result.Status != "SUCCESS" { + return fmt.Errorf("keepalive status=%q", result.Status) + } + return nil +} + +// betfairRequestMutator returns a function that stamps the current Betfair +// AppKey and session token onto outgoing requests. +func betfairRequestMutator() func(*http.Request) { + return func(req *http.Request) { + betfairMu.RLock() + appKey := betfairAppKey__ + token := betfairToken + betfairMu.RUnlock() + if appKey != "" { + req.Header.Set("X-Application", appKey) + } + if token != "" { + req.Header.Set("X-Authentication", token) + } + } +} diff --git a/harnesses/pm-rate-limits/cmd/script/config.go b/harnesses/pm-rate-limits/cmd/script/config.go index 0a2893ef..fd679571 100644 --- a/harnesses/pm-rate-limits/cmd/script/config.go +++ b/harnesses/pm-rate-limits/cmd/script/config.go @@ -19,6 +19,10 @@ type Config struct { DefinedSessionCookie string // 7-day cookie used to mint Codex JWT KalshiAPIKeyID string // KALSHI-ACCESS-KEY header for WS auth KalshiPrivateKeyPEM string // RSA private key PEM for WS signature + BetfairAppKey string // BETFAIR_APP_KEY — developer application key + BetfairUsername string // BETFAIR_USERNAME — login credential for session token + BetfairPassword string // BETFAIR_PASSWORD — login credential for session token + BetfairSessionToken string // BETFAIR_SESSION_TOKEN — pre-obtained token (alternative to user/pass) } func loadConfig() Config { @@ -28,10 +32,15 @@ func loadConfig() Config { DefinedSessionCookie: strings.TrimSpace(os.Getenv("DEFINED_SESSION_COOKIE")), KalshiAPIKeyID: strings.TrimSpace(os.Getenv("KALSHI_API_KEY_ID")), KalshiPrivateKeyPEM: strings.TrimSpace(os.Getenv("KALSHI_PRIVATE_KEY_PEM")), + BetfairAppKey: strings.TrimSpace(os.Getenv("BETFAIR_APP_KEY")), + BetfairUsername: strings.TrimSpace(os.Getenv("BETFAIR_USERNAME")), + BetfairPassword: strings.TrimSpace(os.Getenv("BETFAIR_PASSWORD")), + BetfairSessionToken: strings.TrimSpace(os.Getenv("BETFAIR_SESSION_TOKEN")), } - fmt.Printf("[providers] mobula=%v predexon=%v codex=%v kalshi_ws=%v\n", + fmt.Printf("[providers] mobula=%v predexon=%v codex=%v kalshi_ws=%v betfair=%v\n", cfg.MobulaAPIKey != "", cfg.PredexonAPIKey != "", cfg.DefinedSessionCookie != "", - cfg.KalshiAPIKeyID != "" && cfg.KalshiPrivateKeyPEM != "") + cfg.KalshiAPIKeyID != "" && cfg.KalshiPrivateKeyPEM != "", + cfg.BetfairAppKey != "") return cfg } diff --git a/harnesses/pm-rate-limits/cmd/script/main.go b/harnesses/pm-rate-limits/cmd/script/main.go index e046a7b0..8ed7b5ee 100644 --- a/harnesses/pm-rate-limits/cmd/script/main.go +++ b/harnesses/pm-rate-limits/cmd/script/main.go @@ -32,8 +32,12 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + if cfg.BetfairAppKey != "" { + initBetfairAuth(cfg) + } + pinClient := &http.Client{Timeout: 20 * time.Second} - vs := venues() + vs := venues(cfg) stateByVenue := map[string]*venueState{} for _, v := range vs { v.state = newVenueState() diff --git a/harnesses/pm-rate-limits/cmd/script/pin.go b/harnesses/pm-rate-limits/cmd/script/pin.go index c317a497..c1998c31 100644 --- a/harnesses/pm-rate-limits/cmd/script/pin.go +++ b/harnesses/pm-rate-limits/cmd/script/pin.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "encoding/json" "errors" @@ -337,6 +338,94 @@ func pinSmarkets(ctx context.Context, c *http.Client, avoid string) (Pin, error) return Pin{}, errors.New("smarkets: no open politics market with live quotes found") } +func pinMetaculus(ctx context.Context, c *http.Client, avoid string) (Pin, error) { + var out struct { + Results []struct { + ID int `json:"id"` + CloseTime string `json:"close_time"` + Active bool `json:"active"` + } `json:"results"` + } + url := "https://www.metaculus.com/api2/questions/?limit=50&order_by=-activity&status=open&type=forecast&forecast_type=binary" + if err := fetchJSON(ctx, c, url, &out); err != nil { + return Pin{}, fmt.Errorf("metaculus list: %w", err) + } + for _, m := range out.Results { + if !m.Active { + continue + } + id := fmt.Sprintf("%d", m.ID) + if id == avoid { + continue + } + ct, err := time.Parse(time.RFC3339, m.CloseTime) + if err != nil { + continue + } + if time.Until(ct) < minPinHorizon { + continue + } + return Pin{Market: id, Expiry: ct}, nil + } + return Pin{}, errors.New("metaculus: no open binary question with >24h close horizon") +} + +func pinBetfair(ctx context.Context, c *http.Client, avoid string) (Pin, error) { + const catalogueURL = "https://api.betfair.com/exchange/betting/rest/v1.0/listMarketCatalogue/" + body := []byte(`{"filter":{"inPlayOnly":false},"sort":"FIRST_TO_START","maxResults":"200","marketProjection":["MARKET_START_TIME","RUNNER_DESCRIPTION"]}`) + + var markets []struct { + MarketID string `json:"marketId"` + MarketStartTime string `json:"marketStartTime"` + Runners []json.RawMessage `json:"runners"` + } + if err := postJSON(ctx, c, catalogueURL, body, &markets, betfairRequestMutator()); err != nil { + return Pin{}, fmt.Errorf("betfair catalogue: %w", err) + } + for _, m := range markets { + if m.MarketID == avoid || len(m.Runners) != 2 { + continue + } + // Betfair uses "2006-01-02T15:04:05.000Z" format + start, err := time.Parse("2006-01-02T15:04:05.000Z", m.MarketStartTime) + if err != nil { + start, err = time.Parse(time.RFC3339, m.MarketStartTime) + if err != nil { + continue + } + } + if time.Until(start) < 48*time.Hour { + continue + } + return Pin{Market: m.MarketID, Expiry: start}, nil + } + return Pin{}, errors.New("betfair: no binary market with >48h horizon in first 200 results") +} + +// postJSON sends a POST request with a JSON body and decodes the JSON response. +func postJSON(ctx context.Context, c *http.Client, url string, body []byte, out any, mutator func(*http.Request)) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + if mutator != nil { + mutator(req) + } + resp, err := c.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10)) + return fmt.Errorf("status %d: %s", resp.StatusCode, b) + } + return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out) +} + // stalenessPolymarket reads the ms timestamp Polymarket embeds in every book // response (string in the docs, tolerate a bare number). func stalenessPolymarket(class string, body []byte) (int64, bool) { diff --git a/harnesses/pm-rate-limits/cmd/script/probe.go b/harnesses/pm-rate-limits/cmd/script/probe.go index a02f7d14..03d4ca68 100644 --- a/harnesses/pm-rate-limits/cmd/script/probe.go +++ b/harnesses/pm-rate-limits/cmd/script/probe.go @@ -153,14 +153,33 @@ func probeOnce(ctx context.Context, client *http.Client, v *Venue, cl Class, con }, GotFirstResponseByte: func() { res.ttfb = time.Since(start).Seconds() }, } - url := cl.URL(v.pinOf(ctx)) - req, err := http.NewRequestWithContext(httptrace.WithClientTrace(pctx, trace), http.MethodGet, url, nil) + pin := v.pinOf(ctx) + url := cl.URL(pin) + + method := http.MethodGet + var bodyReader io.Reader + if strings.EqualFold(cl.Method, "POST") { + method = http.MethodPost + if cl.BodyFn != nil { + if b := cl.BodyFn(pin); b != nil { + bodyReader = bytes.NewReader(b) + } + } + } + + req, err := http.NewRequestWithContext(httptrace.WithClientTrace(pctx, trace), method, url, bodyReader) if err != nil { res.outcome = "net_error" return res } req.Header.Set("User-Agent", userAgent) req.Header.Set("Accept", "application/json") + if method == http.MethodPost { + req.Header.Set("Content-Type", "application/json") + } + if v.RequestMutator != nil { + v.RequestMutator(req) + } resp, err := client.Do(req) if err != nil { diff --git a/harnesses/pm-rate-limits/cmd/script/venues.go b/harnesses/pm-rate-limits/cmd/script/venues.go index 2583c5ca..0696061c 100644 --- a/harnesses/pm-rate-limits/cmd/script/venues.go +++ b/harnesses/pm-rate-limits/cmd/script/venues.go @@ -21,6 +21,8 @@ type Class struct { Interval time.Duration Timeout time.Duration URL func(p Pin) string + Method string // "" or "GET" → GET; "POST" for venues with POST-only APIs + BodyFn func(Pin) []byte // POST body generator; nil = no body } // Venue defines the probe matrix for one native prediction-market API. @@ -29,19 +31,20 @@ type Class struct { // from the daily ramp (Myriad: keyless 30 req/10s budget, ramping it would // just measure our own quota). type Venue struct { - Slug string - Classes []Class - PinFunc func(ctx context.Context, c *http.Client, avoid string) (Pin, error) - StalenessMs func(class string, body []byte) (int64, bool) - RampRates []int - StopOn429 bool // Kalshi: documented token bucket, stop at first 429 - InvalidBody []string // 4xx body substrings that mark a probe_invalid (stale pin) + Slug string + Classes []Class + PinFunc func(ctx context.Context, c *http.Client, avoid string) (Pin, error) + StalenessMs func(class string, body []byte) (int64, bool) + RampRates []int + StopOn429 bool // Kalshi: documented token bucket, stop at first 429 + InvalidBody []string // body substrings that mark a probe_invalid (stale pin) + RequestMutator func(*http.Request) // optional: stamps auth/custom headers before send state *venueState // wired at startup } -func venues() []*Venue { - return []*Venue{ +func venues(cfg Config) []*Venue { + vs := []*Venue{ { Slug: "polymarket", Classes: []Class{ @@ -173,7 +176,76 @@ func venues() []*Venue { PinFunc: pinSmarkets, RampRates: []int{10, 25, 50}, }, + // Metaculus: public forecasting platform, no auth, no order book. + // Primary class is price (single question endpoint). Conservative ramp + // because limits are undocumented. + { + Slug: "metaculus", + Classes: []Class{ + {Name: "price", Interval: 5 * time.Second, Timeout: 8 * time.Second, + URL: func(p Pin) string { + return "https://www.metaculus.com/api2/questions/" + p.Market + "/" + }}, + {Name: "list", Interval: 30 * time.Second, Timeout: 15 * time.Second, + URL: func(Pin) string { + return "https://www.metaculus.com/api2/questions/?limit=20&order_by=-activity&status=open" + }}, + }, + PinFunc: pinMetaculus, + RampRates: []int{5, 10, 20}, + }, + } + // Betfair: UK-regulated exchange, auth required. All Exchange API calls are + // POST with JSON body, so we use Class.Method="POST" + Class.BodyFn. + // Only wired when BETFAIR_APP_KEY is set. + if cfg.BetfairAppKey != "" && (cfg.BetfairUsername != "" || cfg.BetfairSessionToken != "") { + vs = append(vs, &Venue{ + Slug: "betfair", + Classes: []Class{ + { + Name: "book", Method: "POST", + Interval: 5 * time.Second, Timeout: 8 * time.Second, + URL: func(Pin) string { + return "https://api.betfair.com/exchange/betting/rest/v1.0/listMarketBook/" + }, + BodyFn: func(p Pin) []byte { + if p.Market == "" { + return nil + } + return []byte(`{"marketIds":["` + p.Market + `"],"priceProjection":{"priceData":["EX_BEST_OFFERS"]}}`) + }, + }, + { + Name: "price", Method: "POST", + Interval: 5 * time.Second, Timeout: 8 * time.Second, + URL: func(Pin) string { + return "https://api.betfair.com/exchange/betting/rest/v1.0/listMarketBook/" + }, + BodyFn: func(p Pin) []byte { + if p.Market == "" { + return nil + } + return []byte(`{"marketIds":["` + p.Market + `"]}`) + }, + }, + { + Name: "list", Method: "POST", + Interval: 30 * time.Second, Timeout: 15 * time.Second, + URL: func(Pin) string { + return "https://api.betfair.com/exchange/betting/rest/v1.0/listMarketCatalogue/" + }, + BodyFn: func(Pin) []byte { + return []byte(`{"filter":{"inPlayOnly":false},"sort":"FIRST_TO_START","maxResults":"20","marketProjection":["MARKET_START_TIME"]}`) + }, + }, + }, + PinFunc: pinBetfair, + RequestMutator: betfairRequestMutator(), + RampRates: []int{10, 25, 50}, + StopOn429: true, + }) } + return vs } // ProbeSource is one (venue, source) cell the aggregator harness probes diff --git a/src/components/data-api-providers-pivot.tsx b/src/components/data-api-providers-pivot.tsx index 04b55443..f5c34f63 100644 --- a/src/components/data-api-providers-pivot.tsx +++ b/src/components/data-api-providers-pivot.tsx @@ -9,7 +9,6 @@ import { fmtDataValue, type DataApiProviderPivotRow, type DataApiRegionScore, - type DataApiChainScore, type DataApiGroup, } from "@/lib/data-api-stats"; @@ -218,8 +217,7 @@ function GroupCell({ const { bestRank, bestCell } = cell; const accent = GROUP_META[group].accent; const hasRegions = bestCell.regions && bestCell.regions.length > 0; - const hasChains = bestCell.chains && bestCell.chains.length > 0; - const hasSub = hasRegions || hasChains; + const hasSub = hasRegions; const bg = bestRank === 1 @@ -253,19 +251,14 @@ function GroupCell({ {bestCell.benchShortTitle.replace("coverage", "cov.").replace("freshness", "fresh.")} - {/* Region + chain sub-scores merged in one row */} + {/* Region sub-scores */} {hasSub && (
- {hasRegions && ( - - )} - {hasChains && ( - - )} +
)} @@ -307,44 +300,6 @@ function RegionSubScores({ ); } -function ChainSubScores({ - chains, - accent, -}: { - chains: DataApiChainScore[]; - accent: string; -}) { - const MAX = 4; - const visible = chains.slice(0, MAX); - const overflow = chains.length - MAX; - return ( - <> - {visible.map((c) => ( -
- - {c.label} - - - #1 - -
- ))} - {overflow > 0 && ( - +{overflow} - )} - - ); -} function RankBadge({ rank, accent }: { rank: number; accent: string }) { const bg = rank === 1 ? accent : "transparent"; diff --git a/src/lib/pm-venue-context.ts b/src/lib/pm-venue-context.ts index fe7dda84..ed170239 100644 --- a/src/lib/pm-venue-context.ts +++ b/src/lib/pm-venue-context.ts @@ -25,6 +25,8 @@ export const PM_VENUE_META: Record limitless: { url: "https://limitless.exchange", chainLabel: "Base" }, myriad: { url: "https://myriad.markets", chainLabel: "Abstract L2" }, manifold: { url: "https://manifold.markets", chainLabel: "Offchain" }, + metaculus: { url: "https://www.metaculus.com", chainLabel: "Offchain" }, + betfair: { url: "https://www.betfair.com", chainLabel: "Offchain UK" }, }; export const PM_FEED_META: Record = {