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 "fmt"
5 "math"
6 "strings"
7 "time"
8)
9
10// boardEvery is the rates and sector poll. Five minutes is finer than either
11// number deserves and it is a fifth of what riding the market poll cost.
12const boardEvery = 5 * time.Minute
13
14// Treasury yields and the sector board. Both ride the request the market strip
15// already makes, so the whole of this file costs no extra call.
16
17// Rate is one point on the curve. Yahoo publishes these as index symbols whose
18// price is the yield.
19type Rate struct {
20 Key string
21 Symbol string
22 Label string
23}
24
25var rates = []Rate{
26 {Key: "m3", Symbol: "^IRX", Label: "3M"},
27 {Key: "y5", Symbol: "^FVX", Label: "5Y"},
28 {Key: "y10", Symbol: "^TNX", Label: "10Y"},
29 {Key: "y30", Symbol: "^TYX", Label: "30Y"},
30}
31
32type RateRow struct {
33 Label string `json:"label"`
34 Yield string `json:"yield"`
35 Change string `json:"change"`
36 Direction string `json:"direction"`
37 Unavailable bool `json:"unavailable"`
38
39 // How long this row's bar is. Four numbers in a column say what each
40 // maturity pays and nothing about the shape they make together, and the
41 // shape is the only reason anyone looks at a curve. Reading the bars down
42 // the panel gives the ladder without spending a block on a chart.
43 Fill float64 `json:"fill"`
44}
45
46type Rates struct {
47 Rows []RateRow `json:"rows"`
48
49 // The shape in a word, which is the read the spread is there to give.
50 Shape string `json:"shape"`
51
52 // Curve is the ten year less the three month. It is the spread with the
53 // better record as a recession signal than the two year version everyone
54 // quotes, and it is the one these four symbols can actually produce, since
55 // Yahoo has no clean two year index.
56 Curve string `json:"curve"`
57 CurveState string `json:"curve_state"`
58}
59
60// normaliseYield handles Yahoo's legacy scaling. These indexes were quoted at
61// ten times the yield for years and are now quoted as the yield itself, and a
62// treasury paying 42% would be a story bigger than this dashboard, so anything
63// above twenty is the old form.
64func normaliseYield(v float64) float64 {
65 if v > 20 {
66 return v / 10
67 }
68 return v
69}
70
71func buildRates(quotes map[string]Quote) Rates {
72 var r Rates
73 yields := map[string]float64{}
74
75 for _, rate := range rates {
76 row := RateRow{Label: rate.Label}
77
78 q, ok := quotes[rate.Symbol]
79 if !ok || q.Price == 0 {
80 row.Unavailable = true
81 r.Rows = append(r.Rows, row)
82 continue
83 }
84
85 y := normaliseYield(q.Price)
86 prev := normaliseYield(q.Previous)
87 yields[rate.Key] = y
88
89 // Moves are in basis points, which is how anyone reading a curve thinks
90 // about them. A hundredth of a percent shown as "0.03%" is noise.
91 bps := (y - prev) * 100
92 row.Yield = fmt.Sprintf("%.2f%%", y)
93 row.Change = signed(bps, 0) + "bp"
94 row.Direction = direction(bps)
95 r.Rows = append(r.Rows, row)
96 }
97
98 if ten, ok := yields["y10"]; ok {
99 if three, ok := yields["m3"]; ok {
100 spread := (ten - three) * 100
101 r.Curve = signed(spread, 0) + "bp"
102 switch {
103 case spread < 0:
104 r.CurveState = "inverted"
105 // Under a quarter point across seven and a half years of maturity
106 // is not a slope anyone would trade on.
107 case spread < 25:
108 r.CurveState = "flat"
109 default:
110 r.CurveState = "normal"
111 }
112 r.Shape = strings.ToUpper(r.CurveState)
113 }
114 }
115
116 scaleRates(&r, yields)
117 return r
118}
119
120// scaleRates sets each row's bar length. The scale runs from zero to the top of
121// the range rounded up, so a bar is proportional to the yield it draws and the
122// four together read as the ladder. A relative scale between the lowest and the
123// highest would turn a quarter point of spread into a cliff.
124func scaleRates(r *Rates, yields map[string]float64) {
125 var hi float64
126 for _, y := range yields {
127 hi = math.Max(hi, y)
128 }
129 if hi <= 0 {
130 return
131 }
132 // Up to the next half point, so the longest bar stops short of the end and
133 // has somewhere to grow.
134 top := math.Ceil(hi*2) / 2
135
136 for i := range r.Rows {
137 y, ok := yields[rates[i].Key]
138 if !ok {
139 continue
140 }
141 r.Rows[i].Fill = math.Round(y/top*1000) / 10
142 }
143}
144
145// Sector is one of the eleven SPDR funds the S&P is cut into. Together they are
146// the answer to what is actually moving on a day the index moved.
147type Sector struct {
148 Symbol string
149 Label string
150}
151
152var sectors = []Sector{
153 {"XLK", "TECH"},
154 {"XLC", "COMM"},
155 {"XLY", "DISC"},
156 {"XLF", "FIN"},
157 {"XLV", "HEALTH"},
158 {"XLI", "INDUS"},
159 {"XLP", "STAPLE"},
160 {"XLE", "ENERGY"},
161 {"XLU", "UTIL"},
162 {"XLRE", "REIT"},
163 {"XLB", "MATRL"},
164}
165
166type SectorCell struct {
167 Label string `json:"label"`
168 Benchmark bool `json:"benchmark"`
169 Percent string `json:"percent"`
170 Direction string `json:"direction"`
171 Heat int `json:"heat"`
172 Raw float64 `json:"raw"`
173 Unavailable bool `json:"unavailable"`
174}
175
176// benchmark sits in the board with the eleven sectors, both so the grid is a
177// complete three by four and because the only useful thing to know about a
178// sector's day is whether it beat the index.
179var benchmark = Sector{"SPY", "S&P 500"}
180
181// buildSectors returns the cells ordered by the day's move, so the board reads
182// best to worst rather than in a fixed order nobody remembers.
183func buildSectors(quotes map[string]Quote) []SectorCell {
184 cells := make([]SectorCell, 0, len(sectors)+1)
185
186 for _, s := range append(sectors, benchmark) {
187 cell := SectorCell{Label: s.Label, Benchmark: s.Symbol == benchmark.Symbol}
188
189 q, ok := quotes[s.Symbol]
190 if !ok || q.Price == 0 {
191 cell.Unavailable = true
192 cells = append(cells, cell)
193 continue
194 }
195
196 pct := q.percent()
197 cell.Raw = pct
198 cell.Percent = signed(pct, 2) + "%"
199 cell.Direction = direction(pct)
200 cell.Heat = heatStep(pct)
201 cells = append(cells, cell)
202 }
203
204 // Descending, with anything unavailable pushed to the end rather than
205 // sorting as a zero in the middle of the board.
206 for i := 1; i < len(cells); i++ {
207 for j := i; j > 0; j-- {
208 a, b := cells[j-1], cells[j]
209 if a.Unavailable && !b.Unavailable || (!a.Unavailable && !b.Unavailable && b.Raw > a.Raw) {
210 cells[j-1], cells[j] = b, a
211 continue
212 }
213 break
214 }
215 }
216 return cells
217}
218
219// heatStep buckets a move into four shades either side of flat. The clamp is at
220// three percent because a sector moving more than that is rare enough that
221// finer gradations above it would only ever show one colour.
222func heatStep(pct float64) int {
223 mag := pct
224 if mag < 0 {
225 mag = -mag
226 }
227 switch {
228 case mag >= 2.0:
229 return 4
230 case mag >= 1.0:
231 return 3
232 case mag >= 0.4:
233 return 2
234 case mag >= 0.1:
235 return 1
236 default:
237 return 0
238 }
239}
240
241// rateAndSectorSymbols is everything this file needs, appended to the one
242// request the market poll already makes.
243func rateAndSectorSymbols() []string {
244 out := make([]string, 0, len(rates)+len(sectors)+1)
245 for _, r := range rates {
246 out = append(out, r.Symbol)
247 }
248 for _, s := range append(sectors, benchmark) {
249 out = append(out, s.Symbol)
250 }
251 return out
252}