Add Static JSON Yahoo Finance provider
This commit is contained in:
parent
67fcfeb177
commit
be7e068fe6
@ -4,6 +4,7 @@ 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"
|
"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
|
// AccountProvider is the base set of requirements to be implemented for any integration
|
||||||
@ -19,4 +20,5 @@ var allProviders []AccountProvider = []AccountProvider{
|
|||||||
&questrade.Provider{},
|
&questrade.Provider{},
|
||||||
&bitcoin.Provider{},
|
&bitcoin.Provider{},
|
||||||
&staticjsonFinnhub.Provider{},
|
&staticjsonFinnhub.Provider{},
|
||||||
|
&staticjsonYahooFinance.Provider{},
|
||||||
}
|
}
|
||||||
|
@ -81,19 +81,19 @@ func (p *Provider) GetBalances() ([]int, []string, error) {
|
|||||||
func loadInputData() (*inputData, error) {
|
func loadInputData() (*inputData, error) {
|
||||||
data := &inputData{}
|
data := &inputData{}
|
||||||
|
|
||||||
f, err := os.Open("data/staticjson-data.json")
|
f, err := os.Open("data/staticjsonFinnhub-data.json")
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
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()
|
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/staticjson-data.json: %v", err)
|
return nil, fmt.Errorf("failed to read file data/staticjsonFinnhub-data.json: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(b, data)
|
err = json.Unmarshal(b, data)
|
||||||
if err != nil {
|
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
|
return data, nil
|
||||||
|
23
staticjsonYahooFinance/README.md
Normal file
23
staticjsonYahooFinance/README.md
Normal file
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
81
staticjsonYahooFinance/client.go
Normal file
81
staticjsonYahooFinance/client.go
Normal file
@ -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
|
||||||
|
}
|
92
staticjsonYahooFinance/providerImpl.go
Normal file
92
staticjsonYahooFinance/providerImpl.go
Normal file
@ -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
|
||||||
|
}
|
83
staticjsonYahooFinance/quote.go
Normal file
83
staticjsonYahooFinance/quote.go
Normal file
@ -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
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user