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 skills
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "time"
8)
9
10// v7/finance/quote is dead: it answers 401 to anything without a cookie and a
11// crumb. spark still works unauthenticated and carries the previous close,
12// which is what a change is measured against.
13const sparkURL = "https://query1.finance.yahoo.com/v7/finance/spark"
14
15// Markets quotes the indexes, the two big cryptocurrencies and the two
16// commodities dash already tracks. It is a price lookup and nothing else, so
17// anything asking why a price moved belongs on the web.
18type Markets struct{}
19
20func (Markets) Card() Card {
21 return Card{
22 Name: "markets",
23 Does: "quotes the current price and the move since the previous close for major indexes, bitcoin, ethereum, gold and crude oil.",
24 Fires: []string{
25 "what is the S&P 500 right now",
26 "how is the market doing",
27 "bitcoin price",
28 "is the dow up or down today",
29 "what is gold at",
30 },
31 NotFor: []string{
32 "why did the market drop",
33 "should i buy bitcoin",
34 "what is the S&P 500",
35 "how has the nasdaq performed this year",
36 "what is the price of a tesla",
37 },
38 Keywords: []string{"s&p", "sp500", "dow", "nasdaq", "vix", "bitcoin", "btc",
39 "ethereum", "gold price", "oil price", "stock market", "market doing"},
40 }
41}
42
43// symbols maps the words a person uses to Yahoo's tickers.
44func marketSymbols(q string) []string {
45 l := strings.ToLower(q)
46 pairs := []struct {
47 words []string
48 symbol string
49 }{
50 {[]string{"s&p", "sp500", "s and p", "s & p"}, "^GSPC"},
51 {[]string{"dow"}, "^DJI"},
52 {[]string{"nasdaq"}, "^IXIC"},
53 {[]string{"russell"}, "^RUT"},
54 {[]string{"vix", "volatility"}, "^VIX"},
55 {[]string{"bitcoin", "btc"}, "BTC-USD"},
56 {[]string{"ethereum", "eth"}, "ETH-USD"},
57 {[]string{"gold"}, "GC=F"},
58 {[]string{"oil", "crude"}, "CL=F"},
59 }
60 var out []string
61 for _, p := range pairs {
62 if containsAny(l, p.words...) {
63 out = append(out, p.symbol)
64 }
65 }
66 // "how is the stock market doing" means the indexes.
67 if len(out) == 0 && containsAny(l, "stock market", "market doing", "markets doing", "markets today") {
68 out = []string{"^GSPC", "^DJI", "^IXIC"}
69 }
70 return out
71}
72
73func symbolName(sym string) string {
74 return map[string]string{
75 "^GSPC": "S&P 500", "^DJI": "Dow Jones", "^IXIC": "Nasdaq",
76 "^RUT": "Russell 2000", "^VIX": "VIX", "BTC-USD": "Bitcoin",
77 "ETH-USD": "Ethereum", "GC=F": "Gold", "CL=F": "Crude oil",
78 }[sym]
79}
80
81type sparkPayload struct {
82 Spark struct {
83 Result []struct {
84 Symbol string `json:"symbol"`
85 Response []struct {
86 Meta struct {
87 Price float64 `json:"regularMarketPrice"`
88 PrevClose float64 `json:"chartPreviousClose"`
89 Time int64 `json:"regularMarketTime"`
90 Currency string `json:"currency"`
91 } `json:"meta"`
92 } `json:"response"`
93 } `json:"result"`
94 } `json:"spark"`
95}
96
97func (Markets) Run(ctx context.Context, question string, d Deps) (*Result, error) {
98 start := d.now()
99
100 // No recognised instrument means the router matched on the subject rather
101 // than on something quotable, so decline instead of quoting the indexes at
102 // someone who asked about a share price.
103 syms := marketSymbols(question)
104 if len(syms) == 0 {
105 return nil, nil
106 }
107
108 url := fmt.Sprintf("%s?symbols=%s&range=1d&interval=5m", sparkURL, strings.Join(syms, ","))
109 var p sparkPayload
110 if err := getJSON(ctx, d, url, &p); err != nil {
111 return nil, err
112 }
113 if len(p.Spark.Result) == 0 {
114 return nil, nil
115 }
116
117 var b strings.Builder
118 var newest int64
119 for _, r := range p.Spark.Result {
120 if len(r.Response) == 0 {
121 continue
122 }
123 m := r.Response[0].Meta
124 if m.Price == 0 || m.PrevClose == 0 {
125 continue
126 }
127 pts := m.Price - m.PrevClose
128 pct := pts / m.PrevClose * 100
129 dir := "up"
130 if pts < 0 {
131 dir = "down"
132 }
133 if m.Time > newest {
134 newest = m.Time
135 }
136 name := symbolName(r.Symbol)
137 if name == "" {
138 name = r.Symbol
139 }
140 fmt.Fprintf(&b, "- **%s** %s, %s **%s** (**%+.2f%%**) since the previous close of %s\n",
141 name, formatNumber(round2(m.Price)), dir,
142 formatNumber(round2(abs(pts))), pct, formatNumber(round2(m.PrevClose)))
143 }
144 if b.Len() == 0 {
145 return nil, nil
146 }
147
148 stamp := "an unknown time"
149 if newest > 0 {
150 stamp = time.Unix(newest, 0).In(d.now().Location()).Format("3:04 PM MST on 2 January 2006")
151 }
152 text := "Latest prices, measured against the previous close.\n\n" + b.String() +
153 fmt.Sprintf("\nFrom Yahoo Finance, quoted at %s.", stamp)
154
155 return &Result{
156 Skill: "markets", Shape: "factual", Text: text,
157 Sources: []Source{{URL: "https://finance.yahoo.com/", Title: "Yahoo Finance", Site: "finance.yahoo.com"}},
158 Elapsed: d.now().Sub(start).Round(10 * time.Millisecond).String(),
159 }, nil
160}