diff options
Diffstat (limited to 'internal/prediction')
| -rw-r--r-- | internal/prediction/market.go | 274 | ||||
| -rw-r--r-- | internal/prediction/market_test.go | 167 |
2 files changed, 441 insertions, 0 deletions
diff --git a/internal/prediction/market.go b/internal/prediction/market.go new file mode 100644 index 0000000..af74ff9 --- /dev/null +++ b/internal/prediction/market.go @@ -0,0 +1,274 @@ +// 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 +} + +// 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() +} diff --git a/internal/prediction/market_test.go b/internal/prediction/market_test.go new file mode 100644 index 0000000..e5e0bcb --- /dev/null +++ b/internal/prediction/market_test.go @@ -0,0 +1,167 @@ +package prediction + +import ( + "context" + "fmt" + "math" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +func fixture() Market { + return Market{ID: "1", Question: "Will it rain?", Description: "Rain at the airport.", Outcomes: `["Yes","No"]`, Tokens: `["11","12"]`, Prices: `["0.421","0.579"]`, Active: true, Accepting: true, End: time.Now().Add(time.Hour).UTC().Format(time.RFC3339)} +} +func TestTradable(t *testing.T) { + good := fixture() + if !good.Tradable(time.Now()) { + t.Fatal("valid market rejected") + } + cases := map[string]func(*Market){"closed": func(m *Market) { m.Closed = true }, "inactive": func(m *Market) { m.Active = false }, "paused": func(m *Market) { m.Accepting = false }, "archived": func(m *Market) { m.Archived = true }, "expired": func(m *Market) { m.End = time.Now().Add(-time.Hour).Format(time.RFC3339) }, "missing expiry": func(m *Market) { m.End = "" }, "started sport": func(m *Market) { m.Start = time.Now().Add(-time.Minute).Format(time.RFC3339) }, "bad start": func(m *Market) { m.Start = "tomorrow" }, "disputed": func(m *Market) { m.Resolution = "disputed" }, "bad price": func(m *Market) { m.Prices = `["NaN","0.5"]` }, "duplicate tokens": func(m *Market) { m.Tokens = `["11","11"]` }} + for name, change := range cases { + t.Run(name, func(t *testing.T) { + m := good + change(&m) + if m.Tradable(time.Now()) { + t.Fatal("unsafe market tradable") + } + }) + } + good.Start = time.Now().Add(time.Minute).UTC().Format("2006-01-02 15:04:05-07") + if !good.Tradable(time.Now()) { + t.Fatal("Gamma sports date format rejected") + } +} +func TestSettlementOnlyFinalized(t *testing.T) { + m := fixture() + m.Prices = `["1","0"]` + if _, ok := m.Settlement("11"); ok { + t.Fatal("open market settled") + } + m.Closed = true + m.Prices = `["0.999","0.001"]` + if _, ok := m.Settlement("11"); ok { + t.Fatal("near-final quote settled") + } + m.Prices = `["1","0"]` + if p, ok := m.Settlement("11"); !ok || p != 1 { + t.Fatal("winner not paid") + } + if p, ok := m.Settlement("12"); !ok || p != 0 { + t.Fatal("loser not settled") + } + m.Prices = `["0.5","0.5"]` + if p, ok := m.Settlement("11"); !ok || p != .5 { + t.Fatal("split result rejected") + } + if _, ok := m.Settlement("unknown"); ok { + t.Fatal("wrong token settled") + } +} +func TestRoundingCannotMintCredits(t *testing.T) { + for q := 1; q <= 20; q++ { + for i := 1; i < 1000; i++ { + p := float64(i) / 1000 + if SellValue(p, q) > BuyCost(p, q) { + t.Fatal("round-trip profit") + } + } + } + if BuyCost(.421, 1) != 43 || SellValue(.421, 1) != 42 || BuyCost(.29, 100) != 2900 || BuyCost(.421, 10) != 430 || SellValue(.421, 10) != 420 || BuyPrice(1e-14) != 1 || SellPrice(.29) != 29 { + t.Fatal("incorrect whole-credit rounding") + } +} +func TestClientFailuresAndBoundaries(t *testing.T) { + for _, tc := range []struct { + name, body string + status int + }{ + {"rate limit", `{}`, 429}, {"server error", `{}`, 500}, {"bad JSON", `<html>`, 200}, {"wrong identity", `{"id":"2"}`, 200}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(tc.status); fmt.Fprint(w, tc.body) })) + defer server.Close() + c := NewClient() + c.Gamma = server.URL + if _, err := c.Market(context.Background(), "1"); err == nil { + t.Fatal("accepted bad response") + } + }) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := NewClient().List(ctx); err == nil { + t.Fatal("ignored cancellation") + } +} +func TestGraphUsesContinuousLines(t *testing.T) { + points := []Point{{T: 100, P: .2}, {T: 200, P: .8}, {T: 300, P: .3}} + unicode := GraphMode(points, true, 32) + for _, bad := range []string{"●", "*", "•"} { + if strings.Contains(unicode, bad) { + t.Fatalf("dot plot survived: %s", unicode) + } + } + for _, part := range []string{"─", "┘", "┌", "┐", "└"} { + if !strings.Contains(unicode, part) { + t.Fatalf("missing connected line %s", part) + } + } + flat := GraphMode([]Point{{P: .5}, {P: .5}}, true, 16) + if !strings.Contains(flat, " 50% │"+strings.Repeat("─", 16)+"│") { + t.Fatal("flat series should be an unbroken line") + } + ascii := GraphMode(points, false, 32) + for _, r := range ascii { + if r > 127 { + t.Fatal("ASCII chart contains Unicode") + } + } +} + +func TestGraphAndTerminalSanitization(t *testing.T) { + graph := Graph([]Point{{T: 100, P: .1}, {T: 200, P: .9}}) + for _, want := range []string{"100%", " 0%", "-", "UTC"} { + if !strings.Contains(graph, want) { + t.Fatalf("missing %s", want) + } + } + if !strings.Contains(Graph(nil), "not available") { + t.Fatal("empty history") + } + _ = Graph([]Point{{P: math.NaN()}, {P: 2}}) + clean := Clean("a\x1b[31m\r\n\xff\u202eb", 100) + if strings.ContainsAny(clean, "\x1b\r\n\u202e") { + t.Fatal("terminal control survived") + } +} + +// Explicit opt-in smoke test; normal tests never depend on the live service. +func TestPolymarketLive(t *testing.T) { + if os.Getenv("TEST_POLYMARKET_LIVE") != "1" { + t.Skip("set TEST_POLYMARKET_LIVE=1 for live smoke test") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + c := NewClient() + markets, err := c.List(ctx) + if err != nil { + t.Fatal(err) + } + if len(markets) == 0 { + t.Fatal("no eligible markets") + } + m, err := c.Market(ctx, markets[0].ID) + if err != nil { + t.Fatal(err) + } + _, tokens, _, _ := m.Values() + history, err := c.History(ctx, tokens[0]) + if err != nil { + t.Fatal(err) + } + t.Logf("%d eligible markets; fetched %q and %d history points", len(markets), m.Question, len(history)) +} |
