feat: add Polymarket and Kalshi prediction market extractors #66
260
sites/kalshi/api.go
Normal file
260
sites/kalshi/api.go
Normal 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
|
||||||
|
}
|
||||||
147
sites/kalshi/cmd/kalshi/main.go
Normal file
147
sites/kalshi/cmd/kalshi/main.go
Normal 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
160
sites/kalshi/kalshi.go
Normal 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
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
326
sites/polymarket/api.go
Normal file
326
sites/polymarket/api.go
Normal file
@@ -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/<slug>
|
||||||
|
// - https://polymarket.com/event/<event-slug>/<market-slug>
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
131
sites/polymarket/cmd/polymarket/main.go
Normal file
131
sites/polymarket/cmd/polymarket/main.go
Normal file
@@ -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: "<query>",
|
||||||
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
|
query := strings.Join(cmd.Args().Slice(), " ")
|
||||||
|
if query == "" {
|
||||||
|
return fmt.Errorf("usage: polymarket search <query>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "<slug-or-url>",
|
||||||
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
|
arg := cmd.Args().First()
|
||||||
|
if arg == "" {
|
||||||
|
return fmt.Errorf("usage: polymarket event <slug-or-url>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "<slug>",
|
||||||
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
|
slug := cmd.Args().First()
|
||||||
|
if slug == "" {
|
||||||
|
return fmt.Errorf("usage: polymarket market <slug>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
136
sites/polymarket/polymarket.go
Normal file
136
sites/polymarket/polymarket.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
363
sites/polymarket/polymarket_test.go
Normal file
363
sites/polymarket/polymarket_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user