feat: add Polymarket and Kalshi prediction market extractors
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:
260
sites/kalshi/api.go
Normal file
260
sites/kalshi/api.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user