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 "time"
9)
10
11// The conditions panel. Isaac sells into declines and buys back on the
12// recovery, and his own read of the research is that these signals work far
13// better for the buying half than the selling half, so this only ever describes
14// how far the market has fallen and never suggests getting out.
15//
16// It is a readout, not a prediction, and the page says so. Every threshold
17// below is arbitrary in the sense that no number is the right one, but they are
18// the conventional ones: five percent is a dip, ten a correction, twenty a bear
19// market, and a VIX in the thirties is panic.
20
21const signalEvery = time.Hour
22
23type Condition struct {
24 Label string `json:"label"`
25 Value string `json:"value"`
26 Note string `json:"note"`
27 State string `json:"state"`
28 Known bool `json:"known"`
29
30 // Where this reading sits along its own scale, and where the three band
31 // edges fall on it. A state word says which band a number landed in and
32 // nothing about how close it is to the next one, which is the question
33 // anyone watching a drawdown is actually asking.
34 Fill float64 `json:"fill"`
35 Ticks []float64 `json:"ticks"`
36}
37
38type Signal struct {
39 // Level is the whole panel in one word, and Headline says what it means in
40 // the only terms that matter here, which is whether this is an ordinary
41 // week or one of the moments worth adding into.
42 Level string `json:"level"`
43 Headline string `json:"headline"`
44 Conditions []Condition `json:"conditions"`
45}
46
47// history is the daily series the panel needs and the 30 second poll cannot
48// give it, since that one only ever asks for a single day.
49type history struct {
50 closes []float64
51 high52 float64
52}
53
54func fetchHistory(ctx context.Context, g *Guard, symbol string) (*history, error) {
55 q := url.Values{}
56 q.Set("symbols", symbol)
57 q.Set("range", "1y")
58 q.Set("interval", "1d")
59
60 var payload sparkPayload
61 if err := getJSON(ctx, g, "yahoo", sparkURL+"?"+q.Encode(), &payload); err != nil {
62 return nil, err
63 }
64 for _, r := range payload.Spark.Result {
65 if r.Symbol != symbol || len(r.Response) == 0 || len(r.Response[0].Indicators.Quote) == 0 {
66 continue
67 }
68 h := &history{high52: r.Response[0].Meta.FiftyTwoWeekHigh}
69 for _, c := range r.Response[0].Indicators.Quote[0].Close {
70 if c != nil {
71 h.closes = append(h.closes, *c)
72 }
73 }
74 if len(h.closes) < 30 {
75 return nil, fmt.Errorf("yahoo: only %d daily closes for %s", len(h.closes), symbol)
76 }
77 return h, nil
78 }
79 return nil, fmt.Errorf("yahoo: no daily series for %s", symbol)
80}
81
82func mean(xs []float64) float64 {
83 var sum float64
84 for _, x := range xs {
85 sum += x
86 }
87 return sum / float64(len(xs))
88}
89
90// buildSignal reads the conditions off the daily series and the live quotes.
91func buildSignal(h *history, quotes map[string]Quote) Signal {
92 var s Signal
93
94 spx, haveSPX := quotes["^GSPC"]
95 price := 0.0
96 if haveSPX {
97 price = spx.Price
98 }
99 if h != nil && price == 0 && len(h.closes) > 0 {
100 price = h.closes[len(h.closes)-1]
101 }
102
103 worst := 0
104
105 // How far below the year's high, which is the number Isaac already acts on.
106 if h != nil && h.high52 > 0 && price > 0 {
107 dd := (price - h.high52) / h.high52 * 100
108 state, rank := band(dd, -20, -10, -5)
109 worst = max(worst, rank)
110 fill, ticks := meter(dd, 0, -25, -5, -10, -20)
111 s.Conditions = append(s.Conditions, Condition{
112 Label: "OFF 52W HIGH", Value: fmt.Sprintf("%.1f%%", dd),
113 Note: "DRAWDOWN", State: state, Known: true,
114 Fill: fill, Ticks: ticks,
115 })
116 }
117
118 // The short shock, which is the case Isaac described: a fast drop over a
119 // few days rather than a long grind down.
120 if h != nil && len(h.closes) > 5 && price > 0 {
121 ref := h.closes[len(h.closes)-6]
122 if ref > 0 {
123 five := (price - ref) / ref * 100
124 state, rank := band(five, -8, -5, -3)
125 worst = max(worst, rank)
126 fill, ticks := meter(five, 3, -10, -3, -5, -8)
127 s.Conditions = append(s.Conditions, Condition{
128 Label: "5 DAY", Value: signed(five, 1) + "%",
129 Note: "SHORT SHOCK", State: state, Known: true,
130 Fill: fill, Ticks: ticks,
131 })
132 }
133 }
134
135 // Volatility, the market's own price for insurance and the fastest of these
136 // to move.
137 if vix, ok := quotes["^VIX"]; ok && vix.Price > 0 {
138 state, rank := band(-vix.Price, -35, -28, -22)
139 worst = max(worst, rank)
140 fill, ticks := meter(vix.Price, 10, 40, 22, 28, 35)
141 s.Conditions = append(s.Conditions, Condition{
142 Label: "VIX", Value: fmt.Sprintf("%.1f", vix.Price),
143 Note: vixNote(vix.Price), State: state, Known: true,
144 Fill: fill, Ticks: ticks,
145 })
146 }
147
148 // The regime. Below the 200 day average is the line most trend rules use,
149 // and it is here as context rather than as a trigger: it stays red through
150 // the whole of a recovery, which is exactly when Isaac is buying.
151 if h != nil && len(h.closes) >= 200 && price > 0 {
152 ma := mean(h.closes[len(h.closes)-200:])
153 gap := (price - ma) / ma * 100
154 state := "calm"
155 note := "ABOVE 200DMA"
156 if gap < 0 {
157 state, note = "watch", "BELOW 200DMA"
158 }
159 // Above the average is the calm end, so the scale runs down from it and
160 // a longer bar means further below, the same as the three rows above.
161 fill, ticks := meter(gap, 15, -15)
162 s.Conditions = append(s.Conditions, Condition{
163 Label: "TREND", Value: fmt.Sprintf("%+.1f%%", gap),
164 Note: note, State: state, Known: true,
165 Fill: fill, Ticks: ticks,
166 })
167 }
168
169 s.Level, s.Headline = verdict(worst)
170 return s
171}
172
173// meter turns a reading into a percentage across a fixed scale, plus where the
174// thresholds sit on that same scale. calm and worst are the two ends, and the
175// scale runs from one to the other in whichever direction the numbers go, so a
176// falling drawdown and a rising VIX both fill from the left as things get worse.
177//
178// A tick of zero is dropped, which is how the trend row gets a bar with no band
179// edges on it: above or below the average is the whole of what it says.
180func meter(v, calm, worst float64, edges ...float64) (float64, []float64) {
181 at := func(x float64) float64 {
182 frac := (x - calm) / (worst - calm) * 100
183 return math.Round(math.Min(100, math.Max(0, frac))*10) / 10
184 }
185
186 ticks := make([]float64, 0, len(edges))
187 for _, e := range edges {
188 if e == 0 {
189 continue
190 }
191 ticks = append(ticks, at(e))
192 }
193 return at(v), ticks
194}
195
196// band grades a value against three increasingly bad thresholds, all of which
197// are negative here so that more negative is worse. It returns the state name
198// and a rank so the panel can take the worst of everything it measured.
199func band(v, deep, mid, mild float64) (string, int) {
200 switch {
201 case v <= deep:
202 return "deep", 3
203 case v <= mid:
204 return "stress", 2
205 case v <= mild:
206 return "dip", 1
207 default:
208 return "calm", 0
209 }
210}
211
212func vixNote(v float64) string {
213 switch {
214 case v >= 35:
215 return "PANIC"
216 case v >= 28:
217 return "STRESSED"
218 case v >= 22:
219 return "ELEVATED"
220 case v >= 15:
221 return "NORMAL"
222 default:
223 return "COMPLACENT"
224 }
225}
226
227// verdict is the one line at the top of the panel. It is worded as an
228// observation about the market rather than as an instruction, because a
229// dashboard that tells someone to buy is a dashboard that will eventually be
230// wrong at the worst possible moment.
231func verdict(worst int) (level, headline string) {
232 switch worst {
233 case 3:
234 return "deep", "HISTORICALLY THE BEST DCA ZONE, AND THE HARDEST TO ACT IN"
235 case 2:
236 return "stress", "CORRECTION TERRITORY, THE RANGE ISAAC ADDS INTO"
237 case 1:
238 return "dip", "A DIP, SHALLOW BY HISTORICAL STANDARDS"
239 default:
240 return "calm", "NOTHING UNUSUAL, ORDINARY DCA CONDITIONS"
241 }
242}
243
244// signalSymbol is the one series the panel needs beyond the market poll.
245const signalSymbol = "^GSPC"