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>
261 lines
6.8 KiB
Go
261 lines
6.8 KiB
Go
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
|
|
}
|