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

14.3 KB · 502 lines · Go Raw History
  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}