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

6.8 KB · 253 lines · Go Raw History
  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}