Compare commits
	
		
			2 Commits
		
	
	
		
			7284545571
			...
			67fcfeb177
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 67fcfeb177 | |||
| 7cdf013b1e | 
| @@ -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{}, | ||||||
| } | } | ||||||
|   | |||||||
| @@ -40,7 +40,7 @@ func (l LoginCredentials) authHeader() string { | |||||||
|  |  | ||||||
| // Send an HTTP GET request, and return the processed response | // Send an HTTP GET request, and return the processed response | ||||||
| func (c *client) get(endpoint string, out interface{}, query url.Values) error { | func (c *client) get(endpoint string, out interface{}, query url.Values) error { | ||||||
| 	req, err := http.NewRequest("GET", c.Credentials.ApiServer+endpoint+query.Encode(), nil) | 	req, err := http.NewRequest("GET", c.Credentials.ApiServer+endpoint+"?"+query.Encode(), nil) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -123,7 +123,7 @@ func loadPersistentData() (*persistentData, error) { | |||||||
| 	defer f.Close() | 	defer f.Close() | ||||||
| 	b, err := io.ReadAll(f) | 	b, err := io.ReadAll(f) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, fmt.Errorf("failed to read file data/questrade-data.jsonn: %v", err) | 		return nil, fmt.Errorf("failed to read file data/questrade-data.json: %v", err) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	err = json.Unmarshal(b, data) | 	err = json.Unmarshal(b, data) | ||||||
|   | |||||||
							
								
								
									
										23
									
								
								staticjsonFinnhub/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								staticjsonFinnhub/README.md
									
									
									
									
									
										Normal 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 | ||||||
|  |                 } | ||||||
|  |             ] | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  | } | ||||||
|  | ``` | ||||||
							
								
								
									
										84
									
								
								staticjsonFinnhub/client.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								staticjsonFinnhub/client.go
									
									
									
									
									
										Normal 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 | ||||||
|  | } | ||||||
							
								
								
									
										100
									
								
								staticjsonFinnhub/providerImpl.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								staticjsonFinnhub/providerImpl.go
									
									
									
									
									
										Normal 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 | ||||||
|  | } | ||||||
							
								
								
									
										26
									
								
								staticjsonFinnhub/quote.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								staticjsonFinnhub/quote.go
									
									
									
									
									
										Normal 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 := "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 | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user