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 main
2
3import (
4 "context"
5 "fmt"
6 "math"
7 "net/url"
8 "sort"
9 "strconv"
10 "strings"
11 "time"
12)
13
14// Earnings for the hundred largest companies in the S&P 500, which is as far
15// down the index as a name is still a reason the market moved. Nasdaq publishes
16// a free calendar and a free screener, and the screener is what makes "the top
17// hundred" a filter that maintains itself rather than a list to keep by hand.
18const (
19 earningsURL = "https://api.nasdaq.com/api/calendar/earnings?date="
20 earningsCapsURL = "https://api.nasdaq.com/api/screener/stocks?tableonly=true&limit=500&offset=0"
21 earningsEvery = 6 * time.Hour
22 earningsDailyURL = "https://query1.finance.yahoo.com/v7/finance/spark"
23
24 // How far down the index the panel goes. The hundredth name is worth about
25 // $120B at the moment, and everything under it reports into a market that is
26 // not watching.
27 earningsRank = 100
28
29 // Three either side, since that is what the panel has room for once every
30 // row carries a result.
31 earningsShown = 3
32
33 // How far to walk for those three. Reporting season is four bursts a year,
34 // so off season the forward walk runs its whole length and finds nothing,
35 // which is the correct answer rather than a failure.
36 earningsBackDays = 21
37 earningsNextDays = 28
38
39 // A surprise inside this band is the estimate being met, not a beat. Penny
40 // estimates make the percentage meaningless either way, which is why the
41 // row shows the two figures next to the word.
42 earningsMetBand = 2.0
43
44 // A move this size is the market having an opinion. Under it a beat and a
45 // red close is noise rather than a story about the outlook.
46 earningsMoveBand = 1.5
47)
48
49// Earning is one row. A reported row and an upcoming row are the same shape
50// with different halves filled in, so the template has one thing to render.
51type Earning struct {
52 Symbol string `json:"symbol"`
53 Name string `json:"name"`
54 Day string `json:"day"`
55 When string `json:"when"`
56
57 // Upcoming: what the street is looking for, and how many analysts said so.
58 Est string `json:"est"`
59 Ests string `json:"ests"`
60
61 // Reported: the call, the two figures behind it, and what the stock did.
62 Verdict string `json:"verdict"`
63 Actual string `json:"actual"`
64 Forecast string `json:"forecast"`
65 Move string `json:"move"`
66 MovePct float64 `json:"move_pct"`
67 Dir string `json:"dir"`
68
69 // Set when the verdict and the move disagree. No free source publishes
70 // guidance, so this is as close as the panel gets to saying why a company
71 // that beat still closed down.
72 Note string `json:"note"`
73}
74
75// Earnings is the panel: what has printed and what is coming.
76type Earnings struct {
77 Reported []Earning `json:"reported"`
78 Upcoming []Earning `json:"upcoming"`
79}
80
81func (e Earnings) empty() bool { return len(e.Reported) == 0 && len(e.Upcoming) == 0 }
82
83type nasdaqEarnings struct {
84 Data struct {
85 Rows []struct {
86 Symbol string `json:"symbol"`
87 Name string `json:"name"`
88 MarketCap string `json:"marketCap"`
89 Time string `json:"time"`
90 EPS string `json:"eps"`
91 EPSForecast string `json:"epsForecast"`
92 Surprise string `json:"surprise"`
93 NoOfEsts string `json:"noOfEsts"`
94 } `json:"rows"`
95 } `json:"data"`
96}
97
98type nasdaqScreener struct {
99 Data struct {
100 Table struct {
101 Rows []struct {
102 Symbol string `json:"symbol"`
103 MarketCap string `json:"marketCap"`
104 } `json:"rows"`
105 } `json:"table"`
106 } `json:"data"`
107}
108
109func fetchEarnings(ctx context.Context, g *Guard, now time.Time) (Earnings, error) {
110 caps, err := fetchIndexCaps(ctx, g)
111 if err != nil {
112 return Earnings{}, err
113 }
114
115 var out Earnings
116 today := now.In(easternTime())
117
118 // Backwards from today, because a company that reported before the bell this
119 // morning belongs under what has printed and one reporting tonight does not.
120 // Nasdaq stops supplying the time of day once a date is in the past, so the
121 // presence of an actual EPS is what sorts a row into one half or the other.
122 for i := 0; i < earningsBackDays && len(out.Reported) < earningsShown; i++ {
123 day := today.AddDate(0, 0, -i)
124 rows, ok := earningsDay(ctx, g, day, today, caps)
125 if !ok {
126 continue
127 }
128 for _, r := range rows {
129 if r.Verdict == "" {
130 continue
131 }
132 out.Reported = append(out.Reported, r)
133 if len(out.Reported) == earningsShown {
134 break
135 }
136 }
137 }
138
139 for i := 1; i <= earningsNextDays && len(out.Upcoming) < earningsShown; i++ {
140 day := today.AddDate(0, 0, i)
141 rows, ok := earningsDay(ctx, g, day, today, caps)
142 if !ok {
143 continue
144 }
145 for _, r := range rows {
146 if r.Verdict != "" {
147 continue
148 }
149 out.Upcoming = append(out.Upcoming, r)
150 if len(out.Upcoming) == earningsShown {
151 break
152 }
153 }
154 }
155
156 // Today's pre-market names land in Reported ahead of yesterday's, and the
157 // walk visits days newest first, so the halves are already in the order they
158 // read in. The reactions are the one thing that needs a second upstream.
159 addReactions(ctx, g, out.Reported, today)
160
161 if out.empty() {
162 return Earnings{}, fmt.Errorf("nasdaq: no top %d name reports in the window", earningsRank)
163 }
164 return out, nil
165}
166
167// earningsDay is one calendar day filtered to the index names, largest first.
168// The false return is a day that could not be fetched, which costs its own rows
169// rather than the panel.
170func earningsDay(ctx context.Context, g *Guard, day, today time.Time, caps map[string]float64) ([]Earning, bool) {
171 if wd := day.Weekday(); wd == time.Saturday || wd == time.Sunday {
172 return nil, false
173 }
174
175 var payload nasdaqEarnings
176 if err := getJSONWith(ctx, g, "nasdaq", earningsURL+day.Format("2006-01-02"), &payload); err != nil {
177 return nil, false
178 }
179
180 label := dayLabel(day, today)
181 var out []Earning
182 for _, r := range payload.Data.Rows {
183 symbol := strings.ToUpper(strings.TrimSpace(r.Symbol))
184 if _, ok := caps[symbol]; !ok {
185 continue
186 }
187
188 e := Earning{
189 Symbol: symbol,
190 Name: trimCompany(r.Name),
191 Day: label,
192 When: whenLabel(r.Time),
193 }
194
195 actual, hasActual := parseEPS(r.EPS)
196 forecast, hasForecast := parseEPS(r.EPSForecast)
197 switch {
198 case hasActual && hasForecast:
199 e.Verdict = epsVerdict(actual, forecast, r.Surprise)
200 e.Actual = money(actual)
201 e.Forecast = money(forecast)
202 case hasForecast:
203 e.Est = money(forecast)
204 e.Ests = strings.TrimSpace(r.NoOfEsts)
205 }
206
207 out = append(out, e)
208 }
209
210 sort.SliceStable(out, func(i, j int) bool { return caps[out[i].Symbol] > caps[out[j].Symbol] })
211 return out, true
212}
213
214// fetchIndexCaps is the top earningsRank of the S&P 500 by market cap. The
215// screener answers every US listing sorted by cap in one call, so the index
216// membership list is the only part of this that is written down.
217func fetchIndexCaps(ctx context.Context, g *Guard) (map[string]float64, error) {
218 var payload nasdaqScreener
219 if err := getJSONWith(ctx, g, "nasdaq", earningsCapsURL, &payload); err != nil {
220 return nil, err
221 }
222
223 caps := make(map[string]float64, earningsRank)
224 for _, r := range payload.Data.Table.Rows {
225 symbol := strings.ToUpper(strings.TrimSpace(r.Symbol))
226 if !inIndex(symbol) {
227 continue
228 }
229 if cap := parseMoney(r.MarketCap); cap > 0 {
230 caps[symbol] = cap
231 }
232 if len(caps) == earningsRank {
233 break
234 }
235 }
236
237 if len(caps) < earningsRank/2 {
238 return nil, fmt.Errorf("nasdaq screener: %d index names, expected %d", len(caps), earningsRank)
239 }
240 return caps, nil
241}
242
243// addReactions fills in what each stock did around its print, in place. One
244// batched request covers every reported row, and a failure leaves the rows
245// without a move rather than dropping them.
246func addReactions(ctx context.Context, g *Guard, rows []Earning, today time.Time) {
247 if len(rows) == 0 {
248 return
249 }
250
251 symbols := make([]string, 0, len(rows))
252 for _, r := range rows {
253 symbols = append(symbols, r.Symbol)
254 }
255
256 series, err := fetchDailyCloses(ctx, g, symbols)
257 if err != nil {
258 return
259 }
260
261 for i := range rows {
262 day, err := time.ParseInLocation("2006-01-02", reportDate(rows[i].Day, today), easternTime())
263 if err != nil {
264 continue
265 }
266 pct, ok := reaction(series[rows[i].Symbol], day)
267 if !ok {
268 continue
269 }
270 rows[i].MovePct = pct
271 rows[i].Move = signedPercent(pct)
272 // Rounded first, so a move that prints as +0.0% is not painted green
273 // for a rounding error.
274 rows[i].Dir = direction(math.Round(pct*10) / 10)
275 rows[i].Note = earningsNote(rows[i].Verdict, pct)
276 }
277}
278
279// dailyClose is one session, keyed by its New York date.
280type dailyClose struct {
281 date string
282 close float64
283}
284
285func fetchDailyCloses(ctx context.Context, g *Guard, symbols []string) (map[string][]dailyClose, error) {
286 q := url.Values{}
287 q.Set("symbols", strings.Join(symbols, ","))
288 // A month of daily bars, which always spans the walk back plus the session
289 // on either side of the oldest report in it.
290 q.Set("range", "1mo")
291 q.Set("interval", "1d")
292
293 var payload sparkPayload
294 if err := getJSON(ctx, g, "yahoo", earningsDailyURL+"?"+q.Encode(), &payload); err != nil {
295 return nil, err
296 }
297
298 out := make(map[string][]dailyClose, len(symbols))
299 for _, r := range payload.Spark.Result {
300 if len(r.Response) == 0 || len(r.Response[0].Indicators.Quote) == 0 {
301 continue
302 }
303 s := r.Response[0]
304 var days []dailyClose
305 for i, c := range s.Indicators.Quote[0].Close {
306 if c == nil || math.IsNaN(*c) || i >= len(s.Timestamp) {
307 continue
308 }
309 at := time.Unix(s.Timestamp[i], 0).In(easternTime())
310 days = append(days, dailyClose{date: at.Format("2006-01-02"), close: *c})
311 }
312 out[strings.ToUpper(r.Symbol)] = days
313 }
314 return out, nil
315}
316
317// reaction is what the stock did on the announcement, as a percent.
318//
319// Nasdaq drops the pre-market or after-hours flag once a date is in the past, so
320// which of the two sessions around the report carried it is not knowable from
321// the calendar. The one that moved is the one that heard the news, and when
322// neither did it does not matter which gets picked.
323func reaction(days []dailyClose, report time.Time) (float64, bool) {
324 stamp := report.Format("2006-01-02")
325
326 var before, on, after float64
327 for _, d := range days {
328 switch {
329 case d.date < stamp:
330 before = d.close
331 case d.date == stamp:
332 on = d.close
333 case after == 0:
334 after = d.close
335 }
336 }
337
338 // Reported this morning before the bell, so the session it moved is still
339 // open and today's close is the last one there is.
340 if after == 0 {
341 if before == 0 || on == 0 {
342 return 0, false
343 }
344 return (on/before - 1) * 100, true
345 }
346 if before == 0 || on == 0 {
347 return 0, false
348 }
349
350 pre := (on/before - 1) * 100
351 post := (after/on - 1) * 100
352 if math.Abs(pre) > math.Abs(post) {
353 return pre, true
354 }
355 return post, true
356}
357
358// earningsNote names the disagreement between the result and the reaction, which is the
359// only honest thing this panel can say about guidance without a paid feed.
360func earningsNote(verdict string, move float64) string {
361 switch {
362 case verdict == "BEAT" && move <= -earningsMoveBand:
363 return "SOLD THE BEAT"
364 case verdict == "MISS" && move >= earningsMoveBand:
365 return "BOUGHT THE MISS"
366 }
367 return ""
368}
369
370func epsVerdict(actual, forecast float64, surprise string) string {
371 pct, err := strconv.ParseFloat(strings.TrimSpace(surprise), 64)
372 if err != nil {
373 // No surprise figure, which happens when last year had no estimate. The
374 // two numbers are still there to compare.
375 if forecast == 0 {
376 return "MET"
377 }
378 pct = (actual - forecast) / math.Abs(forecast) * 100
379 }
380 switch {
381 case pct > earningsMetBand:
382 return "BEAT"
383 case pct < -earningsMetBand:
384 return "MISS"
385 }
386 return "MET"
387}
388
389// reportDate turns a row's display label back into the day it happened, since
390// the label is what the walk kept. Anything it cannot read is a day too far back
391// for a reaction to be interesting anyway.
392func reportDate(label string, today time.Time) string {
393 switch label {
394 case "TODAY":
395 return today.Format("2006-01-02")
396 case "YESTERDAY":
397 return today.AddDate(0, 0, -1).Format("2006-01-02")
398 case "TOMORROW":
399 return today.AddDate(0, 0, 1).Format("2006-01-02")
400 }
401 for i := -earningsBackDays; i <= earningsNextDays; i++ {
402 day := today.AddDate(0, 0, i)
403 if dayLabel(day, today) == label {
404 return day.Format("2006-01-02")
405 }
406 }
407 return ""
408}
409
410// getJSONWith is getJSON with the one header Nasdaq's API insists on. Without
411// an Accept of application/json it answers with an HTML challenge page.
412func getJSONWith(ctx context.Context, g *Guard, endpoint, url string, out any) error {
413 return getJSONHeaders(ctx, g, endpoint, url, map[string]string{
414 "Accept": "application/json",
415 }, out)
416}
417
418// parseEPS reads Nasdaq's EPS strings, which wrap a loss in parentheses the way
419// an accountant would. An empty value is a quarter that has not been reported.
420func parseEPS(s string) (float64, bool) {
421 s = strings.TrimSpace(s)
422 if s == "" || s == "N/A" {
423 return 0, false
424 }
425 negative := strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")")
426 if negative {
427 s = strings.TrimSuffix(strings.TrimPrefix(s, "("), ")")
428 }
429 v, err := strconv.ParseFloat(strings.NewReplacer("$", "", ",", "", " ", "").Replace(s), 64)
430 if err != nil {
431 return 0, false
432 }
433 if negative {
434 v = -v
435 }
436 return v, true
437}
438
439func money(v float64) string {
440 if v < 0 {
441 return fmt.Sprintf("-$%.2f", -v)
442 }
443 return fmt.Sprintf("$%.2f", v)
444}
445
446func signedPercent(v float64) string {
447 if v > 0 {
448 return fmt.Sprintf("+%.1f%%", v)
449 }
450 return fmt.Sprintf("%.1f%%", v)
451}
452
453func parseMoney(s string) float64 {
454 s = strings.NewReplacer("$", "", ",", "", " ", "").Replace(strings.TrimSpace(s))
455 if s == "" || s == "N/A" {
456 return 0
457 }
458 v, err := strconv.ParseFloat(s, 64)
459 if err != nil {
460 return 0
461 }
462 return v
463}
464
465// trimCompany drops the suffixes that make every row the same width and say
466// nothing, since the ticker is already there.
467func trimCompany(name string) string {
468 name = strings.TrimSpace(name)
469 for _, suffix := range []string{
470 ", Inc.", " Inc.", " Inc", ", Ltd.", " Ltd.", " Ltd",
471 " Corporation", " Corp.", " Corp", " Company", " Co.",
472 " Holdings", " plc", " PLC", " S.A.", " N.V.",
473 } {
474 name = strings.TrimSuffix(name, suffix)
475 }
476 return strings.TrimSpace(strings.TrimSuffix(name, ","))
477}
478
479func whenLabel(t string) string {
480 switch {
481 case strings.Contains(t, "pre-market"):
482 return "PRE"
483 case strings.Contains(t, "after-hours"):
484 return "POST"
485 default:
486 return ""
487 }
488}
489
490func dayLabel(day, today time.Time) string {
491 switch days := int(day.Truncate(24*time.Hour).Sub(today.Truncate(24*time.Hour)).Hours() / 24); days {
492 case 0:
493 return "TODAY"
494 case -1:
495 return "YESTERDAY"
496 case 1:
497 return "TOMORROW"
498 default:
499 return strings.ToUpper(day.Format("Mon 2 Jan"))
500 }
501}