From be7e068fe62915d5be7fd8cf3c4b57504b97d1e5 Mon Sep 17 00:00:00 2001 From: Steven Polley Date: Mon, 13 Nov 2023 11:52:45 -0700 Subject: [PATCH] Add Static JSON Yahoo Finance provider --- accountProviders.go | 2 + staticjsonFinnhub/providerImpl.go | 8 +-- staticjsonYahooFinance/README.md | 23 +++++++ staticjsonYahooFinance/client.go | 81 +++++++++++++++++++++++ staticjsonYahooFinance/providerImpl.go | 92 ++++++++++++++++++++++++++ staticjsonYahooFinance/quote.go | 83 +++++++++++++++++++++++ 6 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 staticjsonYahooFinance/README.md create mode 100644 staticjsonYahooFinance/client.go create mode 100644 staticjsonYahooFinance/providerImpl.go create mode 100644 staticjsonYahooFinance/quote.go diff --git a/accountProviders.go b/accountProviders.go index 608cfce..0e99733 100644 --- a/accountProviders.go +++ b/accountProviders.go @@ -4,6 +4,7 @@ import ( "deadbeef.codes/steven/ynab-portfolio-monitor/bitcoin" "deadbeef.codes/steven/ynab-portfolio-monitor/questrade" "deadbeef.codes/steven/ynab-portfolio-monitor/staticjsonFinnhub" + "deadbeef.codes/steven/ynab-portfolio-monitor/staticjsonYahooFinance" ) // AccountProvider is the base set of requirements to be implemented for any integration @@ -19,4 +20,5 @@ var allProviders []AccountProvider = []AccountProvider{ &questrade.Provider{}, &bitcoin.Provider{}, &staticjsonFinnhub.Provider{}, + &staticjsonYahooFinance.Provider{}, } diff --git a/staticjsonFinnhub/providerImpl.go b/staticjsonFinnhub/providerImpl.go index c9cb00e..faf67ed 100644 --- a/staticjsonFinnhub/providerImpl.go +++ b/staticjsonFinnhub/providerImpl.go @@ -81,19 +81,19 @@ func (p *Provider) GetBalances() ([]int, []string, error) { func loadInputData() (*inputData, error) { data := &inputData{} - f, err := os.Open("data/staticjson-data.json") + f, err := os.Open("data/staticjsonFinnhub-data.json") if errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("this account provider is not configured: missing data/staticjson-data.json") + return nil, fmt.Errorf("this account provider is not configured: missing data/staticjsonFinnhub-data.json") } defer f.Close() b, err := io.ReadAll(f) if err != nil { - return nil, fmt.Errorf("failed to read file data/staticjson-data.json: %v", err) + return nil, fmt.Errorf("failed to read file data/staticjsonFinnhub-data.json: %v", err) } err = json.Unmarshal(b, data) if err != nil { - return nil, fmt.Errorf("failed to unmarshal data/staticjson-data.json to inputData struct: %v", err) + return nil, fmt.Errorf("failed to unmarshal data/staticjsonFinnhub-data.json to inputData struct: %v", err) } return data, nil diff --git a/staticjsonYahooFinance/README.md b/staticjsonYahooFinance/README.md new file mode 100644 index 0000000..ad23ed5 --- /dev/null +++ b/staticjsonYahooFinance/README.md @@ -0,0 +1,23 @@ +# Static JSON Yahoo Finance Provider + +If you just want to provide a static JSON file as input containing symbols and quantities owned, this provider will use Yahoo Finance for pricing quotes for the symbols provided. + +### Example data/staticjsonYahooFinance-data.json + +You can define many to many relationships, multiple YNAB accounts containing multiple types of securities using json arrays. + +```json +{ + "accounts":[ + { + "ynabAccountId":"d54da05a-cs20-4654-bcff-9ce36f43225d", + "securities":[ + { + "symbol":"SPY", + "quantity":420 + } + ] + } + ] +} +``` \ No newline at end of file diff --git a/staticjsonYahooFinance/client.go b/staticjsonYahooFinance/client.go new file mode 100644 index 0000000..333d668 --- /dev/null +++ b/staticjsonYahooFinance/client.go @@ -0,0 +1,81 @@ +package staticjsonYahooFinance + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const apiBaseURL = "https://query1.finance.yahoo.com/v8/finance/" + +// A client is the structure that will be used to consume the API +// endpoints. It holds the login credentials, http client/transport, +// rate limit information, and the login session timer. +type client struct { + httpClient *http.Client + transport *http.Transport +} + +// Send an HTTP GET request, and return the processed response +func (c *client) get(endpoint string, out interface{}, query url.Values) error { + req, err := http.NewRequest("GET", apiBaseURL+endpoint+"?"+query.Encode(), nil) + if err != nil { + return err + } + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("http get request failed: %v", err) + } + + err = c.processResponse(res, out) + if err != nil { + return err + } + return nil +} + +// processResponse takes the body of an HTTP response, and either returns +// the error code, or unmarshalls the JSON response, extracts +// rate limit info, and places it into the object +// output parameter. This function closes the response body after reading it. +func (c *client) processResponse(res *http.Response, out interface{}) error { + body, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + return err + } + + if res.StatusCode != 200 { + return fmt.Errorf("got http response status '%d' but expected 200", res.StatusCode) + } + + err = json.Unmarshal(body, out) + if err != nil { + return err + } + + return nil +} + +// newClient is the factory function for clients - takes an API token +func newClient() (*client, error) { + transport := &http.Transport{ + ResponseHeaderTimeout: 5 * time.Second, + } + + httpClient := &http.Client{ + Transport: transport, + } + + // Create a new client + c := &client{ + httpClient: httpClient, + transport: transport, + } + + return c, nil +} diff --git a/staticjsonYahooFinance/providerImpl.go b/staticjsonYahooFinance/providerImpl.go new file mode 100644 index 0000000..10f2363 --- /dev/null +++ b/staticjsonYahooFinance/providerImpl.go @@ -0,0 +1,92 @@ +package staticjsonYahooFinance + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" +) + +type security struct { + Symbol string `json:"symbol"` + Quantity int `json:"quantity"` +} + +type account struct { + YnabAccountID string `json:"ynabAccountId"` + Securities []security `json:"securities"` +} + +type inputData struct { + Accounts []account `json:"accounts"` +} + +type Provider struct { + data *inputData // Data stored on disk and loaded when program starts + client *client // HTTP client for interacting with Finnhub API +} + +func (p *Provider) Name() string { + return "Static JSON - Yahoo Finance" +} + +// Configures the provider for usage via environment variables and inputData +// If an error is returned, the provider will not be used +func (p *Provider) Configure() error { + var err error + + // Load input data from disk + p.data, err = loadInputData() + if err != nil { + return err + } + + p.client, err = newClient() + if err != nil { + return fmt.Errorf("failed to create new client: %v", err) + } + + return nil +} + +// Returns slices of account balances and mapped YNAB account IDs, along with an error +func (p *Provider) GetBalances() ([]int, []string, error) { + balances := make([]int, 0) + ynabAccountIDs := make([]string, 0) + for i := range p.data.Accounts { + balance := 0 + for j := range p.data.Accounts[i].Securities { + price, err := p.client.getChart(p.data.Accounts[i].Securities[j].Symbol) + if err != nil { + return balances, ynabAccountIDs, fmt.Errorf("failed to get quote for security with symbol '%s': %v", p.data.Accounts[i].Securities[j].Symbol, err) + } + balance += price * p.data.Accounts[i].Securities[j].Quantity + } + balances = append(balances, balance) + ynabAccountIDs = append(ynabAccountIDs, p.data.Accounts[i].YnabAccountID) + } + return balances, ynabAccountIDs, nil +} + +// Load input data from disk +func loadInputData() (*inputData, error) { + data := &inputData{} + + f, err := os.Open("data/staticjsonYahooFinance-data.json") + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("this account provider is not configured: missing data/staticjsonYahooFinance-data.json") + } + defer f.Close() + b, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("failed to read file data/staticjsonYahooFinance-data.json: %v", err) + } + + err = json.Unmarshal(b, data) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data/staticjsonYahooFinance-data.json to inputData struct: %v", err) + } + + return data, nil +} diff --git a/staticjsonYahooFinance/quote.go b/staticjsonYahooFinance/quote.go new file mode 100644 index 0000000..f3336ea --- /dev/null +++ b/staticjsonYahooFinance/quote.go @@ -0,0 +1,83 @@ +package staticjsonYahooFinance + +import ( + "fmt" + "net/url" +) + +type chart struct { + Chart struct { + Result []struct { + Meta struct { + Currency string `json:"currency"` + Symbol string `json:"symbol"` + ExchangeName string `json:"exchangeName"` + InstrumentType string `json:"instrumentType"` + FirstTradeDate int `json:"firstTradeDate"` + RegularMarketTime int `json:"regularMarketTime"` + Gmtoffset int `json:"gmtoffset"` + Timezone string `json:"timezone"` + ExchangeTimezoneName string `json:"exchangeTimezoneName"` + RegularMarketPrice float64 `json:"regularMarketPrice"` + ChartPreviousClose float64 `json:"chartPreviousClose"` + PreviousClose float64 `json:"previousClose"` + Scale int `json:"scale"` + PriceHint int `json:"priceHint"` + CurrentTradingPeriod struct { + Pre struct { + Timezone string `json:"timezone"` + Start int `json:"start"` + End int `json:"end"` + Gmtoffset int `json:"gmtoffset"` + } `json:"pre"` + Regular struct { + Timezone string `json:"timezone"` + Start int `json:"start"` + End int `json:"end"` + Gmtoffset int `json:"gmtoffset"` + } `json:"regular"` + Post struct { + Timezone string `json:"timezone"` + Start int `json:"start"` + End int `json:"end"` + Gmtoffset int `json:"gmtoffset"` + } `json:"post"` + } `json:"currentTradingPeriod"` + TradingPeriods [][]struct { + Timezone string `json:"timezone"` + Start int `json:"start"` + End int `json:"end"` + Gmtoffset int `json:"gmtoffset"` + } `json:"tradingPeriods"` + DataGranularity string `json:"dataGranularity"` + Range string `json:"range"` + ValidRanges []string `json:"validRanges"` + } `json:"meta"` + Timestamp []int `json:"timestamp"` + Indicators struct { + Quote []struct { + Volume []any `json:"volume"` + High []any `json:"high"` + Close []any `json:"close"` + Open []any `json:"open"` + Low []any `json:"low"` + } `json:"quote"` + } `json:"indicators"` + } `json:"result"` + Error any `json:"error"` + } `json:"chart"` +} + +func (c client) getChart(symbol string) (int, error) { + chartResponse := &chart{} + err := c.get(fmt.Sprintf("/chart/%s", symbol), chartResponse, url.Values{}) + if err != nil { + return 0, fmt.Errorf("http get request error: %v", err) + } + + if len(chartResponse.Chart.Result) != 1 { + return 0, fmt.Errorf("unexpected length of results - expected 1 but got %d", len(chartResponse.Chart.Result)) + } + + return int(chartResponse.Chart.Result[0].Meta.RegularMarketPrice * 1000), nil +}