From 7cdf013b1efdc7351d52eb75cd549052bf9d05ab Mon Sep 17 00:00:00 2001 From: Steven Polley Date: Mon, 13 Nov 2023 00:33:22 -0700 Subject: [PATCH] Add finnhub static JSON provider --- accountProviders.go | 2 + staticjsonFinnhub/README.md | 23 +++++++ staticjsonFinnhub/client.go | 84 +++++++++++++++++++++++++ staticjsonFinnhub/providerImpl.go | 100 ++++++++++++++++++++++++++++++ staticjsonFinnhub/quote.go | 26 ++++++++ 5 files changed, 235 insertions(+) create mode 100644 staticjsonFinnhub/README.md create mode 100644 staticjsonFinnhub/client.go create mode 100644 staticjsonFinnhub/providerImpl.go create mode 100644 staticjsonFinnhub/quote.go diff --git a/accountProviders.go b/accountProviders.go index 61e66c3..608cfce 100644 --- a/accountProviders.go +++ b/accountProviders.go @@ -3,6 +3,7 @@ package main import ( "deadbeef.codes/steven/ynab-portfolio-monitor/bitcoin" "deadbeef.codes/steven/ynab-portfolio-monitor/questrade" + "deadbeef.codes/steven/ynab-portfolio-monitor/staticjsonFinnhub" ) // AccountProvider is the base set of requirements to be implemented for any integration @@ -17,4 +18,5 @@ type AccountProvider interface { var allProviders []AccountProvider = []AccountProvider{ &questrade.Provider{}, &bitcoin.Provider{}, + &staticjsonFinnhub.Provider{}, } diff --git a/staticjsonFinnhub/README.md b/staticjsonFinnhub/README.md new file mode 100644 index 0000000..688478e --- /dev/null +++ b/staticjsonFinnhub/README.md @@ -0,0 +1,23 @@ +# Static JSON Finnhub Provider + +If you just want to provide a static JSON file as input containing symbols and quantities owned, this provider will use finnhub for pricing quotes for the symbols provided. + +### Example data/staticjsonFinnhub-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/staticjsonFinnhub/client.go b/staticjsonFinnhub/client.go new file mode 100644 index 0000000..3a676ad --- /dev/null +++ b/staticjsonFinnhub/client.go @@ -0,0 +1,84 @@ +package staticjsonFinnhub + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const apiBaseURL = "https://finnhub.io/api/v1/" + +// 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 { + apiToken string + 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 + } + req.Header.Add("X-Finnhub-Token", c.apiToken) + + 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(apiToken string) (*client, error) { + transport := &http.Transport{ + ResponseHeaderTimeout: 5 * time.Second, + } + + httpClient := &http.Client{ + Transport: transport, + } + + // Create a new client + c := &client{ + apiToken: apiToken, + httpClient: httpClient, + transport: transport, + } + + return c, nil +} diff --git a/staticjsonFinnhub/providerImpl.go b/staticjsonFinnhub/providerImpl.go new file mode 100644 index 0000000..c9cb00e --- /dev/null +++ b/staticjsonFinnhub/providerImpl.go @@ -0,0 +1,100 @@ +package staticjsonFinnhub + +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 - Finnhub" +} + +// 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 + } + + b, _ := json.Marshal(p.data) + fmt.Println(string(b)) + + apiKey := os.Getenv("staticjson_finnhub_key") + if apiKey == "" { + return fmt.Errorf("this account provider is not configured: missing staticjson_finnhub_key environment variable") + } + + p.client, err = newClient(apiKey) + 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.getQuote(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/staticjson-data.json") + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("this account provider is not configured: missing data/staticjson-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) + } + + 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 data, nil +} diff --git a/staticjsonFinnhub/quote.go b/staticjsonFinnhub/quote.go new file mode 100644 index 0000000..93b758d --- /dev/null +++ b/staticjsonFinnhub/quote.go @@ -0,0 +1,26 @@ +package staticjsonFinnhub + +import ( + "fmt" + "net/url" +) + +type quote struct { + C float64 `json:"c"` // Current price + H float64 `json:"h"` // High price of the day + L float64 `json:"l"` // Low price of the day + O float64 `json:"O"` // Open price of the day + Pc float64 `json:"pc"` // Previous close price + T int `json:"t"` // ? +} + +func (c client) getQuote(symbol string) (int, error) { + quoteResponse := "e{} + query := url.Values{} + query.Add("symbol", symbol) + err := c.get("/quote", quoteResponse, query) + if err != nil { + return 0, fmt.Errorf("http get request error: %v", err) + } + return int(quoteResponse.C * 1000), nil +}