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>
161 lines
4.2 KiB
Go
161 lines
4.2 KiB
Go
package kalshi
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Market represents a single prediction market on Kalshi.
|
|
type Market struct {
|
|
Ticker string
|
|
EventTicker string
|
|
Title string
|
|
Subtitle string
|
|
Status string
|
|
YesBid float64 // 0-1 (probability)
|
|
YesAsk float64
|
|
NoBid float64
|
|
NoAsk float64
|
|
LastPrice float64
|
|
Volume int
|
|
Volume24h int
|
|
OpenInterest int
|
|
Result string // "yes", "no", or "" if unresolved
|
|
Rules string
|
|
CloseTime time.Time
|
|
}
|
|
|
|
// URL returns the Kalshi web URL for this market.
|
|
func (m Market) URL() string {
|
|
if m.Ticker == "" {
|
|
return ""
|
|
}
|
|
return "https://kalshi.com/markets/" + m.Ticker
|
|
}
|
|
|
|
// Event represents a prediction market event on Kalshi, which may contain
|
|
// one or more individual markets.
|
|
type Event struct {
|
|
EventTicker string
|
|
SeriesTicker string
|
|
Title string
|
|
Subtitle string
|
|
Category string
|
|
Markets []Market
|
|
}
|
|
|
|
// URL returns the Kalshi web URL for this event.
|
|
func (e Event) URL() string {
|
|
if e.EventTicker == "" {
|
|
return ""
|
|
}
|
|
return "https://kalshi.com/markets/" + e.EventTicker
|
|
}
|
|
|
|
// Config holds configuration for the Kalshi extractor.
|
|
type Config struct {
|
|
// BaseURL overrides the API base URL. Leave empty for the default.
|
|
BaseURL string
|
|
|
|
// HTTPClient overrides the HTTP client used for API requests. Leave nil for default.
|
|
HTTPClient *http.Client
|
|
|
|
// MaxSearchResults limits the number of results returned by Search.
|
|
// Defaults to 20 if zero.
|
|
MaxSearchResults int
|
|
}
|
|
|
|
// DefaultConfig is the default configuration.
|
|
var DefaultConfig = Config{}
|
|
|
|
func (c Config) validate() Config {
|
|
if c.MaxSearchResults <= 0 {
|
|
c.MaxSearchResults = 20
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Search finds events matching the given query string by fetching open events
|
|
// and filtering by title. Kalshi does not provide a text search API, so this
|
|
// performs client-side substring matching.
|
|
func (c Config) Search(ctx context.Context, query string) ([]Event, error) {
|
|
c = c.validate()
|
|
return c.searchEvents(ctx, query, c.MaxSearchResults)
|
|
}
|
|
|
|
// GetMarket retrieves a single market by its ticker.
|
|
func (c Config) GetMarket(ctx context.Context, ticker string) (*Market, error) {
|
|
c = c.validate()
|
|
return c.getMarketByTicker(ctx, strings.ToUpper(ticker))
|
|
}
|
|
|
|
// GetEvent retrieves an event by its ticker, including all nested markets.
|
|
func (c Config) GetEvent(ctx context.Context, eventTicker string) (*Event, error) {
|
|
c = c.validate()
|
|
return c.getEventByTicker(ctx, strings.ToUpper(eventTicker))
|
|
}
|
|
|
|
// GetMarketFromURL extracts the market ticker from a Kalshi URL and retrieves
|
|
// the market details.
|
|
func (c Config) GetMarketFromURL(ctx context.Context, rawURL string) (*Market, error) {
|
|
c = c.validate()
|
|
|
|
ticker, err := parseKalshiURL(rawURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return c.getMarketByTicker(ctx, ticker)
|
|
}
|
|
|
|
// GetEventFromURL extracts the event ticker from a Kalshi URL and retrieves
|
|
// the event details.
|
|
func (c Config) GetEventFromURL(ctx context.Context, rawURL string) (*Event, error) {
|
|
c = c.validate()
|
|
|
|
ticker, err := parseKalshiURL(rawURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The ticker from the URL could be either a market ticker or event ticker.
|
|
// Try as event first, then fall back to getting the market and looking up its event.
|
|
ev, err := c.getEventByTicker(ctx, ticker)
|
|
if err == nil {
|
|
return ev, nil
|
|
}
|
|
|
|
// Try as market ticker - get the market, then get its event
|
|
m, mErr := c.getMarketByTicker(ctx, ticker)
|
|
if mErr != nil {
|
|
return nil, fmt.Errorf("getting event from URL: %w", err)
|
|
}
|
|
|
|
return c.getEventByTicker(ctx, m.EventTicker)
|
|
}
|
|
|
|
// Convenience functions using DefaultConfig.
|
|
|
|
func Search(ctx context.Context, query string) ([]Event, error) {
|
|
return DefaultConfig.Search(ctx, query)
|
|
}
|
|
|
|
func GetMarket(ctx context.Context, ticker string) (*Market, error) {
|
|
return DefaultConfig.GetMarket(ctx, ticker)
|
|
}
|
|
|
|
func GetEvent(ctx context.Context, eventTicker string) (*Event, error) {
|
|
return DefaultConfig.GetEvent(ctx, eventTicker)
|
|
}
|
|
|
|
func GetMarketFromURL(ctx context.Context, rawURL string) (*Market, error) {
|
|
return DefaultConfig.GetMarketFromURL(ctx, rawURL)
|
|
}
|
|
|
|
func GetEventFromURL(ctx context.Context, rawURL string) (*Event, error) {
|
|
return DefaultConfig.GetEventFromURL(ctx, rawURL)
|
|
}
|