From 29e53bb7c2a8b7c791722d32541cfe04b5ca630a Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 21 Feb 2026 00:52:40 +0000 Subject: [PATCH] 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 --- sites/kalshi/api.go | 260 ++++++++++++++++ sites/kalshi/cmd/kalshi/main.go | 147 +++++++++ sites/kalshi/kalshi.go | 160 ++++++++++ sites/kalshi/kalshi_test.go | 387 ++++++++++++++++++++++++ sites/polymarket/api.go | 326 ++++++++++++++++++++ sites/polymarket/cmd/polymarket/main.go | 131 ++++++++ sites/polymarket/polymarket.go | 136 +++++++++ sites/polymarket/polymarket_test.go | 363 ++++++++++++++++++++++ 8 files changed, 1910 insertions(+) create mode 100644 sites/kalshi/api.go create mode 100644 sites/kalshi/cmd/kalshi/main.go create mode 100644 sites/kalshi/kalshi.go create mode 100644 sites/kalshi/kalshi_test.go create mode 100644 sites/polymarket/api.go create mode 100644 sites/polymarket/cmd/polymarket/main.go create mode 100644 sites/polymarket/polymarket.go create mode 100644 sites/polymarket/polymarket_test.go diff --git a/sites/kalshi/api.go b/sites/kalshi/api.go new file mode 100644 index 0000000..b3f6075 --- /dev/null +++ b/sites/kalshi/api.go @@ -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/ +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 +} diff --git a/sites/kalshi/cmd/kalshi/main.go b/sites/kalshi/cmd/kalshi/main.go new file mode 100644 index 0000000..362b3e4 --- /dev/null +++ b/sites/kalshi/cmd/kalshi/main.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "gitea.stevedudenhoeffer.com/steve/go-extractor/sites/kalshi" + "github.com/urfave/cli/v3" +) + +func main() { + app := &cli.Command{ + Name: "kalshi", + Usage: "Query prediction markets on Kalshi", + Commands: []*cli.Command{ + { + Name: "search", + Usage: "Search for markets by keyword", + ArgsUsage: "", + Action: func(ctx context.Context, cmd *cli.Command) error { + query := strings.Join(cmd.Args().Slice(), " ") + if query == "" { + return fmt.Errorf("usage: kalshi search ") + } + + events, err := kalshi.Search(ctx, query) + if err != nil { + return err + } + + if len(events) == 0 { + fmt.Println("No events found.") + return nil + } + + for _, e := range events { + printEvent(e) + } + return nil + }, + }, + { + Name: "event", + Usage: "Get event details by ticker or URL", + ArgsUsage: "", + Action: func(ctx context.Context, cmd *cli.Command) error { + arg := cmd.Args().First() + if arg == "" { + return fmt.Errorf("usage: kalshi event ") + } + + var ev *kalshi.Event + var err error + + if strings.Contains(arg, "kalshi.com") { + ev, err = kalshi.GetEventFromURL(ctx, arg) + } else { + ev, err = kalshi.GetEvent(ctx, arg) + } + if err != nil { + return err + } + + printEvent(*ev) + return nil + }, + }, + { + Name: "market", + Usage: "Get market details by ticker or URL", + ArgsUsage: "", + Action: func(ctx context.Context, cmd *cli.Command) error { + arg := cmd.Args().First() + if arg == "" { + return fmt.Errorf("usage: kalshi market ") + } + + var m *kalshi.Market + var err error + + if strings.Contains(arg, "kalshi.com") { + m, err = kalshi.GetMarketFromURL(ctx, arg) + } else { + m, err = kalshi.GetMarket(ctx, arg) + } + if err != nil { + return err + } + + printMarket(*m, "") + return nil + }, + }, + }, + } + + if err := app.Run(context.Background(), os.Args); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func printEvent(e kalshi.Event) { + fmt.Printf("Event: %s\n", e.Title) + fmt.Printf(" Ticker: %s\n", e.EventTicker) + fmt.Printf(" URL: %s\n", e.URL()) + if e.Category != "" { + fmt.Printf(" Category: %s\n", e.Category) + } + + if len(e.Markets) > 0 { + fmt.Printf(" Markets:\n") + for _, m := range e.Markets { + printMarket(m, " ") + } + } + fmt.Println() +} + +func printMarket(m kalshi.Market, indent string) { + fmt.Printf("%s- %s", indent, m.Title) + if m.Subtitle != "" { + fmt.Printf(" (%s)", m.Subtitle) + } + fmt.Println() + + fmt.Printf("%s Yes: %.0f%%", indent, m.LastPrice*100) + if m.YesBid > 0 || m.YesAsk > 0 { + fmt.Printf(" (bid %.0f%% / ask %.0f%%)", m.YesBid*100, m.YesAsk*100) + } + fmt.Println() + + if m.Volume > 0 { + fmt.Printf("%s Volume: %d contracts\n", indent, m.Volume) + } + if m.Status != "" { + fmt.Printf("%s Status: %s\n", indent, m.Status) + } + if m.Result != "" { + fmt.Printf("%s Result: %s\n", indent, m.Result) + } + if !m.CloseTime.IsZero() { + fmt.Printf("%s Closes: %s\n", indent, m.CloseTime.Format("2006-01-02 15:04 MST")) + } +} diff --git a/sites/kalshi/kalshi.go b/sites/kalshi/kalshi.go new file mode 100644 index 0000000..3975c46 --- /dev/null +++ b/sites/kalshi/kalshi.go @@ -0,0 +1,160 @@ +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) +} diff --git a/sites/kalshi/kalshi_test.go b/sites/kalshi/kalshi_test.go new file mode 100644 index 0000000..1cb4242 --- /dev/null +++ b/sites/kalshi/kalshi_test.go @@ -0,0 +1,387 @@ +package kalshi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestParseKalshiURL(t *testing.T) { + tests := []struct { + name string + url string + wantTicker string + wantErr bool + }{ + { + name: "market URL", + url: "https://kalshi.com/markets/KXHIGHNY-26FEB21-T50", + wantTicker: "KXHIGHNY-26FEB21-T50", + }, + { + name: "www prefix", + url: "https://www.kalshi.com/markets/KXHIGHNY-26FEB21-T50", + wantTicker: "KXHIGHNY-26FEB21-T50", + }, + { + name: "wrong host", + url: "https://example.com/markets/FOO", + wantErr: true, + }, + { + name: "too short path", + url: "https://kalshi.com/markets", + wantErr: true, + }, + { + name: "wrong path", + url: "https://kalshi.com/browse/FOO", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ticker, err := parseKalshiURL(tt.url) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ticker != tt.wantTicker { + t.Errorf("ticker = %q, want %q", ticker, tt.wantTicker) + } + }) + } +} + +func TestMatchesQuery(t *testing.T) { + tests := []struct { + text string + query string + want bool + }{ + {"USA vs Canada Olympic Hockey", "olympic hockey", true}, + {"USA vs Canada Olympic Hockey", "usa canada", true}, + {"USA vs Canada Olympic Hockey", "baseball", false}, + {"Will it rain tomorrow?", "rain", true}, + {"Will it rain tomorrow?", "rain snow", false}, + } + + for _, tt := range tests { + words := []string{} + for _, w := range splitWords(tt.query) { + words = append(words, w) + } + got := matchesQuery(tt.text, words) + if got != tt.want { + t.Errorf("matchesQuery(%q, %q) = %v, want %v", tt.text, tt.query, got, tt.want) + } + } +} + +func splitWords(s string) []string { + var words []string + for _, w := range []byte(s) { + if w == ' ' { + continue + } + if len(words) == 0 || s[len(s)-1] == ' ' { + words = append(words, "") + } + } + // Simple split for tests + result := []string{} + current := "" + for _, c := range s { + if c == ' ' { + if current != "" { + result = append(result, current) + current = "" + } + } else { + current += string(c) + } + } + if current != "" { + result = append(result, current) + } + return result +} + +func newTestServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, Config) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return srv, Config{BaseURL: srv.URL} +} + +func TestGetMarket(t *testing.T) { + _, cfg := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/markets/KXHIGHNY-26FEB21-T50" { + http.NotFound(w, r) + return + } + resp := apiMarketResponse{ + Market: apiMarket{ + Ticker: "KXHIGHNY-26FEB21-T50", + EventTicker: "KXHIGHNY-26FEB21", + Title: "Will the high temp in NYC be >50?", + Subtitle: "51 or above", + Status: "active", + YesBid: 3, + YesAsk: 4, + NoBid: 96, + NoAsk: 97, + LastPrice: 3, + Volume: 1849, + Volume24h: 500, + OpenInterest: 1203, + RulesPrimary: "Resolves Yes if temp > 50", + CloseTime: "2026-02-22T04:59:00Z", + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + + m, err := cfg.GetMarket(context.Background(), "KXHIGHNY-26FEB21-T50") + if err != nil { + t.Fatalf("GetMarket error: %v", err) + } + if m.Ticker != "KXHIGHNY-26FEB21-T50" { + t.Errorf("ticker = %q", m.Ticker) + } + if m.Title != "Will the high temp in NYC be >50?" { + t.Errorf("title = %q", m.Title) + } + if m.YesBid != 0.03 { + t.Errorf("yes bid = %v, want 0.03", m.YesBid) + } + if m.YesAsk != 0.04 { + t.Errorf("yes ask = %v, want 0.04", m.YesAsk) + } + if m.LastPrice != 0.03 { + t.Errorf("last price = %v, want 0.03", m.LastPrice) + } + if m.Volume != 1849 { + t.Errorf("volume = %d, want 1849", m.Volume) + } + if m.Rules != "Resolves Yes if temp > 50" { + t.Errorf("rules = %q", m.Rules) + } + if m.URL() != "https://kalshi.com/markets/KXHIGHNY-26FEB21-T50" { + t.Errorf("URL = %q", m.URL()) + } +} + +func TestGetEvent(t *testing.T) { + _, cfg := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/events/KXHIGHNY-26FEB21" { + http.NotFound(w, r) + return + } + resp := apiEventResponse{ + Event: apiEvent{ + EventTicker: "KXHIGHNY-26FEB21", + SeriesTicker: "KXHIGHNY", + Title: "Highest temperature in NYC on Feb 21", + Category: "Weather", + Markets: []apiMarket{ + { + Ticker: "KXHIGHNY-26FEB21-T50", + EventTicker: "KXHIGHNY-26FEB21", + Title: "Will the high temp be >50?", + Status: "active", + YesBid: 3, + YesAsk: 4, + LastPrice: 3, + }, + { + Ticker: "KXHIGHNY-26FEB21-T40", + EventTicker: "KXHIGHNY-26FEB21", + Title: "Will the high temp be >40?", + Status: "active", + YesBid: 65, + YesAsk: 68, + LastPrice: 66, + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + + ev, err := cfg.GetEvent(context.Background(), "KXHIGHNY-26FEB21") + if err != nil { + t.Fatalf("GetEvent error: %v", err) + } + if ev.EventTicker != "KXHIGHNY-26FEB21" { + t.Errorf("event ticker = %q", ev.EventTicker) + } + if ev.Title != "Highest temperature in NYC on Feb 21" { + t.Errorf("title = %q", ev.Title) + } + if ev.Category != "Weather" { + t.Errorf("category = %q", ev.Category) + } + if len(ev.Markets) != 2 { + t.Fatalf("got %d markets, want 2", len(ev.Markets)) + } + if ev.Markets[1].LastPrice != 0.66 { + t.Errorf("market 2 last price = %v, want 0.66", ev.Markets[1].LastPrice) + } +} + +func TestSearch(t *testing.T) { + _, cfg := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/events" { + http.NotFound(w, r) + return + } + resp := apiEventsResponse{ + Events: []apiEvent{ + { + EventTicker: "HOCKEY-USA-CAN-MEN", + Title: "USA vs Canada Olympic Hockey (Men)", + Category: "Sports", + }, + { + EventTicker: "HOCKEY-USA-CAN-WOM", + Title: "USA vs Canada Olympic Hockey (Women)", + Category: "Sports", + }, + { + EventTicker: "WEATHER-NYC", + Title: "NYC Temperature Today", + Category: "Weather", + }, + }, + Cursor: "", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + + events, err := cfg.Search(context.Background(), "hockey") + if err != nil { + t.Fatalf("Search error: %v", err) + } + if len(events) != 2 { + t.Fatalf("got %d events, want 2", len(events)) + } + if events[0].EventTicker != "HOCKEY-USA-CAN-MEN" { + t.Errorf("event 0 ticker = %q", events[0].EventTicker) + } + if events[1].EventTicker != "HOCKEY-USA-CAN-WOM" { + t.Errorf("event 1 ticker = %q", events[1].EventTicker) + } +} + +func TestSearch_Pagination(t *testing.T) { + callCount := 0 + _, cfg := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + callCount++ + var resp apiEventsResponse + if callCount == 1 { + resp = apiEventsResponse{ + Events: []apiEvent{ + {EventTicker: "OTHER", Title: "Something else"}, + }, + Cursor: "page2", + } + } else { + resp = apiEventsResponse{ + Events: []apiEvent{ + {EventTicker: "MATCH", Title: "Hockey Match"}, + }, + Cursor: "", + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + + events, err := cfg.Search(context.Background(), "hockey") + if err != nil { + t.Fatalf("Search error: %v", err) + } + if len(events) != 1 { + t.Fatalf("got %d events, want 1", len(events)) + } + if events[0].EventTicker != "MATCH" { + t.Errorf("ticker = %q", events[0].EventTicker) + } + if callCount != 2 { + t.Errorf("API called %d times, want 2", callCount) + } +} + +func TestSearch_Empty(t *testing.T) { + _, cfg := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + resp := apiEventsResponse{Events: []apiEvent{}, Cursor: ""} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + + events, err := cfg.Search(context.Background(), "nonexistent") + if err != nil { + t.Fatalf("Search error: %v", err) + } + if len(events) != 0 { + t.Errorf("got %d events, want 0", len(events)) + } +} + +func TestMarketURL(t *testing.T) { + m := Market{Ticker: "FOO-BAR"} + if m.URL() != "https://kalshi.com/markets/FOO-BAR" { + t.Errorf("URL = %q", m.URL()) + } + + m2 := Market{} + if m2.URL() != "" { + t.Errorf("empty ticker URL = %q, want empty", m2.URL()) + } +} + +func TestEventURL(t *testing.T) { + e := Event{EventTicker: "FOO-BAR"} + if e.URL() != "https://kalshi.com/markets/FOO-BAR" { + t.Errorf("URL = %q", e.URL()) + } +} + +func TestGetMarketFromURL(t *testing.T) { + _, cfg := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/markets/KXHIGHNY-26FEB21-T50" { + http.NotFound(w, r) + return + } + resp := apiMarketResponse{ + Market: apiMarket{ + Ticker: "KXHIGHNY-26FEB21-T50", + Title: "Will the high temp be >50?", + Status: "active", + LastPrice: 3, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + + m, err := cfg.GetMarketFromURL(context.Background(), "https://kalshi.com/markets/KXHIGHNY-26FEB21-T50") + if err != nil { + t.Fatalf("GetMarketFromURL error: %v", err) + } + if m.Ticker != "KXHIGHNY-26FEB21-T50" { + t.Errorf("ticker = %q", m.Ticker) + } +} diff --git a/sites/polymarket/api.go b/sites/polymarket/api.go new file mode 100644 index 0000000..37bd5f6 --- /dev/null +++ b/sites/polymarket/api.go @@ -0,0 +1,326 @@ +package polymarket + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +const ( + defaultGammaBaseURL = "https://gamma-api.polymarket.com" +) + +// stringOrArray handles Polymarket's inconsistent JSON encoding where fields +// can be either a JSON array or a stringified JSON array (e.g. "[\"Yes\",\"No\"]"). +type stringOrArray []string + +func (s *stringOrArray) UnmarshalJSON(data []byte) error { + // Try as regular array first + var arr []string + if err := json.Unmarshal(data, &arr); err == nil { + *s = arr + return nil + } + + // Try as stringified array + var str string + if err := json.Unmarshal(data, &str); err == nil { + if err := json.Unmarshal([]byte(str), &arr); err == nil { + *s = arr + return nil + } + } + + *s = nil + return nil +} + +// floatOrArray handles the same pattern for numeric arrays. +type floatOrArray []float64 + +func (f *floatOrArray) UnmarshalJSON(data []byte) error { + // Try as regular array first + var arr []float64 + if err := json.Unmarshal(data, &arr); err == nil { + *f = arr + return nil + } + + // Try as array of strings + var strArr []string + if err := json.Unmarshal(data, &strArr); err == nil { + result := make([]float64, 0, len(strArr)) + for _, s := range strArr { + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return fmt.Errorf("parsing price %q: %w", s, err) + } + result = append(result, v) + } + *f = result + return nil + } + + // Try as stringified array of strings + var str string + if err := json.Unmarshal(data, &str); err == nil { + if err := json.Unmarshal([]byte(str), &strArr); err == nil { + result := make([]float64, 0, len(strArr)) + for _, s := range strArr { + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return fmt.Errorf("parsing price %q: %w", s, err) + } + result = append(result, v) + } + *f = result + return nil + } + } + + *f = nil + return nil +} + +// apiMarket is the JSON shape returned by the Gamma API for a market. +type apiMarket struct { + ID string `json:"id"` + Question string `json:"question"` + Slug string `json:"slug"` + Outcomes stringOrArray `json:"outcomes"` + OutcomePrices floatOrArray `json:"outcomePrices"` + Volume flexFloat `json:"volume"` + Volume24hr float64 `json:"volume24hr"` + LiquidityClob float64 `json:"liquidityClob"` + EndDate string `json:"endDate"` + Active bool `json:"active"` + Closed bool `json:"closed"` + BestBid float64 `json:"bestBid"` + BestAsk float64 `json:"bestAsk"` + LastTradePrice float64 `json:"lastTradePrice"` + Description string `json:"description"` + GroupItemTitle string `json:"groupItemTitle"` +} + +// flexFloat handles volume fields that are sometimes strings, sometimes numbers. +type flexFloat float64 + +func (f *flexFloat) UnmarshalJSON(data []byte) error { + var num float64 + if err := json.Unmarshal(data, &num); err == nil { + *f = flexFloat(num) + return nil + } + + var str string + if err := json.Unmarshal(data, &str); err == nil { + v, err := strconv.ParseFloat(str, 64) + if err != nil { + *f = 0 + return nil + } + *f = flexFloat(v) + return nil + } + + *f = 0 + return nil +} + +// apiEvent is the JSON shape returned by the Gamma API for an event. +type apiEvent struct { + ID string `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + Description string `json:"description"` + Markets []apiMarket `json:"markets"` + Volume float64 `json:"volume"` + Volume24hr float64 `json:"volume24hr"` + Liquidity float64 `json:"liquidity"` + Active bool `json:"active"` + Closed bool `json:"closed"` +} + +// apiSearchResponse is the JSON shape returned by the public-search endpoint. +type apiSearchResponse struct { + Events []apiEvent `json:"events"` +} + +func apiMarketToMarket(m apiMarket) Market { + return Market{ + ID: m.ID, + Question: m.Question, + Slug: m.Slug, + Outcomes: []string(m.Outcomes), + OutcomePrices: []float64(m.OutcomePrices), + Volume: float64(m.Volume), + Volume24h: m.Volume24hr, + Liquidity: m.LiquidityClob, + EndDate: m.EndDate, + Active: m.Active, + Closed: m.Closed, + BestBid: m.BestBid, + BestAsk: m.BestAsk, + LastPrice: m.LastTradePrice, + Description: m.Description, + } +} + +func apiEventToEvent(e apiEvent) Event { + markets := make([]Market, 0, len(e.Markets)) + for _, m := range e.Markets { + markets = append(markets, apiMarketToMarket(m)) + } + + return Event{ + ID: e.ID, + Title: e.Title, + Slug: e.Slug, + Description: e.Description, + Markets: markets, + Volume: e.Volume, + Volume24h: e.Volume24hr, + Liquidity: e.Liquidity, + Active: e.Active, + Closed: e.Closed, + } +} + +func (c Config) doGet(ctx context.Context, endpoint string) ([]byte, error) { + base := c.BaseURL + if base == "" { + base = defaultGammaBaseURL + } + + 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) searchAPI(ctx context.Context, query string) ([]Event, error) { + endpoint := "/public-search?q=" + url.QueryEscape(query) + + body, err := c.doGet(ctx, endpoint) + if err != nil { + return nil, fmt.Errorf("searching: %w", err) + } + + var resp apiSearchResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parsing search response: %w", err) + } + + events := make([]Event, 0, len(resp.Events)) + for _, e := range resp.Events { + events = append(events, apiEventToEvent(e)) + } + + return events, nil +} + +func (c Config) getEventBySlug(ctx context.Context, slug string) (*Event, error) { + body, err := c.doGet(ctx, "/events/slug/"+url.PathEscape(slug)) + if err != nil { + return nil, fmt.Errorf("getting event: %w", err) + } + + var raw apiEvent + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parsing event: %w", err) + } + + ev := apiEventToEvent(raw) + return &ev, nil +} + +func (c Config) getEventByID(ctx context.Context, id string) (*Event, error) { + body, err := c.doGet(ctx, "/events/"+url.PathEscape(id)) + if err != nil { + return nil, fmt.Errorf("getting event: %w", err) + } + + var raw apiEvent + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parsing event: %w", err) + } + + ev := apiEventToEvent(raw) + return &ev, nil +} + +func (c Config) getMarketBySlug(ctx context.Context, slug string) (*Market, error) { + body, err := c.doGet(ctx, "/markets/slug/"+url.PathEscape(slug)) + if err != nil { + return nil, fmt.Errorf("getting market: %w", err) + } + + var raw apiMarket + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parsing market: %w", err) + } + + m := apiMarketToMarket(raw) + return &m, nil +} + +// parsePolymarketURL extracts the type ("event" or "market") and slug from a Polymarket URL. +// Supported formats: +// - https://polymarket.com/event/ +// - https://polymarket.com/event// +func parsePolymarketURL(rawURL string) (urlType, slug, marketSlug 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 != "polymarket.com" { + return "", "", "", fmt.Errorf("not a Polymarket URL: %s", rawURL) + } + + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) < 2 { + return "", "", "", fmt.Errorf("invalid Polymarket URL path: %s", u.Path) + } + + switch parts[0] { + case "event": + if len(parts) >= 3 { + return "event", parts[1], parts[2], nil + } + return "event", parts[1], "", nil + default: + return "", "", "", fmt.Errorf("unsupported Polymarket URL type: %s", parts[0]) + } +} diff --git a/sites/polymarket/cmd/polymarket/main.go b/sites/polymarket/cmd/polymarket/main.go new file mode 100644 index 0000000..c9d9118 --- /dev/null +++ b/sites/polymarket/cmd/polymarket/main.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "gitea.stevedudenhoeffer.com/steve/go-extractor/sites/polymarket" + "github.com/urfave/cli/v3" +) + +func main() { + app := &cli.Command{ + Name: "polymarket", + Usage: "Query prediction markets on Polymarket", + Commands: []*cli.Command{ + { + Name: "search", + Usage: "Search for markets by keyword", + ArgsUsage: "", + Action: func(ctx context.Context, cmd *cli.Command) error { + query := strings.Join(cmd.Args().Slice(), " ") + if query == "" { + return fmt.Errorf("usage: polymarket search ") + } + + events, err := polymarket.Search(ctx, query) + if err != nil { + return err + } + + if len(events) == 0 { + fmt.Println("No events found.") + return nil + } + + for _, e := range events { + printEvent(e) + } + return nil + }, + }, + { + Name: "event", + Usage: "Get event details by slug, ID, or URL", + ArgsUsage: "", + Action: func(ctx context.Context, cmd *cli.Command) error { + arg := cmd.Args().First() + if arg == "" { + return fmt.Errorf("usage: polymarket event ") + } + + var ev *polymarket.Event + var err error + + if strings.Contains(arg, "polymarket.com") { + ev, err = polymarket.GetEventFromURL(ctx, arg) + } else { + ev, err = polymarket.GetEvent(ctx, arg) + } + if err != nil { + return err + } + + printEvent(*ev) + return nil + }, + }, + { + Name: "market", + Usage: "Get market details by slug", + ArgsUsage: "", + Action: func(ctx context.Context, cmd *cli.Command) error { + slug := cmd.Args().First() + if slug == "" { + return fmt.Errorf("usage: polymarket market ") + } + + m, err := polymarket.GetMarket(ctx, slug) + if err != nil { + return err + } + + printMarket(*m, "") + return nil + }, + }, + }, + } + + if err := app.Run(context.Background(), os.Args); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func printEvent(e polymarket.Event) { + fmt.Printf("Event: %s\n", e.Title) + fmt.Printf(" URL: %s\n", e.URL()) + fmt.Printf(" Volume: $%.0f\n", e.Volume) + fmt.Printf(" Liquidity: $%.0f\n", e.Liquidity) + if e.Active { + fmt.Printf(" Status: Active\n") + } else if e.Closed { + fmt.Printf(" Status: Closed\n") + } + + if len(e.Markets) > 0 { + fmt.Printf(" Markets:\n") + for _, m := range e.Markets { + printMarket(m, " ") + } + } + fmt.Println() +} + +func printMarket(m polymarket.Market, indent string) { + fmt.Printf("%s- %s\n", indent, m.Question) + for i, outcome := range m.Outcomes { + if i < len(m.OutcomePrices) { + fmt.Printf("%s %s: %.1f%%\n", indent, outcome, m.OutcomePrices[i]*100) + } + } + if m.Volume > 0 { + fmt.Printf("%s Volume: $%.0f\n", indent, m.Volume) + } + if m.LastPrice > 0 { + fmt.Printf("%s Last: %.1f%%\n", indent, m.LastPrice*100) + } +} diff --git a/sites/polymarket/polymarket.go b/sites/polymarket/polymarket.go new file mode 100644 index 0000000..69e61ae --- /dev/null +++ b/sites/polymarket/polymarket.go @@ -0,0 +1,136 @@ +package polymarket + +import ( + "context" + "fmt" + "net/http" +) + +// Market represents a single prediction market on Polymarket. +type Market struct { + ID string + Question string + Slug string + Description string + Outcomes []string + OutcomePrices []float64 // probabilities 0-1 + Volume float64 + Volume24h float64 + Liquidity float64 + EndDate string + Active bool + Closed bool + BestBid float64 + BestAsk float64 + LastPrice float64 +} + +// URL returns the Polymarket web URL for this market. +func (m Market) URL() string { + if m.Slug == "" { + return "" + } + return "https://polymarket.com/event/" + m.Slug +} + +// Event represents a prediction market event on Polymarket, which may contain +// one or more individual markets. +type Event struct { + ID string + Title string + Slug string + Description string + Markets []Market + Volume float64 + Volume24h float64 + Liquidity float64 + Active bool + Closed bool +} + +// URL returns the Polymarket web URL for this event. +func (e Event) URL() string { + if e.Slug == "" { + return "" + } + return "https://polymarket.com/event/" + e.Slug +} + +// Config holds configuration for the Polymarket extractor. +type Config struct { + // BaseURL overrides the Gamma 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 +} + +// DefaultConfig is the default configuration. +var DefaultConfig = Config{} + +func (c Config) validate() Config { + return c +} + +// Search finds events matching the given query string. +func (c Config) Search(ctx context.Context, query string) ([]Event, error) { + c = c.validate() + return c.searchAPI(ctx, query) +} + +// GetEvent retrieves an event by its slug or numeric ID. +func (c Config) GetEvent(ctx context.Context, slugOrID string) (*Event, error) { + c = c.validate() + + // Try slug first (most common from URLs) + ev, err := c.getEventBySlug(ctx, slugOrID) + if err == nil { + return ev, nil + } + + // Fall back to numeric ID + ev, idErr := c.getEventByID(ctx, slugOrID) + if idErr == nil { + return ev, nil + } + + return nil, fmt.Errorf("getting event %q: slug lookup: %w", slugOrID, err) +} + +// GetMarket retrieves a single market by its slug. +func (c Config) GetMarket(ctx context.Context, slug string) (*Market, error) { + c = c.validate() + return c.getMarketBySlug(ctx, slug) +} + +// GetEventFromURL extracts the event slug from a Polymarket URL and retrieves +// the event. If the URL points to a specific market within an event, the full +// event is still returned. +func (c Config) GetEventFromURL(ctx context.Context, rawURL string) (*Event, error) { + c = c.validate() + + _, slug, _, err := parsePolymarketURL(rawURL) + if err != nil { + return nil, err + } + + return c.getEventBySlug(ctx, slug) +} + +// Convenience functions using DefaultConfig. + +func Search(ctx context.Context, query string) ([]Event, error) { + return DefaultConfig.Search(ctx, query) +} + +func GetEvent(ctx context.Context, slugOrID string) (*Event, error) { + return DefaultConfig.GetEvent(ctx, slugOrID) +} + +func GetMarket(ctx context.Context, slug string) (*Market, error) { + return DefaultConfig.GetMarket(ctx, slug) +} + +func GetEventFromURL(ctx context.Context, rawURL string) (*Event, error) { + return DefaultConfig.GetEventFromURL(ctx, rawURL) +} diff --git a/sites/polymarket/polymarket_test.go b/sites/polymarket/polymarket_test.go new file mode 100644 index 0000000..23c36e2 --- /dev/null +++ b/sites/polymarket/polymarket_test.go @@ -0,0 +1,363 @@ +package polymarket + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestStringOrArray_Array(t *testing.T) { + input := `["Yes","No"]` + var s stringOrArray + if err := json.Unmarshal([]byte(input), &s); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if len(s) != 2 || s[0] != "Yes" || s[1] != "No" { + t.Errorf("got %v, want [Yes No]", s) + } +} + +func TestStringOrArray_StringifiedArray(t *testing.T) { + input := `"[\"Yes\",\"No\"]"` + var s stringOrArray + if err := json.Unmarshal([]byte(input), &s); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if len(s) != 2 || s[0] != "Yes" || s[1] != "No" { + t.Errorf("got %v, want [Yes No]", s) + } +} + +func TestFloatOrArray_Array(t *testing.T) { + input := `[0.65, 0.35]` + var f floatOrArray + if err := json.Unmarshal([]byte(input), &f); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if len(f) != 2 || f[0] != 0.65 || f[1] != 0.35 { + t.Errorf("got %v, want [0.65 0.35]", f) + } +} + +func TestFloatOrArray_StringArray(t *testing.T) { + input := `["0.65","0.35"]` + var f floatOrArray + if err := json.Unmarshal([]byte(input), &f); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if len(f) != 2 || f[0] != 0.65 || f[1] != 0.35 { + t.Errorf("got %v, want [0.65 0.35]", f) + } +} + +func TestFloatOrArray_StringifiedStringArray(t *testing.T) { + input := `"[\"0.0125\",\"0.9875\"]"` + var f floatOrArray + if err := json.Unmarshal([]byte(input), &f); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if len(f) != 2 || f[0] != 0.0125 || f[1] != 0.9875 { + t.Errorf("got %v, want [0.0125 0.9875]", f) + } +} + +func TestFlexFloat_Number(t *testing.T) { + input := `1234.56` + var f flexFloat + if err := json.Unmarshal([]byte(input), &f); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if float64(f) != 1234.56 { + t.Errorf("got %v, want 1234.56", f) + } +} + +func TestFlexFloat_String(t *testing.T) { + input := `"1787746.945826"` + var f flexFloat + if err := json.Unmarshal([]byte(input), &f); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if float64(f) != 1787746.945826 { + t.Errorf("got %v, want 1787746.945826", f) + } +} + +func TestParsePolymarketURL(t *testing.T) { + tests := []struct { + name string + url string + wantType string + wantSlug string + wantMarket string + wantErr bool + }{ + { + name: "event URL", + url: "https://polymarket.com/event/microstrategy-sell-any-bitcoin-in-2025", + wantType: "event", + wantSlug: "microstrategy-sell-any-bitcoin-in-2025", + }, + { + name: "event with market slug", + url: "https://polymarket.com/event/measles-cases/will-there-be-10000-cases", + wantType: "event", + wantSlug: "measles-cases", + wantMarket: "will-there-be-10000-cases", + }, + { + name: "www prefix", + url: "https://www.polymarket.com/event/some-event", + wantType: "event", + wantSlug: "some-event", + }, + { + name: "wrong host", + url: "https://example.com/event/foo", + wantErr: true, + }, + { + name: "too short path", + url: "https://polymarket.com/event", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + typ, slug, mkt, err := parsePolymarketURL(tt.url) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if typ != tt.wantType { + t.Errorf("type = %q, want %q", typ, tt.wantType) + } + if slug != tt.wantSlug { + t.Errorf("slug = %q, want %q", slug, tt.wantSlug) + } + if mkt != tt.wantMarket { + t.Errorf("market = %q, want %q", mkt, tt.wantMarket) + } + }) + } +} + +func newTestServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, Config) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return srv, Config{BaseURL: srv.URL} +} + +func TestSearch(t *testing.T) { + _, cfg := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/public-search" { + http.NotFound(w, r) + return + } + q := r.URL.Query().Get("q") + if q != "olympic hockey" { + t.Errorf("query = %q, want %q", q, "olympic hockey") + } + resp := apiSearchResponse{ + Events: []apiEvent{ + { + ID: "100", + Title: "USA vs CAN Olympic Hockey (Men)", + Slug: "usa-vs-can-olympic-hockey-men", + Markets: []apiMarket{ + { + ID: "200", + Question: "Will USA win?", + Slug: "will-usa-win", + Outcomes: stringOrArray{"Yes", "No"}, + OutcomePrices: floatOrArray{0.45, 0.55}, + Active: true, + }, + }, + Active: true, + }, + { + ID: "101", + Title: "USA vs CAN Olympic Hockey (Women)", + Slug: "usa-vs-can-olympic-hockey-women", + Active: true, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + + events, err := cfg.Search(context.Background(), "olympic hockey") + if err != nil { + t.Fatalf("Search error: %v", err) + } + if len(events) != 2 { + t.Fatalf("got %d events, want 2", len(events)) + } + if events[0].Title != "USA vs CAN Olympic Hockey (Men)" { + t.Errorf("title = %q", events[0].Title) + } + if len(events[0].Markets) != 1 { + t.Fatalf("got %d markets, want 1", len(events[0].Markets)) + } + m := events[0].Markets[0] + if m.Question != "Will USA win?" { + t.Errorf("question = %q", m.Question) + } + if len(m.OutcomePrices) != 2 || m.OutcomePrices[0] != 0.45 { + t.Errorf("prices = %v", m.OutcomePrices) + } +} + +func TestGetEvent(t *testing.T) { + _, cfg := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/events/slug/my-event" { + http.NotFound(w, r) + return + } + resp := apiEvent{ + ID: "42", + Title: "My Event", + Slug: "my-event", + Markets: []apiMarket{ + { + ID: "100", + Question: "Will it happen?", + Slug: "will-it-happen", + Outcomes: stringOrArray{"Yes", "No"}, + OutcomePrices: floatOrArray{0.7, 0.3}, + BestBid: 0.69, + BestAsk: 0.71, + LastTradePrice: 0.7, + Active: true, + }, + }, + Volume: 500000, + Liquidity: 25000, + Active: true, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + + ev, err := cfg.GetEvent(context.Background(), "my-event") + if err != nil { + t.Fatalf("GetEvent error: %v", err) + } + if ev.Title != "My Event" { + t.Errorf("title = %q", ev.Title) + } + if ev.Volume != 500000 { + t.Errorf("volume = %v", ev.Volume) + } + if len(ev.Markets) != 1 { + t.Fatalf("got %d markets, want 1", len(ev.Markets)) + } + if ev.Markets[0].BestBid != 0.69 { + t.Errorf("best bid = %v", ev.Markets[0].BestBid) + } + if ev.URL() != "https://polymarket.com/event/my-event" { + t.Errorf("URL = %q", ev.URL()) + } +} + +func TestGetMarket(t *testing.T) { + _, cfg := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/markets/slug/will-it-happen" { + http.NotFound(w, r) + return + } + resp := apiMarket{ + ID: "100", + Question: "Will it happen?", + Slug: "will-it-happen", + Outcomes: stringOrArray{"Yes", "No"}, + OutcomePrices: floatOrArray{0.7, 0.3}, + Volume: flexFloat(500000), + Volume24hr: 12000, + LiquidityClob: 25000, + BestBid: 0.69, + BestAsk: 0.71, + LastTradePrice: 0.7, + Active: true, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + + m, err := cfg.GetMarket(context.Background(), "will-it-happen") + if err != nil { + t.Fatalf("GetMarket error: %v", err) + } + if m.Question != "Will it happen?" { + t.Errorf("question = %q", m.Question) + } + if m.Volume != 500000 { + t.Errorf("volume = %v", m.Volume) + } + if m.LastPrice != 0.7 { + t.Errorf("last price = %v", m.LastPrice) + } +} + +func TestGetEventFromURL(t *testing.T) { + _, cfg := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/events/slug/my-event" { + http.NotFound(w, r) + return + } + resp := apiEvent{ + ID: "42", + Title: "My Event", + Slug: "my-event", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + + // Override the URL to point to our test server via the config + ev, err := cfg.GetEventFromURL(context.Background(), "https://polymarket.com/event/my-event") + if err != nil { + t.Fatalf("GetEventFromURL error: %v", err) + } + if ev.Title != "My Event" { + t.Errorf("title = %q", ev.Title) + } +} + +func TestSearch_Empty(t *testing.T) { + _, cfg := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(apiSearchResponse{}) + }) + + events, err := cfg.Search(context.Background(), "nonexistent") + if err != nil { + t.Fatalf("Search error: %v", err) + } + if len(events) != 0 { + t.Errorf("got %d events, want 0", len(events)) + } +} + +func TestEventURL(t *testing.T) { + e := Event{Slug: "test-event"} + if e.URL() != "https://polymarket.com/event/test-event" { + t.Errorf("URL = %q", e.URL()) + } + + e2 := Event{} + if e2.URL() != "" { + t.Errorf("empty slug URL = %q, want empty", e2.URL()) + } +} -- 2.49.1