Files
go-extractor/sites/polymarket/polymarket_test.go
Steve Dudenhoeffer 29e53bb7c2
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
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>
2026-02-21 00:52:40 +00:00

364 lines
9.0 KiB
Go

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