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

4.6 KB · 161 lines · Go Raw History
  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}