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

16.8 KB · 519 lines · Go Raw History
  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}