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 "testing"
5 "time"
6)
7
8// at builds a New York wall clock time, which is what every session boundary
9// here is expressed in.
10func at(t *testing.T, s string) time.Time {
11 t.Helper()
12 ts, err := time.ParseInLocation("2006-01-02 15:04", s, easternTime())
13 if err != nil {
14 t.Fatalf("parsing %q: %v", s, err)
15 }
16 return ts
17}
18
19func TestEquitySession(t *testing.T) {
20 // 2026-08-27 is a Thursday and 2026-08-29 a Saturday.
21 cases := []struct {
22 when string
23 want string
24 }{
25 {"2026-08-27 03:59", "closed"},
26 {"2026-08-27 04:00", "pre"},
27 {"2026-08-27 09:29", "pre"},
28 {"2026-08-27 09:30", "regular"},
29 {"2026-08-27 15:59", "regular"},
30 {"2026-08-27 16:00", "post"},
31 {"2026-08-27 19:59", "post"},
32 {"2026-08-27 20:00", "closed"},
33 {"2026-08-29 12:00", "closed"},
34 {"2026-08-30 12:00", "closed"},
35 }
36
37 for _, c := range cases {
38 if got, _ := equitySession(at(t, c.when)); got != c.want {
39 t.Errorf("%s: session %q, want %q", c.when, got, c.want)
40 }
41 }
42}
43
44func TestBuildMarketSwapsToFuturesOutsideRegularHours(t *testing.T) {
45 session := at(t, "2026-08-27 10:00")
46 quotes := map[string]Quote{
47 "^GSPC": {Symbol: "^GSPC", Price: 100, Previous: 99, AsOf: session, Closes: []float64{99, 100}},
48 "ES=F": {Symbol: "ES=F", Price: 101, Previous: 99, AsOf: session, Closes: []float64{99, 101}},
49 }
50
51 regular := buildMarket(quotes, session)
52 if got := regular.Cards[0].Symbol; got != "^GSPC" {
53 t.Errorf("during the session the S&P card is %q, want ^GSPC", got)
54 }
55 if regular.Cards[0].Note != "" {
56 t.Errorf("cash card carries note %q, want none", regular.Cards[0].Note)
57 }
58
59 overnight := buildMarket(quotes, at(t, "2026-08-27 21:00"))
60 if got := overnight.Cards[0].Symbol; got != "ES=F" {
61 t.Errorf("after hours the S&P card is %q, want ES=F", got)
62 }
63 if overnight.Cards[0].Note == "" {
64 t.Error("a futures card has to say it is futures")
65 }
66}
67
68// A market holiday falls inside regular hours by the clock, and there is no
69// holiday calendar here, so the quote's own age is what catches it.
70func TestBuildMarketTreatsAStaleCashQuoteAsClosed(t *testing.T) {
71 now := at(t, "2026-08-27 14:00")
72
73 fresh := map[string]Quote{
74 "^GSPC": {Price: 100, Previous: 99, AsOf: now.Add(-2 * time.Minute)},
75 }
76 if got, _ := buildMarket(fresh, now).Session, ""; got != "regular" {
77 t.Errorf("a fresh quote at 2pm reads %q, want regular", got)
78 }
79
80 stale := map[string]Quote{
81 "^GSPC": {Price: 100, Previous: 99, AsOf: now.Add(-4 * time.Hour)},
82 }
83 m := buildMarket(stale, now)
84 if m.Session != "closed" {
85 t.Errorf("a four hour old quote at 2pm reads %q, want closed", m.Session)
86 }
87 if m.Cards[0].Symbol != "ES=F" {
88 t.Errorf("a closed session shows %q, want the future", m.Cards[0].Symbol)
89 }
90}
91
92func TestBuildMarketMarksAMissingSymbolUnavailable(t *testing.T) {
93 m := buildMarket(map[string]Quote{}, at(t, "2026-08-27 10:00"))
94 for _, c := range m.Cards {
95 if !c.Unavailable {
96 t.Errorf("%s should be unavailable with no quotes behind it", c.Key)
97 }
98 }
99}
100
101func TestBuildMarketDrawdownIsSignedAgainstTheHigh(t *testing.T) {
102 now := at(t, "2026-08-27 10:00")
103 quotes := map[string]Quote{
104 "^GSPC": {Price: 90, Previous: 89, High52: 100, AsOf: now},
105 }
106 m := buildMarket(quotes, now)
107 if m.DrawdownPct > -9.9 || m.DrawdownPct < -10.1 {
108 t.Errorf("drawdown %.2f, want about -10", m.DrawdownPct)
109 }
110}
111
112// Every symbol either half of a card can ask for has to be in the one request
113// the poller makes, or a session flip shows an unavailable card until the next
114// tick.
115func TestSparkSymbolsCoversBothHalvesOfEveryCard(t *testing.T) {
116 asked := map[string]bool{}
117 for _, s := range sparkSymbols() {
118 asked[s] = true
119 }
120 for _, in := range instruments {
121 if !asked[in.Cash] {
122 t.Errorf("%s: cash symbol %s is never fetched", in.Key, in.Cash)
123 }
124 if in.Future != "" && !asked[in.Future] {
125 t.Errorf("%s: future %s is never fetched", in.Key, in.Future)
126 }
127 }
128}
129
130func TestQuoteChangeIsZeroWithNoPreviousClose(t *testing.T) {
131 q := Quote{Price: 100}
132 if q.change() != 0 || q.percent() != 0 {
133 t.Errorf("with no previous close change is %v and percent %v, want zero for both", q.change(), q.percent())
134 }
135}
136
137// Yahoo's spark endpoint intermittently returns two closes for a symbol that
138// had hundreds a minute earlier, and a straight line between two points is not
139// a sparkline.
140func TestCarrySparksHoldsTheLastGoodShape(t *testing.T) {
141 day := at(t, "2026-08-27 14:00")
142
143 full := buildMarket(map[string]Quote{
144 "^GSPC": {Price: 100, Previous: 99, AsOf: day, Closes: []float64{99, 100, 101, 102, 103, 104}},
145 }, day)
146 if full.Cards[0].Spark.Points != 6 {
147 t.Fatalf("fixture drew %d points, want 6", full.Cards[0].Spark.Points)
148 }
149
150 degraded := buildMarket(map[string]Quote{
151 "^GSPC": {Price: 105, Previous: 99, AsOf: day.Add(time.Minute), Closes: []float64{104, 105}},
152 }, day)
153
154 got := carrySparks(degraded, full)
155 if got.Cards[0].Spark.Points != 6 {
156 t.Errorf("kept %d points, want the previous 6", got.Cards[0].Spark.Points)
157 }
158 // The figures are still the fresh ones; only the shape is held back.
159 if got.Cards[0].Price != "105.00" {
160 t.Errorf("price %q, want the fresh 105.00", got.Cards[0].Price)
161 }
162}
163
164func TestCarrySparksTakesAHealthyPoll(t *testing.T) {
165 day := at(t, "2026-08-27 14:00")
166
167 prev := buildMarket(map[string]Quote{
168 "^GSPC": {Price: 100, Previous: 99, AsOf: day, Closes: []float64{99, 100}},
169 }, day)
170 next := buildMarket(map[string]Quote{
171 "^GSPC": {Price: 101, Previous: 99, AsOf: day.Add(time.Minute), Closes: []float64{99, 100, 101, 102, 103}},
172 }, day)
173
174 if got := carrySparks(next, prev).Cards[0].Spark.Points; got != 5 {
175 t.Errorf("kept %d points, want the fresh 5", got)
176 }
177}
178
179// Yesterday's shape on today's card would be a chart of the wrong day, so the
180// carry only applies within one session.
181func TestCarrySparksStopsAtADayBoundary(t *testing.T) {
182 yesterday := at(t, "2026-08-26 14:00")
183 today := at(t, "2026-08-27 09:35")
184
185 prev := buildMarket(map[string]Quote{
186 "^GSPC": {Price: 100, Previous: 99, AsOf: yesterday, Closes: []float64{99, 100, 101, 102, 103, 104}},
187 }, yesterday)
188 next := buildMarket(map[string]Quote{
189 "^GSPC": {Price: 101, Previous: 100, AsOf: today, Closes: []float64{100, 101}},
190 }, today)
191
192 if got := carrySparks(next, prev).Cards[0].Spark.Points; got != 2 {
193 t.Errorf("kept %d points, want today's 2 rather than yesterday's shape", got)
194 }
195}
196
197// A card that swapped between cash and futures is a different instrument, so
198// the shape must not follow it across.
199func TestCarrySparksStopsAtASymbolChange(t *testing.T) {
200 day := at(t, "2026-08-27 15:55")
201 after := at(t, "2026-08-27 16:05")
202
203 prev := buildMarket(map[string]Quote{
204 "^GSPC": {Price: 100, Previous: 99, AsOf: day, Closes: []float64{99, 100, 101, 102, 103, 104}},
205 }, day)
206 next := buildMarket(map[string]Quote{
207 "ES=F": {Price: 101, Previous: 99, AsOf: after, Closes: []float64{100, 101}},
208 }, after)
209
210 if prev.Cards[0].Symbol == next.Cards[0].Symbol {
211 t.Fatalf("fixture did not swap: both cards are %s", prev.Cards[0].Symbol)
212 }
213 if got := carrySparks(next, prev).Cards[0].Spark.Points; got != 2 {
214 t.Errorf("kept %d points, want the futures card's own 2", got)
215 }
216}
217
218// The spark endpoint answers 400 rather than truncating past its symbol limit,
219// so every symbol the page needs has to arrive in a batch small enough to be
220// accepted.
221func TestSparkSymbolsBatchWithinTheLimit(t *testing.T) {
222 symbols := sparkSymbols()
223 if len(symbols) <= sparkBatch {
224 t.Skip("the whole list fits in one request, so batching is untested here")
225 }
226
227 var batched int
228 for start := 0; start < len(symbols); start += sparkBatch {
229 end := min(start+sparkBatch, len(symbols))
230 if size := end - start; size > sparkBatch {
231 t.Errorf("batch of %d exceeds the limit of %d", size, sparkBatch)
232 }
233 batched += end - start
234 }
235 if batched != len(symbols) {
236 t.Errorf("batching covered %d of %d symbols", batched, len(symbols))
237 }
238}
239
240// Rates and sectors have their own slower poll, so between the two lists every
241// symbol the page shows has to be fetched by something.
242func TestEverySymbolIsFetchedBySomePoll(t *testing.T) {
243 asked := map[string]bool{}
244 for _, s := range sparkSymbols() {
245 asked[s] = true
246 }
247 for _, s := range rateAndSectorSymbols() {
248 asked[s] = true
249 }
250
251 for _, r := range rates {
252 if !asked[r.Symbol] {
253 t.Errorf("rate %s is never fetched", r.Symbol)
254 }
255 }
256 for _, sec := range sectors {
257 if !asked[sec.Symbol] {
258 t.Errorf("sector %s is never fetched", sec.Symbol)
259 }
260 }
261 for _, in := range instruments {
262 if !asked[in.Cash] {
263 t.Errorf("%s cash symbol %s is never fetched", in.Key, in.Cash)
264 }
265 }
266}
267
268// The fast poll is the one that runs every thirty seconds, so what rides it is
269// the thing that decides how hard Yahoo gets hit. Rates and sectors do not
270// belong on it.
271func TestTheFastPollCarriesOnlyTheStrip(t *testing.T) {
272 fast := map[string]bool{}
273 for _, s := range sparkSymbols() {
274 fast[s] = true
275 }
276
277 for _, r := range rates {
278 if fast[r.Symbol] {
279 t.Errorf("rate %s rides the 30 second poll", r.Symbol)
280 }
281 }
282 for _, sec := range sectors {
283 if fast[sec.Symbol] {
284 t.Errorf("sector %s rides the 30 second poll", sec.Symbol)
285 }
286 }
287
288 // Two batches at thirty seconds is 240 requests an hour, and the budget
289 // has to leave room for the slower polls beside it.
290 session, extended := splitStrip()
291 batches := (len(session)+sparkBatch-1)/sparkBatch + (len(extended)+sparkBatch-1)/sparkBatch
292 if perHour := batches * 120; perHour > budgets["yahoo"].perHour/2 {
293 t.Errorf("the fast poll alone would spend %d of a %d budget", perHour, budgets["yahoo"].perHour)
294 }
295}
296
297// The strip goes out as two requests now, and each half still has to fit the
298// endpoint's symbol limit or it answers 400 rather than truncating.
299func TestSplitStripCoversTheWholeStripInAcceptableBatches(t *testing.T) {
300 session, extended := splitStrip()
301
302 seen := map[string]bool{}
303 for _, half := range [][]string{session, extended} {
304 if len(half) > sparkBatch {
305 t.Errorf("half of %d symbols needs batching past the limit of %d", len(half), sparkBatch)
306 }
307 for _, s := range half {
308 seen[s] = true
309 }
310 }
311
312 for _, s := range sparkSymbols() {
313 if !seen[s] {
314 t.Errorf("%s is in neither half of the strip fetch", s)
315 }
316 }
317 for _, s := range session {
318 if roundClock(s) {
319 t.Errorf("%s trades around the clock and needs the wider range", s)
320 }
321 }
322}
323
324// A cash index keeps Yahoo's own previous close, which is already the 4pm one
325// and is what every other site quotes. Only the symbols Yahoo dates by UTC or by
326// contract get the close read off their bars.
327func TestBuildMarketOnlyRecomputesThePreviousCloseForRoundTheClockSymbols(t *testing.T) {
328 et := easternTime()
329 open := time.Date(2026, 8, 31, 9, 30, 0, 0, et)
330 now := open.Add(2 * time.Hour)
331
332 series := func(from time.Time, n int, price float64) ([]float64, []int64) {
333 var closes []float64
334 var times []int64
335 for i := range n {
336 closes = append(closes, price)
337 times = append(times, from.Add(time.Duration(i)*30*time.Minute).Unix())
338 }
339 return closes, times
340 }
341
342 // A day of bars before the open at 50, then the session itself at 100.
343 oldCloses, oldTimes := series(open.AddDate(0, 0, -1), 20, 50)
344 newCloses, newTimes := series(open, 4, 100)
345
346 quotes := map[string]Quote{
347 "^GSPC": {
348 Symbol: "^GSPC", Price: 100, Previous: 80, AsOf: now,
349 Closes: append(oldCloses, newCloses...), Times: append(oldTimes, newTimes...),
350 },
351 "BTC-USD": {
352 Symbol: "BTC-USD", Price: 100, Previous: 80, AsOf: now,
353 Closes: append(oldCloses, newCloses...), Times: append(oldTimes, newTimes...),
354 },
355 }
356
357 cards := map[string]Card{}
358 for _, c := range buildMarket(quotes, now).Cards {
359 cards[c.Key] = c
360 }
361
362 if got := cards["sp500"].Percent; got != "+25.00%" {
363 t.Errorf("the S&P measured %s, want +25.00%% off Yahoo's own 80", got)
364 }
365 if got := cards["bitcoin"].Percent; got != "+100.00%" {
366 t.Errorf("bitcoin measured %s, want +100.00%% off 4pm yesterday's 50", got)
367 }
368}
369
370// The board is the eleven sectors plus the index they are read against, which
371// is also what makes it a complete three by four grid.
372func TestSectorBoardCarriesTheBenchmark(t *testing.T) {
373 quotes := map[string]Quote{}
374 for _, s := range append(sectors, benchmark) {
375 quotes[s.Symbol] = Quote{Price: 100, Previous: 100}
376 }
377
378 cells := buildSectors(quotes)
379 if len(cells) != 12 {
380 t.Fatalf("%d cells, want 12", len(cells))
381 }
382
383 var marked int
384 for _, c := range cells {
385 if c.Benchmark {
386 marked++
387 }
388 }
389 if marked != 1 {
390 t.Errorf("%d cells marked as the benchmark, want 1", marked)
391 }
392
393 asked := map[string]bool{}
394 for _, s := range rateAndSectorSymbols() {
395 asked[s] = true
396 }
397 if !asked[benchmark.Symbol] {
398 t.Errorf("%s is on the board but never fetched", benchmark.Symbol)
399 }
400}
401
402// The tab leads with whatever is still moving: the S&P while the session is
403// open, and bitcoin once it shuts, which is the one on this page that never
404// stops. Worth a test because the closed branch is the only one visible for
405// most of the day and the open one would otherwise ship unread.
406func TestTabTickerFollowsTheSession(t *testing.T) {
407 cards := []Card{
408 {Key: "sp500", Percent: "+0.42%"},
409 {Key: "bitcoin", Percent: "+1.10%"},
410 }
411
412 for _, c := range []struct{ session, want string }{
413 {"regular", "S&P +0.42%"},
414 {"pre", "BTC +1.10%"},
415 {"post", "BTC +1.10%"},
416 {"closed", "BTC +1.10%"},
417 } {
418 if got := tabTicker(Market{Session: c.session, Cards: cards}); got != c.want {
419 t.Errorf("session %q gave %q, want %q", c.session, got, c.want)
420 }
421 }
422}
423
424// A card with no price must not put a bare label in the tab.
425func TestTabTickerSkipsAnUnavailableCard(t *testing.T) {
426 m := Market{Session: "closed", Cards: []Card{{Key: "bitcoin", Unavailable: true}}}
427 if got := tabTicker(m); got != "" {
428 t.Errorf("tabTicker = %q, want nothing", got)
429 }
430}
431
432// The eight cards are read across, so the right edge of one has to mean the same
433// hour as the right edge of the next. Bitcoin trades through the evening and the
434// VIX stops at the bell, and without a shared end both would fill their card.
435func TestStripAxisSharesOneWindowAndLeavesAFrozenCardShort(t *testing.T) {
436 et := easternTime()
437 open := time.Date(2026, 8, 31, 9, 30, 0, 0, et)
438 now := open.Add(11 * time.Hour)
439
440 series := func(until time.Time) ([]float64, []int64) {
441 var closes []float64
442 var times []int64
443 for at := open.AddDate(0, 0, -1); !at.After(until); at = at.Add(15 * time.Minute) {
444 closes = append(closes, 100)
445 times = append(times, at.Unix())
446 }
447 return closes, times
448 }
449
450 vixCloses, vixTimes := series(open.Add(regularHours))
451 btcCloses, btcTimes := series(now)
452
453 quotes := map[string]Quote{
454 "^VIX": {Symbol: "^VIX", Price: 100, Previous: 100, AsOf: now, Closes: vixCloses, Times: vixTimes},
455 "BTC-USD": {Symbol: "BTC-USD", Price: 100, Previous: 100, AsOf: now, Closes: btcCloses, Times: btcTimes},
456 }
457
458 cards := map[string]Card{}
459 for _, c := range buildMarket(quotes, now).Cards {
460 cards[c.Key] = c
461 }
462
463 if got := cards["bitcoin"].Spark.Span; got < 99 {
464 t.Errorf("bitcoin is still printing and spans %v, want the full card", got)
465 }
466 if got := cards["vix"].Spark.Span; got < 55 || got > 65 {
467 t.Errorf("the VIX stopped at 4pm of an 11 hour window and spans %v, want about 59", got)
468 }
469 if !cards["vix"].Spark.Closed {
470 t.Error("the VIX shut at the bell and its dead stretch has to be marked")
471 }
472 if cards["bitcoin"].Spark.Closed {
473 t.Error("bitcoin is still printing and must not be marked shut")
474 }
475}
476
477// The symbols do not print on the same tick, and the futures normally trail
478// bitcoin by about ten minutes, so a card is only shut once it is further behind
479// than that spread.
480func TestShutByIgnoresTheNormalSpreadBetweenSymbols(t *testing.T) {
481 live := time.Date(2026, 8, 31, 20, 30, 0, 0, easternTime()).Unix()
482
483 behind := func(d time.Duration) stripRow {
484 return stripRow{times: []int64{live - int64(d/time.Second)}}
485 }
486
487 if behind(10 * time.Minute).shutBy(live) {
488 t.Error("ten minutes behind is the usual spread, not a shut market")
489 }
490 if !behind(4 * time.Hour).shutBy(live) {
491 t.Error("four hours behind is a market that shut")
492 }
493 if (stripRow{}).shutBy(live) {
494 t.Error("a card with no bars cannot be judged shut")
495 }
496}
497
498// A card whose last session is a different day from the rest keeps its own
499// window, or a VIX frozen on Friday would be stretched across the weekend.
500func TestStripAxisLeavesAStaleCardOnItsOwnSession(t *testing.T) {
501 et := easternTime()
502 friday := time.Date(2026, 8, 28, 9, 30, 0, 0, et)
503 monday := time.Date(2026, 8, 31, 9, 30, 0, 0, et)
504
505 stale := tradingAxis{start: friday.Unix(), end: friday.Add(regularHours).Unix(), ok: true}
506 live := tradingAxis{start: monday.Unix(), end: monday.Add(8 * time.Hour).Unix(), ok: true}
507
508 strip := stripAxis([]stripRow{{axis: stale}, {axis: live}})
509 if strip.start != live.start || strip.end != live.end {
510 t.Fatalf("strip window is %v, want Monday's", strip)
511 }
512 if got := (stripRow{axis: stale}).axisOr(strip); got != stale {
513 t.Errorf("the stale card took the strip's window, want its own Friday")
514 }
515 if got := (stripRow{axis: live}).axisOr(strip); got != strip {
516 t.Errorf("a card on the current session should take the shared window")
517 }
518}