orchard
mirrorEvery 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
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}