feat: add Polymarket and Kalshi prediction market extractors
All checks were successful
CI / vet (pull_request) Successful in 2m15s
CI / build (pull_request) Successful in 2m17s
CI / test (pull_request) Successful in 2m27s

Add site extractors for two prediction market platforms using their
public REST APIs (no browser/Playwright dependency needed).

Polymarket: search via Gamma API, get events/markets by slug/ID/URL.
Handles inconsistent JSON encoding (stringified arrays).

Kalshi: get markets/events by ticker/URL, search via client-side
filtering of open events (no text search API available).

Both include CLI tools and comprehensive test suites using httptest.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 00:52:40 +00:00
parent 8c2848246b
commit 29e53bb7c2
8 changed files with 1910 additions and 0 deletions

260
sites/kalshi/api.go Normal file
View File

@@ -0,0 +1,260 @@
package kalshi
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
defaultBaseURL = "https://api.elections.kalshi.com/trade-api/v2"
)
// apiMarket is the JSON shape returned by the Kalshi API for a market.
type apiMarket struct {
Ticker string `json:"ticker"`
EventTicker string `json:"event_ticker"`
MarketType string `json:"market_type"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Status string `json:"status"`
YesBid int `json:"yes_bid"`
YesAsk int `json:"yes_ask"`
NoBid int `json:"no_bid"`
NoAsk int `json:"no_ask"`
LastPrice int `json:"last_price"`
Volume int `json:"volume"`
Volume24h int `json:"volume_24h"`
OpenInterest int `json:"open_interest"`
Result string `json:"result"`
RulesPrimary string `json:"rules_primary"`
OpenTime string `json:"open_time"`
CloseTime string `json:"close_time"`
YesSubTitle string `json:"yes_sub_title"`
NoSubTitle string `json:"no_sub_title"`
CanCloseEarly bool `json:"can_close_early"`
}
// apiEvent is the JSON shape returned by the Kalshi API for an event.
type apiEvent struct {
EventTicker string `json:"event_ticker"`
SeriesTicker string `json:"series_ticker"`
Title string `json:"title"`
Subtitle string `json:"sub_title"`
Category string `json:"category"`
MutuallyExclusive bool `json:"mutually_exclusive"`
Markets []apiMarket `json:"markets"`
}
// apiEventsResponse wraps the events list endpoint response.
type apiEventsResponse struct {
Events []apiEvent `json:"events"`
Cursor string `json:"cursor"`
}
// apiMarketResponse wraps the single market endpoint response.
type apiMarketResponse struct {
Market apiMarket `json:"market"`
}
// apiEventResponse wraps the single event endpoint response.
type apiEventResponse struct {
Event apiEvent `json:"event"`
}
func apiMarketToMarket(m apiMarket) Market {
var closeTime time.Time
if m.CloseTime != "" {
closeTime, _ = time.Parse(time.RFC3339, m.CloseTime)
}
return Market{
Ticker: m.Ticker,
EventTicker: m.EventTicker,
Title: m.Title,
Subtitle: m.Subtitle,
Status: m.Status,
YesBid: float64(m.YesBid) / 100,
YesAsk: float64(m.YesAsk) / 100,
NoBid: float64(m.NoBid) / 100,
NoAsk: float64(m.NoAsk) / 100,
LastPrice: float64(m.LastPrice) / 100,
Volume: m.Volume,
Volume24h: m.Volume24h,
OpenInterest: m.OpenInterest,
Result: m.Result,
Rules: m.RulesPrimary,
CloseTime: closeTime,
}
}
func apiEventToEvent(e apiEvent) Event {
markets := make([]Market, 0, len(e.Markets))
for _, m := range e.Markets {
markets = append(markets, apiMarketToMarket(m))
}
return Event{
EventTicker: e.EventTicker,
SeriesTicker: e.SeriesTicker,
Title: e.Title,
Subtitle: e.Subtitle,
Category: e.Category,
Markets: markets,
}
}
func (c Config) doGet(ctx context.Context, endpoint string) ([]byte, error) {
base := c.BaseURL
if base == "" {
base = defaultBaseURL
}
reqURL := base + endpoint
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Accept", "application/json")
client := c.HTTPClient
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
return body, nil
}
func (c Config) getMarketByTicker(ctx context.Context, ticker string) (*Market, error) {
body, err := c.doGet(ctx, "/markets/"+url.PathEscape(ticker))
if err != nil {
return nil, fmt.Errorf("getting market: %w", err)
}
var resp apiMarketResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("parsing market: %w", err)
}
m := apiMarketToMarket(resp.Market)
return &m, nil
}
func (c Config) getEventByTicker(ctx context.Context, ticker string) (*Event, error) {
body, err := c.doGet(ctx, "/events/"+url.PathEscape(ticker)+"?with_nested_markets=true")
if err != nil {
return nil, fmt.Errorf("getting event: %w", err)
}
var resp apiEventResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("parsing event: %w", err)
}
ev := apiEventToEvent(resp.Event)
return &ev, nil
}
// searchEvents fetches open events and filters by query substring match on title.
// Kalshi has no text search API, so we paginate through events client-side.
func (c Config) searchEvents(ctx context.Context, query string, maxResults int) ([]Event, error) {
if maxResults <= 0 {
maxResults = 20
}
queryLower := strings.ToLower(query)
queryWords := strings.Fields(queryLower)
var results []Event
cursor := ""
for {
endpoint := "/events?status=open&limit=200&with_nested_markets=true"
if cursor != "" {
endpoint += "&cursor=" + url.QueryEscape(cursor)
}
body, err := c.doGet(ctx, endpoint)
if err != nil {
return results, fmt.Errorf("listing events: %w", err)
}
var resp apiEventsResponse
if err := json.Unmarshal(body, &resp); err != nil {
return results, fmt.Errorf("parsing events: %w", err)
}
for _, e := range resp.Events {
if matchesQuery(e.Title, queryWords) {
results = append(results, apiEventToEvent(e))
if len(results) >= maxResults {
return results, nil
}
}
}
if resp.Cursor == "" || len(resp.Events) == 0 {
break
}
cursor = resp.Cursor
}
return results, nil
}
// matchesQuery checks if all query words appear in the text (case-insensitive).
func matchesQuery(text string, queryWords []string) bool {
textLower := strings.ToLower(text)
for _, word := range queryWords {
if !strings.Contains(textLower, word) {
return false
}
}
return true
}
// parseKalshiURL extracts the market ticker from a Kalshi URL.
// Supported formats:
// - https://kalshi.com/markets/<TICKER>
func parseKalshiURL(rawURL string) (ticker string, err error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", fmt.Errorf("parsing URL: %w", err)
}
host := strings.TrimPrefix(u.Host, "www.")
if host != "kalshi.com" {
return "", fmt.Errorf("not a Kalshi URL: %s", rawURL)
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) < 2 {
return "", fmt.Errorf("invalid Kalshi URL path: %s", u.Path)
}
if parts[0] != "markets" {
return "", fmt.Errorf("unsupported Kalshi URL type: %s", parts[0])
}
return parts[1], nil
}