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

22.9 KB · 736 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"fmt"
  6	"math"
  7	"net/url"
  8	"strings"
  9	"time"
 10
 11	// The runtime image is FROM scratch, so there is no /usr/share/zoneinfo in
 12	// it and LoadLocation("America/New_York") would fail there and nowhere
 13	// else. This embeds the database in the binary instead.
 14	_ "time/tzdata"
 15)
 16
 17// Yahoo's spark endpoint is the one that still answers without a session.
 18// v7/finance/quote returns 401 Unauthorized to anything that has not carried a
 19// cookie and crumb through their handshake, and v8/finance/chart works but is
 20// one request per symbol. spark takes the whole list in a single call and hands
 21// back the same meta block plus the intraday closes, which is every number on
 22// this page for the cost of one request.
 23const sparkURL = "https://query1.finance.yahoo.com/v7/finance/spark"
 24
 25// Instrument is one card. Cash shows during the US equity session and Future
 26// replaces it outside one, which is what Isaac asked for and what Yahoo itself
 27// does on its front page.
 28type Instrument struct {
 29	Key         string
 30	Label       string
 31	Cash        string
 32	Future      string
 33	FutureLabel string
 34	Decimals    int
 35}
 36
 37// The eight cards, in display order.
 38//
 39// Gold, crude and bitcoin have no Future because Cash already is the nearly
 40// round the clock contract, so there is nothing to swap them to. The VIX has no
 41// tradeable overnight form on Yahoo, so it simply goes stale after the close
 42// and says so rather than pretending.
 43//
 44// ^IXIC is the Nasdaq Composite and NQ=F is the Nasdaq 100. Different baskets,
 45// so the two halves of that card will not agree to the basis point across a
 46// session boundary. Yahoo pairs them the same way, and "Nasdaq" means the
 47// Composite to most people, so the futures label names the index it actually is.
 48var instruments = []Instrument{
 49	{Key: "sp500", Label: "S&P 500", Cash: "^GSPC", Future: "ES=F", FutureLabel: "S&P 500 futures", Decimals: 2},
 50	{Key: "dow", Label: "Dow 30", Cash: "^DJI", Future: "YM=F", FutureLabel: "Dow futures", Decimals: 2},
 51	{Key: "nasdaq", Label: "Nasdaq", Cash: "^IXIC", Future: "NQ=F", FutureLabel: "Nasdaq 100 futures", Decimals: 2},
 52	{Key: "russell", Label: "Russell 2000", Cash: "^RUT", Future: "RTY=F", FutureLabel: "Russell 2000 futures", Decimals: 2},
 53	{Key: "vix", Label: "VIX", Cash: "^VIX", Decimals: 2},
 54	{Key: "gold", Label: "Gold", Cash: "GC=F", Decimals: 2},
 55	{Key: "oil", Label: "Crude oil", Cash: "CL=F", Decimals: 2},
 56	{Key: "bitcoin", Label: "Bitcoin", Cash: "BTC-USD", Decimals: 0},
 57}
 58
 59// sparkSymbols is every symbol the poll asks for, cash and futures together.
 60// Both halves are fetched on every tick regardless of session so a card can
 61// swap the instant the clock crosses without waiting for the next poll.
 62func sparkSymbols() []string {
 63	var out []string
 64	for _, in := range instruments {
 65		out = append(out, in.Cash)
 66		if in.Future != "" {
 67			out = append(out, in.Future)
 68		}
 69	}
 70	return out
 71}
 72
 73// roundClock is a symbol that keeps printing outside the New York session: the
 74// four index futures, gold, crude and bitcoin. They need a wider fetch and a
 75// previous close worked out from the bars, because Yahoo dates their day by the
 76// contract or by UTC and never by the exchange floor. A leading caret is every
 77// cash index and only cash indexes, and the sector ETFs are on the board poll
 78// which does not go through here.
 79func roundClock(symbol string) bool {
 80	return !strings.HasPrefix(symbol, "^")
 81}
 82
 83// splitStrip separates the strip into the two fetches it takes. A cash index is
 84// quoted on exchange hours and Yahoo's own previous close is already the 4pm
 85// one, so those keep the single day request they have always had.
 86func splitStrip() (session, extended []string) {
 87	for _, s := range sparkSymbols() {
 88		if roundClock(s) {
 89			extended = append(extended, s)
 90		} else {
 91			session = append(session, s)
 92		}
 93	}
 94	return session, extended
 95}
 96
 97type tradingWindow struct {
 98	Start int64 `json:"start"`
 99	End   int64 `json:"end"`
100}
101
102type sparkMeta struct {
103	Symbol             string  `json:"symbol"`
104	Currency           string  `json:"currency"`
105	ShortName          string  `json:"shortName"`
106	RegularMarketPrice float64 `json:"regularMarketPrice"`
107	ChartPreviousClose float64 `json:"chartPreviousClose"`
108	PreviousClose      float64 `json:"previousClose"`
109	RegularMarketTime  int64   `json:"regularMarketTime"`
110	FiftyTwoWeekHigh   float64 `json:"fiftyTwoWeekHigh"`
111	FiftyTwoWeekLow    float64 `json:"fiftyTwoWeekLow"`
112}
113
114type sparkSeries struct {
115	Meta       sparkMeta `json:"meta"`
116	Timestamp  []int64   `json:"timestamp"`
117	Indicators struct {
118		Quote []struct {
119			// Pointers because Yahoo writes a literal null for a bar with no
120			// trade in it, and a plain float64 would silently read those as
121			// zero and drop the sparkline to the axis.
122			Close []*float64 `json:"close"`
123		} `json:"quote"`
124	} `json:"indicators"`
125}
126
127type sparkPayload struct {
128	Spark struct {
129		Result []struct {
130			Symbol   string        `json:"symbol"`
131			Response []sparkSeries `json:"response"`
132		} `json:"result"`
133	} `json:"spark"`
134}
135
136// Quote is one symbol reduced to what a card needs.
137type Quote struct {
138	Symbol   string
139	Name     string
140	Price    float64
141	Previous float64
142	AsOf     time.Time
143	High52   float64
144	Low52    float64
145	Closes   []float64
146
147	// Unix seconds for each close, so the sparkline can place a bar at the time
148	// it printed rather than at its position in the list.
149	Times []int64
150}
151
152func (q Quote) change() float64 {
153	if q.Previous == 0 {
154		return 0
155	}
156	return q.Price - q.Previous
157}
158
159func (q Quote) percent() float64 {
160	if q.Previous == 0 {
161		return 0
162	}
163	return (q.Price - q.Previous) / q.Previous * 100
164}
165
166// sparkBatch is how many symbols one spark request may carry. The endpoint
167// answers 400 rather than truncating past its limit, which is how a working
168// twelve symbol poll broke the moment rates and sectors took it to twenty seven.
169const sparkBatch = 10
170
171// fetchQuotes asks for every symbol the page needs, in as few requests as the
172// endpoint allows. The guard paces the batches, so a poll takes a few seconds
173// of wall clock and no more requests than the budget expects.
174func fetchQuotes(ctx context.Context, g *Guard, symbols []string, rng string) (map[string]Quote, error) {
175	out := make(map[string]Quote, len(symbols))
176
177	var firstErr error
178	for start := 0; start < len(symbols); start += sparkBatch {
179		end := min(start+sparkBatch, len(symbols))
180
181		batch, err := fetchQuoteBatch(ctx, g, symbols[start:end], rng)
182		if err != nil {
183			// One failed batch costs its own symbols and not the whole page,
184			// and the cards it would have filled keep their previous values.
185			if firstErr == nil {
186				firstErr = err
187			}
188			continue
189		}
190		for k, v := range batch {
191			out[k] = v
192		}
193	}
194
195	if len(out) == 0 {
196		if firstErr != nil {
197			return nil, firstErr
198		}
199		return nil, fmt.Errorf("yahoo: no series in response")
200	}
201	return out, nil
202}
203
204// The cash indexes only ever need the day they are in. Everything else needs
205// enough history to find 4pm yesterday for itself, and over a weekend that is
206// three days back, so five days is the smallest range that always holds it.
207const (
208	sessionRange  = "1d"
209	extendedRange = "5d"
210)
211
212// fetchStrip is the market poll. It goes out as two requests because the range
213// is per request and the two halves of the strip need different ones, which is
214// the same number of batches the one range fetch took.
215func fetchStrip(ctx context.Context, g *Guard) (map[string]Quote, error) {
216	session, extended := splitStrip()
217
218	out, firstErr := fetchQuotes(ctx, g, session, sessionRange)
219	if out == nil {
220		out = make(map[string]Quote, len(session)+len(extended))
221	}
222
223	rest, err := fetchQuotes(ctx, g, extended, extendedRange)
224	if err != nil && firstErr == nil {
225		firstErr = err
226	}
227	for k, v := range rest {
228		out[k] = v
229	}
230
231	if len(out) == 0 {
232		return nil, firstErr
233	}
234	return out, nil
235}
236
237func fetchQuoteBatch(ctx context.Context, g *Guard, symbols []string, rng string) (map[string]Quote, error) {
238	q := url.Values{}
239	q.Set("symbols", strings.Join(symbols, ","))
240	q.Set("range", rng)
241	q.Set("interval", "5m")
242	// Extended hours bars, so a card built from a cash index still draws the
243	// pre-market and after-hours tail instead of stopping at the bell.
244	q.Set("includePrePost", "true")
245
246	var payload sparkPayload
247	if err := getJSON(ctx, g, "yahoo", sparkURL+"?"+q.Encode(), &payload); err != nil {
248		return nil, err
249	}
250
251	out := make(map[string]Quote, len(symbols))
252	for _, r := range payload.Spark.Result {
253		if len(r.Response) == 0 {
254			continue
255		}
256		s := r.Response[0]
257		m := s.Meta
258
259		prev := m.ChartPreviousClose
260		if prev == 0 {
261			prev = m.PreviousClose
262		}
263
264		var closes []float64
265		var times []int64
266		if len(s.Indicators.Quote) > 0 {
267			for i, c := range s.Indicators.Quote[0].Close {
268				if c == nil || math.IsNaN(*c) {
269					continue
270				}
271				closes = append(closes, *c)
272				// A null close drops its bar, so the two slices have to be
273				// filled together or every point after the first gap is drawn
274				// at the wrong time.
275				if i < len(s.Timestamp) {
276					times = append(times, s.Timestamp[i])
277				}
278			}
279		}
280
281		out[r.Symbol] = Quote{
282			Symbol:   r.Symbol,
283			Name:     m.ShortName,
284			Price:    m.RegularMarketPrice,
285			Previous: prev,
286			AsOf:     time.Unix(m.RegularMarketTime, 0),
287			High52:   m.FiftyTwoWeekHigh,
288			Low52:    m.FiftyTwoWeekLow,
289			Closes:   closes,
290			Times:    times,
291		}
292	}
293	return out, nil
294}
295
296// Card is one instrument as the page renders it.
297type Card struct {
298	Key         string `json:"key"`
299	Label       string `json:"label"`
300	Symbol      string `json:"symbol"`
301	Price       string `json:"price"`
302	Change      string `json:"change"`
303	Percent     string `json:"percent"`
304	Direction   string `json:"direction"`
305	Note        string `json:"note"`
306	Spark       Spark  `json:"spark"`
307	Unavailable bool   `json:"unavailable"`
308
309	// Unexported, so it stays out of the JSON the page is patched from. It is
310	// only here so one poll can tell whether the next one is the same trading
311	// day. See carrySparks.
312	asOf time.Time
313}
314
315// Market is the whole panel.
316type Market struct {
317	Cards       []Card  `json:"cards"`
318	Session     string  `json:"session"`
319	Phase       string  `json:"phase"`
320	Drawdown    string  `json:"drawdown"`
321	DrawdownPct float64 `json:"drawdown_pct"`
322	Updated     string  `json:"updated"`
323	Stale       bool    `json:"stale"`
324
325	// The one figure the browser tab carries, so a backgrounded tab still says
326	// what the market is doing. The S&P while the session is open and bitcoin
327	// once it shuts, since bitcoin is the one on this page that never stops.
328	Ticker string `json:"ticker"`
329}
330
331// stripRow is one card between picking its symbol and drawing it, which takes two
332// passes because the window every card shares cannot be known until each has
333// worked out its own.
334type stripRow struct {
335	in      Instrument
336	symbol  string
337	note    string
338	quote   Quote
339	closes  []float64
340	times   []int64
341	axis    tradingAxis
342	missing bool
343}
344
345// axisOr takes the strip's shared window when this card belongs to the same
346// session, and its own otherwise. A VIX that stopped at Friday's bell has no
347// business being stretched over a window that runs to Monday night.
348func (r stripRow) axisOr(strip tradingAxis) tradingAxis {
349	if strip.ok && r.axis.ok && r.axis.start == strip.start {
350		return strip
351	}
352	return r.axis
353}
354
355// resolveRows picks each card's symbol and puts its bars on the New York trading
356// day, so the eight of them start their line at the same 9:30 and measure it
357// from the same 4pm. The previous close is read before the bars are trimmed,
358// since it is the print before the open they are trimmed to.
359func resolveRows(quotes map[string]Quote, useFutures bool) []stripRow {
360	rows := make([]stripRow, 0, len(instruments))
361
362	for _, in := range instruments {
363		r := stripRow{in: in, symbol: in.Cash}
364		if useFutures && in.Future != "" {
365			r.symbol, r.note = in.Future, in.FutureLabel
366		}
367
368		q, ok := quotes[r.symbol]
369		if !ok || q.Price == 0 {
370			r.missing = true
371			rows = append(rows, r)
372			continue
373		}
374
375		// Anything with no overnight form freezes at the close, so say when the
376		// number is from rather than showing a stale figure as a live one.
377		if useFutures && in.Future == "" && in.Key == "vix" {
378			r.note = "as of " + q.AsOf.In(easternTime()).Format("Mon 3:04pm")
379		}
380
381		axis := sessionAxis(q.Times)
382		if axis.ok && roundClock(r.symbol) {
383			if prev, found := previousSessionClose(q.Closes, q.Times, time.Unix(axis.start, 0)); found {
384				q.Previous = prev
385			}
386		}
387
388		r.quote = q
389		r.closes, r.times, r.axis = sessionBars(q.Closes, q.Times, axis)
390		rows = append(rows, r)
391	}
392	return rows
393}
394
395// stripAxis is the one window the strip is read across: the latest open any card
396// reached, and the latest bar printed against it. Without a shared end the right
397// edge of a card that stopped at 4pm is a different hour than the right edge of
398// the one beside it that is still trading, and reading the eight together is the
399// only reason they sit in a row.
400func stripAxis(rows []stripRow) tradingAxis {
401	var strip tradingAxis
402	for _, r := range rows {
403		if r.axis.ok && r.axis.start > strip.start {
404			strip = tradingAxis{start: r.axis.start, ok: true}
405		}
406	}
407	if !strip.ok {
408		return strip
409	}
410	for _, r := range rows {
411		if r.axis.ok && r.axis.start == strip.start && r.axis.end > strip.end {
412			strip.end = r.axis.end
413		}
414	}
415	return strip
416}
417
418// stripLive is the most recent bar anywhere on the strip, which is the edge a
419// card has to reach to count as still trading. The axis end cannot stand in for
420// it, since through the session that is the scheduled 16:00 and every card is
421// short of it without any of them having stopped.
422func stripLive(rows []stripRow) int64 {
423	var live int64
424	for _, r := range rows {
425		if n := len(r.times); n > 0 && r.times[n-1] > live {
426			live = r.times[n-1]
427		}
428	}
429	return live
430}
431
432// Bars are five minutes apart and the symbols do not all print on the same tick,
433// so a card counts as shut only once it is further behind than that spread. Half
434// an hour clears the ten minutes the futures usually trail bitcoin by.
435const shutAfter = 30 * time.Minute
436
437func (r stripRow) shutBy(live int64) bool {
438	n := len(r.times)
439	if live == 0 || n == 0 {
440		return false
441	}
442	return live-r.times[n-1] > int64(shutAfter/time.Second)
443}
444
445// buildMarket turns a quote map into the panel. now is a parameter so the
446// session boundaries are testable without waiting for one.
447func buildMarket(quotes map[string]Quote, now time.Time) Market {
448	session, phase := equitySession(now)
449
450	// A cash index that has not printed in half an hour during what the clock
451	// calls regular hours means the clock is wrong, and the cheapest way that
452	// happens is a market holiday. There is no holiday calendar here, on
453	// purpose, so the quote's own age stands in for one.
454	if session == "regular" {
455		// A zero AsOf means Yahoo sent no regularMarketTime, which reads as
456		// 1970 and would make every session look like a holiday.
457		if q, ok := quotes["^GSPC"]; ok && !q.AsOf.IsZero() && now.Sub(q.AsOf) > 30*time.Minute {
458			session, phase = "closed", "Holiday or halted"
459		}
460	}
461
462	useFutures := session != "regular"
463
464	rows := resolveRows(quotes, useFutures)
465	axis := stripAxis(rows)
466	live := stripLive(rows)
467
468	m := Market{Session: session, Phase: phase}
469	for _, r := range rows {
470		if r.missing {
471			m.Cards = append(m.Cards, Card{
472				Key: r.in.Key, Label: r.in.Label, Symbol: r.symbol, Unavailable: true,
473			})
474			continue
475		}
476
477		q := r.quote
478		spark := buildSpark(r.closes, r.times, q.Previous, r.axisOr(axis))
479		spark.Closed = r.shutBy(live) && spark.Span < sparkWidth
480
481		change, pct := q.change(), q.percent()
482		m.Cards = append(m.Cards, Card{
483			Key:       r.in.Key,
484			Label:     r.in.Label,
485			Symbol:    r.symbol,
486			Price:     formatNumber(q.Price, r.in.Decimals),
487			Change:    signed(change, r.in.Decimals),
488			Percent:   signed(pct, 2) + "%",
489			Direction: direction(change),
490			Note:      r.note,
491			Spark:     spark,
492			asOf:      q.AsOf,
493		})
494	}
495
496	m.Ticker = tabTicker(m)
497
498	// Drawdown from the 52 week high, which is the number Isaac acts on. It is
499	// the 52 week high and not the all time high because that is what the same
500	// payload already carries, and the label says so.
501	if q, ok := quotes["^GSPC"]; ok && q.High52 > 0 {
502		m.DrawdownPct = (q.Price - q.High52) / q.High52 * 100
503		m.Drawdown = fmt.Sprintf("%.1f%%", m.DrawdownPct)
504	}
505
506	return m
507}
508
509// A poll that comes back with almost no points to draw is the upstream having a
510// moment, not the market: Yahoo's spark endpoint will return two closes for a
511// symbol that had 287 a minute earlier. A straight line between two points is
512// worse than the real shape a minute late, so the old one is carried forward.
513// The price and the change on the card are always the fresh ones.
514const sparkFloor = 5
515
516func carrySparks(next, prev Market) Market {
517	previous := make(map[string]Card, len(prev.Cards))
518	for _, c := range prev.Cards {
519		previous[c.Key] = c
520	}
521
522	for i, c := range next.Cards {
523		old, ok := previous[c.Key]
524		switch {
525		case !ok, c.Unavailable, old.Unavailable:
526			continue
527		case c.Spark.Points >= sparkFloor:
528			continue
529		case old.Spark.Points <= c.Spark.Points:
530			continue
531		// A different symbol is a different instrument, and a different day is
532		// a different session, so neither shape belongs on this card.
533		case old.Symbol != c.Symbol, !sameTradingDay(old.asOf, c.asOf):
534			continue
535		}
536		next.Cards[i].Spark = old.Spark
537	}
538	return next
539}
540
541// The day here is the trading day and not the calendar one, or every card would
542// look like it had rolled over at midnight while the session ran on until 9:30.
543func sameTradingDay(a, b time.Time) bool {
544	if a.IsZero() || b.IsZero() {
545		return false
546	}
547	return sessionStart(a).Equal(sessionStart(b))
548}
549
550func direction(change float64) string {
551	switch {
552	case change > 0:
553		return "up"
554	case change < 0:
555		return "down"
556	default:
557		return "flat"
558	}
559}
560
561var eastern *time.Location
562
563func easternTime() *time.Location {
564	if eastern == nil {
565		loc, err := time.LoadLocation("America/New_York")
566		if err != nil {
567			// Cannot happen with time/tzdata linked in, and UTC is a wrong
568			// clock rather than a crashed process if it somehow does.
569			loc = time.UTC
570		}
571		eastern = loc
572	}
573	return eastern
574}
575
576// equitySession is the US equity clock: pre from 4:00, regular from 9:30 to
577// 16:00, post until 20:00, all New York time, weekends closed.
578//
579// There is no exchange holiday calendar, which is the same call finance made:
580// a hardcoded list goes stale silently and a fetched one is another endpoint to
581// guard. buildMarket catches the holiday case from the quote's own age instead.
582func equitySession(now time.Time) (session, phase string) {
583	t := now.In(easternTime())
584
585	if wd := t.Weekday(); wd == time.Saturday || wd == time.Sunday {
586		return "closed", "Weekend"
587	}
588
589	mins := t.Hour()*60 + t.Minute()
590	const (
591		preOpen     = 4 * 60
592		regularOpen = 9*60 + 30
593		regularShut = 16 * 60
594		postShut    = 20 * 60
595	)
596
597	switch {
598	case mins < preOpen:
599		return "closed", "Opens " + until(mins, preOpen)
600	case mins < regularOpen:
601		return "pre", "Pre-market, opens " + until(mins, regularOpen)
602	case mins < regularShut:
603		return "regular", "Open, closes " + until(mins, regularShut)
604	case mins < postShut:
605		return "post", "After hours, ends " + until(mins, postShut)
606	default:
607		return "closed", "Closed"
608	}
609}
610
611func until(from, to int) string {
612	d := to - from
613	if h := d / 60; h > 0 {
614		return fmt.Sprintf("in %dh %dm", h, d%60)
615	}
616	return fmt.Sprintf("in %dm", d)
617}
618
619// tradingAxis is the session a sparkline is drawn against, in unix seconds.
620type tradingAxis struct {
621	start, end int64
622	ok         bool
623}
624
625// The New York trading day, which every card on the strip is now drawn against.
626// The exchanges open at 9:30 and shut at 16:00, and a symbol that trades through
627// the night keeps printing past the close.
628const (
629	sessionOpenHour = 9
630	sessionOpenMin  = 30
631	regularHours    = 6*time.Hour + 30*time.Minute
632)
633
634// sessionStart is the 9:30 New York morning that t belongs to, so the trading
635// day runs from one open to the next rather than from midnight. Weekends get an
636// open like any other day, since gold and bitcoin do not take Saturday off and
637// the whole point is that every card resets at the same hour.
638func sessionStart(t time.Time) time.Time {
639	et := easternTime()
640	y, m, d := t.In(et).Date()
641	open := time.Date(y, m, d, sessionOpenHour, sessionOpenMin, 0, 0, et)
642	if t.Before(open) {
643		open = open.AddDate(0, 0, -1)
644	}
645	return open
646}
647
648// sessionAxis is the window a series is drawn against: the open it belongs to
649// through the close, stretched to hold whatever printed after the bell. The
650// anchor comes from the last bar rather than from the wall clock, so the VIX at
651// midnight still shows the session it actually traded instead of an empty box.
652func sessionAxis(times []int64) tradingAxis {
653	if len(times) == 0 {
654		return tradingAxis{}
655	}
656
657	last := times[len(times)-1]
658	open := sessionStart(time.Unix(last, 0))
659	axis := tradingAxis{start: open.Unix(), end: open.Add(regularHours).Unix(), ok: true}
660	if last > axis.end {
661		axis.end = last
662	}
663	return axis
664}
665
666// sessionBars drops everything before the open. They used to be clamped to the
667// edge instead, which stacked the VIX's 3:15am prints into a vertical smear
668// against the left of its card.
669//
670// A series with bars after the close and none inside it is a market that has
671// reopened before its own next open, which is futures between Sunday evening and
672// Monday morning. There is no session to lay those over, so they take the full
673// width the way they always did.
674func sessionBars(closes []float64, times []int64, axis tradingAxis) ([]float64, []int64, tradingAxis) {
675	if !axis.ok || len(times) != len(closes) {
676		return closes, times, tradingAxis{}
677	}
678
679	regularEnd := time.Unix(axis.start, 0).Add(regularHours).Unix()
680	var keptCloses []float64
681	var keptTimes []int64
682	var inRegular int
683	for i, t := range times {
684		if t < axis.start {
685			continue
686		}
687		if t <= regularEnd {
688			inRegular++
689		}
690		keptCloses = append(keptCloses, closes[i])
691		keptTimes = append(keptTimes, t)
692	}
693
694	if inRegular == 0 {
695		return closes, times, tradingAxis{}
696	}
697	return keptCloses, keptTimes, axis
698}
699
700// previousSessionClose is the last print at or before 4pm the day before the
701// open, which is what "yesterday's close" has to mean once every card is on one
702// clock. Yahoo dates bitcoin's day by UTC and a future's by its contract, so
703// their own previous close measures from a different moment than the S&P's and
704// the eight cards disagree about what day it is. Reading it off the bars puts
705// them all on the same 4pm.
706func previousSessionClose(closes []float64, times []int64, open time.Time) (float64, bool) {
707	cut := open.AddDate(0, 0, -1).Add(regularHours).Unix()
708
709	var prev float64
710	var found bool
711	for i, t := range times {
712		if t > cut {
713			break
714		}
715		if i < len(closes) {
716			prev, found = closes[i], true
717		}
718	}
719	return prev, found
720}
721
722// tabTicker picks the figure the browser tab leads with. Labels are short
723// because a tab truncates and this sits in front of the site's own name.
724func tabTicker(m Market) string {
725	key, label := "bitcoin", "BTC"
726	if m.Session == "regular" {
727		key, label = "sp500", "S&P"
728	}
729	for _, c := range m.Cards {
730		if c.Key == key && !c.Unavailable {
731			return label + " " + c.Percent
732		}
733	}
734	return ""
735}