feat: add Polymarket and Kalshi prediction market extractors
All checks were successful
CI / vet (pull_request) Successful in 2m15s
CI / build (pull_request) Successful in 2m17s
CI / test (pull_request) Successful in 2m27s

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:
2026-02-21 00:52:40 +00:00
parent 8c2848246b
commit 29e53bb7c2
8 changed files with 1910 additions and 0 deletions

260
sites/kalshi/api.go Normal file
View 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
}

View File

@@ -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: "<query>",
Action: func(ctx context.Context, cmd *cli.Command) error {
query := strings.Join(cmd.Args().Slice(), " ")
if query == "" {
return fmt.Errorf("usage: kalshi search <query>")
}
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: "<ticker-or-url>",
Action: func(ctx context.Context, cmd *cli.Command) error {
arg := cmd.Args().First()
if arg == "" {
return fmt.Errorf("usage: kalshi event <ticker-or-url>")
}
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: "<ticker-or-url>",
Action: func(ctx context.Context, cmd *cli.Command) error {
arg := cmd.Args().First()
if arg == "" {
return fmt.Errorf("usage: kalshi market <ticker-or-url>")
}
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"))
}
}

160
sites/kalshi/kalshi.go Normal file
View File

@@ -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)
}

387
sites/kalshi/kalshi_test.go Normal file
View File

@@ -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)
}
}