repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

8.7 KB · 270 lines · Go Raw History
  1package skills
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"net/url"
  8	"sort"
  9	"strconv"
 10	"strings"
 11	"time"
 12)
 13
 14// Polymarket's Gamma API is public, keyless and needs no wallet. Only order
 15// placement is authenticated, and nothing here places one.
 16const (
 17	gammaSearchURL = "https://gamma-api.polymarket.com/public-search"
 18
 19	// Polymarket lists a novelty market beside a real one, and "how many
 20	// Chipotle BOGOs will be given out during the 2026 US Open" is a real
 21	// market with real prices that answers nothing anybody asked. Volume is
 22	// what separates them, since the tournament winner traded $20m and the
 23	// BOGO market $204. The floor is low enough to leave a thin but real
 24	// market alone, because declining sends the question to a web search that
 25	// is worse at odds than any market is.
 26	minOddsVolume = 10000
 27)
 28
 29// Odds answers "what are the chances" from a prediction market.
 30//
 31// This exists because it is the question the web is worst at. A search for the
 32// odds on a tournament returns bookmaker affiliate pages, listicles written
 33// before the event started, and fractional odds nobody converts, and the model
 34// then paraphrases whichever it fetched. A prediction market publishes a single
 35// number that already means a probability, and it is current to the minute.
 36//
 37// The number is what people are betting, which is not the same thing as the
 38// truth, so the answer says so rather than presenting it as a forecast.
 39type Odds struct{}
 40
 41func (Odds) Card() Card {
 42	return Card{
 43		Name: "odds",
 44		Does: "reports the current market-implied probability of a future event, such as who wins an election, a tournament or a title, from a prediction market.",
 45		Fires: []string{
 46			"what are the odds on the us open",
 47			"who is favoured to win the election",
 48			"chances of a rate cut this month",
 49			"is trump likely to win",
 50			"what are the odds the fed cuts rates",
 51		},
 52		NotFor: []string{
 53			"who won the us open",
 54			"how is the us open going",
 55			"what's the score in the us open",
 56			"what are the rules of the us open",
 57			"when is the election",
 58			"what happened in the last election",
 59			"how do betting odds work",
 60		},
 61		Keywords: []string{"odds", "chances of", "likely to win", "favourite to win",
 62			"favorite to win", "probability of", "who will win"},
 63	}
 64}
 65
 66type gammaSearch struct {
 67	Events []struct {
 68		Title   string  `json:"title"`
 69		Slug    string  `json:"slug"`
 70		Closed  bool    `json:"closed"`
 71		Active  bool    `json:"active"`
 72		Volume  float64 `json:"volume"`
 73		EndDate string  `json:"endDate"`
 74		Markets []struct {
 75			GroupItemTitle string `json:"groupItemTitle"`
 76			Question       string `json:"question"`
 77			Closed         bool   `json:"closed"`
 78			// Both of these are JSON arrays encoded as strings inside the JSON,
 79			// so they need a second decode. Reading them as []string silently
 80			// yields nothing.
 81			Outcomes      string `json:"outcomes"`
 82			OutcomePrices string `json:"outcomePrices"`
 83		} `json:"markets"`
 84	} `json:"events"`
 85}
 86
 87type contender struct {
 88	Name  string
 89	Prob  float64
 90	Fixed bool
 91}
 92
 93func (Odds) Run(ctx context.Context, question string, d Deps) (*Result, error) {
 94	start := d.now()
 95
 96	// The router sends this questions that merely name something it deals in,
 97	// and a skill that trusts the router has no way of declining. "How is the
 98	// us open going" wants the state of a tournament, and a price is not an
 99	// answer to it.
100	if !wantsProbability(question) {
101		return nil, nil
102	}
103
104	q := oddsQuery(question)
105	if q == "" {
106		return nil, nil
107	}
108
109	// events_status=active drops the settled markets, which is what made a
110	// search for a tournament return last year's.
111	u := fmt.Sprintf("%s?q=%s&limit_per_type=10&events_status=active",
112		gammaSearchURL, url.QueryEscape(q))
113	var s gammaSearch
114	if err := getJSON(ctx, d, u, &s); err != nil {
115		return nil, err
116	}
117
118	// Search order is relevance, which is the right order: sorting by volume
119	// instead answers a question about the women's draw from the men's market
120	// because more money is on it.
121	for _, ev := range s.Events {
122		if ev.Volume < minOddsVolume {
123			continue
124		}
125		if ev.Closed || len(ev.Markets) == 0 {
126			continue
127		}
128		var picks []contender
129		for _, m := range ev.Markets {
130			var names, prices []string
131			if json.Unmarshal([]byte(m.Outcomes), &names) != nil ||
132				json.Unmarshal([]byte(m.OutcomePrices), &prices) != nil ||
133				len(names) == 0 || len(prices) != len(names) {
134				continue
135			}
136			label := m.GroupItemTitle
137			if label == "" {
138				label = m.Question
139			}
140			// A Yes/No market on one contender carries that contender's
141			// probability in the Yes leg. A market with real outcome names
142			// carries one row per outcome instead.
143			if len(names) == 2 && strings.EqualFold(names[0], "yes") {
144				p, err := strconv.ParseFloat(prices[0], 64)
145				if err != nil {
146					continue
147				}
148				picks = append(picks, contender{Name: label, Prob: p, Fixed: m.Closed})
149				continue
150			}
151			for i, n := range names {
152				p, err := strconv.ParseFloat(prices[i], 64)
153				if err != nil {
154					continue
155				}
156				picks = append(picks, contender{Name: n, Prob: p, Fixed: m.Closed})
157			}
158		}
159
160		// A settled leg prices at exactly 0 or 1 and is an eliminated name
161		// rather than a long shot, so it is dropped instead of listed at 0%.
162		live := picks[:0]
163		for _, p := range picks {
164			if p.Fixed || p.Prob <= 0.001 {
165				continue
166			}
167			live = append(live, p)
168		}
169		if len(live) == 0 {
170			continue
171		}
172		sort.SliceStable(live, func(i, j int) bool { return live[i].Prob > live[j].Prob })
173
174		return &Result{
175			Skill: "odds", Shape: "factual",
176			Text:    oddsText(ev.Title, ev.Slug, ev.EndDate, ev.Volume, live, d),
177			Sources: []Source{{URL: "https://polymarket.com/event/" + ev.Slug, Title: ev.Title, Site: "polymarket.com"}},
178			Elapsed: d.now().Sub(start).Round(10 * time.Millisecond).String(),
179		}, nil
180	}
181	return nil, nil
182}
183
184func oddsText(title, slug, end string, volume float64, live []contender, d Deps) string {
185	var b strings.Builder
186
187	top := live[0]
188	if len(live) == 1 {
189		fmt.Fprintf(&b, "**%.0f%%** on %s.\n\n", top.Prob*100, strings.ToLower(title))
190	} else {
191		fmt.Fprintf(&b, "**%s** is the favourite at **%.0f%%** in %s.\n\n",
192			top.Name, top.Prob*100, title)
193	}
194
195	shown := live
196	if len(shown) > 8 {
197		shown = shown[:8]
198	}
199	for _, p := range shown {
200		fmt.Fprintf(&b, "- **%s** %.0f%%\n", p.Name, p.Prob*100)
201	}
202	if len(live) > len(shown) {
203		fmt.Fprintf(&b, "- %d others below %.0f%%\n", len(live)-len(shown), shown[len(shown)-1].Prob*100)
204	}
205
206	// The one caveat that changes how the number should be read, and the only
207	// thing here the bullets do not already carry.
208	b.WriteString("\nThese are prices on Polymarket, so they say what people are betting rather than what will happen")
209	if volume > 0 {
210		fmt.Fprintf(&b, ", on **$%s** of volume", formatNumber(round2(volume)))
211	}
212	if t, err := time.Parse(time.RFC3339, end); err == nil {
213		fmt.Fprintf(&b, ", resolving %s", t.Format("2 January 2006"))
214	}
215	fmt.Fprintf(&b, ". Read %s.", d.now().Format("3:04 PM on 2 January"))
216	return b.String()
217}
218
219// wantsProbability is the guard on the router. A question has to actually ask
220// how likely something is, since naming a tournament is not asking for a price
221// on it.
222func wantsProbability(question string) bool {
223	l := strings.ToLower(question)
224	for _, w := range []string{
225		"odds", "chance", "chances", "probability", "likely", "likelihood",
226		"favourite", "favorite", "favoured", "favored", "predicted", "expected to win",
227		"who will win", "who wins", "going to win", "will he win", "will she win",
228		"will they win", "bet", "betting", "market says",
229	} {
230		if strings.Contains(l, w) {
231			return true
232		}
233	}
234	return false
235}
236
237// oddsQuery strips the asking words so the search sees the subject. Sending the
238// whole question matches on "what" and "the" and returns whatever is busiest.
239func oddsQuery(question string) string {
240	l := strings.ToLower(question)
241	for _, cut := range []string{
242		"what are the odds on", "what are the odds that", "what are the odds of",
243		"what are the odds", "what is the probability of", "what are the chances of",
244		"what are the chances", "chances of", "odds on", "odds of", "odds for",
245		"who is favoured to win", "who is favored to win", "who is likely to win",
246		"who will win", "is it likely that", "how likely is",
247	} {
248		l = strings.ReplaceAll(l, cut, " ")
249	}
250	var keep []string
251	for _, w := range strings.Fields(l) {
252		w = strings.Trim(w, "?.,!'\"")
253		switch w {
254		case "", "the", "a", "an", "of", "on", "in", "for", "to", "is", "are",
255			"be", "will", "what", "who", "how", "odds", "chance", "chances", "probability",
256			"likely", "favourite", "favorite", "win", "wins", "winning", "this", "that",
257			"going", "now", "currently", "still", "right", "today", "tonight":
258			continue
259		}
260		keep = append(keep, w)
261	}
262	if len(keep) == 0 {
263		return ""
264	}
265	if len(keep) > 6 {
266		keep = keep[:6]
267	}
268	return strings.Join(keep, " ")
269}