Add finnhub static JSON provider

This commit is contained in:
Steven Polley 2023-11-13 00:33:22 -07:00
parent 7284545571
commit 7cdf013b1e
5 changed files with 235 additions and 0 deletions

View File

@ -3,6 +3,7 @@ package main
import ( import (
"deadbeef.codes/steven/ynab-portfolio-monitor/bitcoin" "deadbeef.codes/steven/ynab-portfolio-monitor/bitcoin"
"deadbeef.codes/steven/ynab-portfolio-monitor/questrade" "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 // AccountProvider is the base set of requirements to be implemented for any integration
@ -17,4 +18,5 @@ type AccountProvider interface {
var allProviders []AccountProvider = []AccountProvider{ var allProviders []AccountProvider = []AccountProvider{
&questrade.Provider{}, &questrade.Provider{},
&bitcoin.Provider{}, &bitcoin.Provider{},
&staticjsonFinnhub.Provider{},
} }

View File

@ -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
}
]
}
]
}
```

View File

@ -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
}

View File

@ -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
}

View File

@ -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 := &quote{}
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
}