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

7.5 KB · 246 lines · Go Raw History
  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"