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 <noreply@anthropic.com>
This commit is contained in:
387
sites/kalshi/kalshi_test.go
Normal file
387
sites/kalshi/kalshi_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user