// Package prediction implements a paper exchange: no wallets or real orders. package prediction import ( "context" "encoding/json" "fmt" "io" "math" "net/http" "net/url" "strconv" "strings" "time" "unicode" ) const FaceValue = 100 const MaxContracts = 50 type Contract struct { MarketID string `yaml:"market_id"` Title string `yaml:"title"` Outcome string `yaml:"outcome"` Token string `yaml:"token"` Quantity int `yaml:"quantity"` Cost int `yaml:"cost"` Proceeds int `yaml:"proceeds"` Status string `yaml:"status"` // open, sold, settled Opened string `yaml:"opened"` Closed string `yaml:"closed,omitempty"` } type Market struct { ID string `json:"id"` Question string `json:"question"` Description string `json:"description"` Outcomes string `json:"outcomes"` Prices string `json:"outcomePrices"` Tokens string `json:"clobTokenIds"` Active bool `json:"active"` Closed bool `json:"closed"` Archived bool `json:"archived"` Accepting bool `json:"acceptingOrders"` End string `json:"endDate"` Start string `json:"gameStartTime"` Resolution string `json:"umaResolutionStatus"` } // Clean removes remote terminal controls and collapses paragraphs for local wrapping. func Clean(s string, max int) string { r := []rune(strings.Join(strings.FieldsFunc(s, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) || r == '\u202e' || unicode.Is(unicode.Cf, r) }), " ")) if len(r) > max { r = r[:max] } return string(r) } func (m Market) Values() (names, tokens []string, prices []float64, err error) { var raw []string if json.Unmarshal([]byte(m.Outcomes), &names) != nil || json.Unmarshal([]byte(m.Tokens), &tokens) != nil || json.Unmarshal([]byte(m.Prices), &raw) != nil || len(names) < 2 || len(names) != len(raw) || len(tokens) != len(names) { return nil, nil, nil, fmt.Errorf("invalid market outcomes") } seen := make(map[string]bool) for i, token := range tokens { if token == "" || seen[token] || strings.TrimSpace(names[i]) == "" { return nil, nil, nil, fmt.Errorf("invalid outcome identity") } seen[token] = true } for _, s := range raw { p, e := strconv.ParseFloat(s, 64) if e != nil || math.IsNaN(p) || math.IsInf(p, 0) || p < 0 || p > 1 { return nil, nil, nil, fmt.Errorf("invalid price") } prices = append(prices, p) } return } func (m Market) Tradable(now time.Time) bool { if !m.Active || m.Closed || m.Archived || !m.Accepting || m.Resolution != "" && m.Resolution != "unresolved" { return false } // Fail closed on missing/malformed expiry; sports stop at kickoff where supplied. end, err := time.Parse(time.RFC3339, m.End) if err != nil || !now.Before(end) { return false } if m.Start != "" { start, err := time.Parse(time.RFC3339, m.Start) if err != nil { start, err = time.Parse("2006-01-02 15:04:05-07", m.Start) } if err != nil || !now.Before(start) { return false } } _, _, _, err = m.Values() return err == nil } func (m Market) Price(token string) (float64, error) { _, tokens, prices, err := m.Values() if err != nil { return 0, err } for i, t := range tokens { if t == token { return prices[i], nil } } return 0, fmt.Errorf("outcome changed") } // Settlement reports the terminal per-share payout for a closed market. A // resolved flag is not required: once a market stops trading its payout vector // is normally driven to whole values (1, 0, or 0.5 for a push). Near-1 quotes // are rejected, so a paused or disputed market is never inferred as decided. func (m Market) Settlement(token string) (float64, bool) { if !m.Closed { return 0, false } _, _, prices, err := m.Values() if err != nil { return 0, false } sum := 0.0 for _, p := range prices { if p != 0 && p != 1 && p != 0.5 { return 0, false } sum += p } if sum != 1 { return 0, false } p, err := m.Price(token) return p, err == nil } // BuyPrice returns the house buy quote. House quotes are whole credits PER // SHARE, not fractional prices rounded only after multiplication. A // 42.1-credit quote costs 43 and sells for 42 per share. func BuyPrice(p float64) int { return int(math.Ceil(math.Nextafter(p*FaceValue, 0))) } func SellPrice(p float64) int { return int(math.Floor(math.Nextafter(p*FaceValue, math.Inf(1)))) } func BuyCost(p float64, q int) int { return BuyPrice(p) * q } func SellValue(p float64, q int) int { return SellPrice(p) * q } type Client struct { HTTP *http.Client Gamma, CLOB string } func NewClient() *Client { return &Client{HTTP: &http.Client{Timeout: 8 * time.Second}, Gamma: "https://gamma-api.polymarket.com", CLOB: "https://clob.polymarket.com"} } func (c *Client) get(ctx context.Context, address string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil) if err != nil { return err } req.Header.Set("User-Agent", "IcarusMUD-PaperMarkets/1.0") req.Header.Set("Cache-Control", "no-cache") resp, err := c.HTTP.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("market API: HTTP %d", resp.StatusCode) } return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(out) } func (c *Client) List(ctx context.Context) ([]Market, error) { var ms []Market err := c.get(ctx, c.Gamma+"/markets?limit=100&active=true&closed=false&order=volume24hr&ascending=false", &ms) if err != nil { return nil, err } out := []Market{} for _, m := range ms { if m.ID != "" && m.Question != "" && m.Tradable(time.Now()) { out = append(out, m) } } return out, nil } func (c *Client) Market(ctx context.Context, id string) (Market, error) { var m Market err := c.get(ctx, c.Gamma+"/markets/"+url.PathEscape(id), &m) if err == nil && (m.ID != id || strings.TrimSpace(m.Question) == "") { err = fmt.Errorf("market identity mismatch") } if err == nil { _, _, _, err = m.Values() } return m, err } type Point struct { T int64 `json:"t"` P float64 `json:"p"` } func (c *Client) History(ctx context.Context, token string) ([]Point, error) { var v struct { History []Point `json:"history"` } err := c.get(ctx, c.CLOB+"/prices-history?market="+url.QueryEscape(token)+"&interval=1w&fidelity=60", &v) for _, p := range v.History { if math.IsNaN(p.P) || math.IsInf(p.P, 0) || p.P < 0 || p.P > 1 { return nil, fmt.Errorf("invalid history") } } return v.History, err } // Graph preserves the plain-text rendering for non-interactive consumers. func Graph(points []Point) string { return GraphMode(points, false, 48) } // GraphMode uses connected Unicode plotting unless the client requests ASCII. func GraphMode(points []Point, unicode bool, width int) string { if len(points) < 2 { return "Odds history is not available for this outcome." } width = max(4, min(80, width)) horizontal, vertical, downCorner, upCorner, endDown, endUp := '-', '|', '+', '+', '+', '+' side, bottom, left, right, arrow := "|", "-", "+", "+", "->" if unicode { horizontal, vertical, downCorner, upCorner, endDown, endUp = '─', '│', '┐', '┘', '└', '┌' side, bottom, left, right, arrow = "│", "─", "└", "┘", "→" } grid := make([][]rune, 11) for i := range grid { grid[i] = []rune(strings.Repeat(" ", width)) } previous := -1 for x := 0; x < width; x++ { // Interpolate samples so sparse history remains a continuous trace. position := float64(x) * float64(len(points)-1) / float64(width-1) i := int(position) fraction := position - float64(i) p := points[i].P if i+1 < len(points) { p += fraction * (points[i+1].P - p) } if p < 0 || p > 1 || math.IsNaN(p) { previous = -1 continue } row := 10 - int(math.Round(p*10)) if previous < 0 || previous == row { grid[row][x] = horizontal } else { for y := min(previous, row) + 1; y < max(previous, row); y++ { grid[y][x] = vertical } if row > previous { grid[previous][x] = downCorner grid[row][x] = endDown } else { grid[previous][x] = upCorner grid[row][x] = endUp } } previous = row } var b strings.Builder for y, row := range grid { fmt.Fprintf(&b, "%3d%% %s%s%s\n", 100-y*10, side, string(row), side) } b.WriteString(" " + left + strings.Repeat(bottom, width) + right + "\n") fmt.Fprintf(&b, " %s UTC %s %s UTC", time.Unix(points[0].T, 0).UTC().Format("Jan 02 15:04"), arrow, time.Unix(points[len(points)-1].T, 0).UTC().Format("Jan 02 15:04")) return b.String() }