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/ 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 }