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 "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}