aboutsummaryrefslogtreecommitdiff
path: root/internal/prediction
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-05 06:03:49 -0400
committerhistoria <[not public]>2026-10-05 06:03:49 -0400
commitc84a9944356f590d90d819d652bdf1349ebacd40 (patch)
treeaf1ee15992e141fb3eb6f26cac02cf06306d4982 /internal/prediction
parent8b78aa4cd930fde145959704ba7bdc0e57465b9b (diff)
downloadthehouseoficarus-c84a9944356f590d90d819d652bdf1349ebacd40.tar.gz
feat: prediction exchange with live polymarket data
Diffstat (limited to 'internal/prediction')
-rw-r--r--internal/prediction/market.go274
-rw-r--r--internal/prediction/market_test.go167
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))
+}