Compare commits
20 Commits
54417bf436
...
main
Author | SHA1 | Date | |
---|---|---|---|
3c274c614b | |||
61074bfd80 | |||
13291da691 | |||
3fbfbab7d6 | |||
a34dca1076 | |||
0110941ac7 | |||
43cd399c18 | |||
88552ba042 | |||
068004ba14 | |||
287acc03eb | |||
c4a79b0f4c | |||
6ed332d8b6 | |||
5e401c06ae | |||
7689e3e1f2 | |||
c119f1f57c | |||
647f9a8f7b | |||
a7d0005423 | |||
39f3b27a8b | |||
3ae78f3b32 | |||
7ce58c03d7 |
@ -3,7 +3,7 @@ name: default
|
|||||||
|
|
||||||
workspace:
|
workspace:
|
||||||
base: /go
|
base: /go
|
||||||
path: src/deadbeef.codes/steven/ynab-portfolio-monitor
|
path: src/code.stevenpolley.net/steven/ynab-portfolio-monitor
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
@ -24,4 +24,4 @@ steps:
|
|||||||
- name: package in docker container
|
- name: package in docker container
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
repo: registry.deadbeef.codes/ynab-portfolio-monitor
|
repo: registry.stevenpolley.net/ynab-portfolio-monitor
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# ynab-portfolio-monitor
|
# ynab-portfolio-monitor
|
||||||
|
|
||||||
[](https://drone.deadbeef.codes/steven/ynab-portfolio-monitor)
|
[](https://drone.stevenpolley.net/steven/ynab-portfolio-monitor)
|
||||||
|
|
||||||
Track your securities in YNAB for account types and update your balance automatically. For each configured account, it will update the balance from your broker in YNAB every 6 hours by creating / editing a transaction named "Capital Gains or Losses". On days that exchanges are closed, it will not do anything. The end result is that there will be transaction each day with payee "Capital Gains or Losses" in YNAB for each account you configure, which allows tracking your account balance over time.
|
Track your securities in YNAB for account types and update your balance automatically. For each configured account, it will update the balance from your broker in YNAB every 6 hours by creating / editing a transaction named "Capital Gains or Losses". On days that exchanges are closed, it will not do anything. The end result is that there will be transaction each day with payee "Capital Gains or Losses" in YNAB for each account you configure, which allows tracking your account balance over time.
|
||||||
|
|
||||||
@ -8,7 +8,7 @@ It syncs your balance like magic!
|
|||||||
|
|
||||||
![alt text][logo]
|
![alt text][logo]
|
||||||
|
|
||||||
[logo]: https://deadbeef.codes/steven/ynab-portfolio-monitor/raw/branch/main/example-image.png "It syncs your balance like magic!"
|
[logo]: https://code.stevenpolley.net/steven/ynab-portfolio-monitor/raw/branch/main/example-image.png "It syncs your balance like magic!"
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
@ -56,7 +56,7 @@ version: '3.8'
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
ynab-portfolio-monitor:
|
ynab-portfolio-monitor:
|
||||||
image: registry.deadbeef.codes/ynab-portfolio-monitor:latest
|
image: registry.stevenpolley.net/ynab-portfolio-monitor:latest
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- TZ=America/Edmonton
|
- TZ=America/Edmonton
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"deadbeef.codes/steven/ynab-portfolio-monitor/providers/bitcoin"
|
"code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/bitcoin"
|
||||||
"deadbeef.codes/steven/ynab-portfolio-monitor/providers/questrade"
|
"code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/questrade"
|
||||||
"deadbeef.codes/steven/ynab-portfolio-monitor/providers/staticjsonFinnhub"
|
"code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/staticjsonFinnhub"
|
||||||
"deadbeef.codes/steven/ynab-portfolio-monitor/providers/staticjsonYahooFinance"
|
"code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/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
|
||||||
|
2
go.mod
2
go.mod
@ -1,4 +1,4 @@
|
|||||||
module deadbeef.codes/steven/ynab-portfolio-monitor
|
module code.stevenpolley.net/steven/ynab-portfolio-monitor
|
||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
|
13
main.go
13
main.go
@ -7,7 +7,7 @@ import (
|
|||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"deadbeef.codes/steven/ynab-portfolio-monitor/ynab"
|
"code.stevenpolley.net/steven/ynab-portfolio-monitor/ynab"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -86,16 +86,21 @@ func refreshData() {
|
|||||||
}
|
}
|
||||||
lastRefresh = time.Now()
|
lastRefresh = time.Now()
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
|
||||||
// Loop through each configured account provider and attempt to get the account balances, and update YNAB
|
// Loop through each configured account provider and attempt to get the account balances, and update YNAB
|
||||||
for _, p := range configuredProviders {
|
for _, p := range configuredProviders {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
balances, accountIDs, err := p.GetBalances()
|
balances, accountIDs, err := p.GetBalances()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("failed to get balances with provider '%s': %v", p.Name(), err)
|
log.Printf("failed to get balances with provider '%s': %v", p.Name(), err)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
if len(balances) != len(accountIDs) {
|
if len(balances) != len(accountIDs) {
|
||||||
log.Printf("'%s' provider data validation error: mismatched balance and accountID slice lengths - expected the same: balances length = %d, accountIDs length = %d", p.Name(), len(balances), len(accountIDs))
|
log.Printf("'%s' provider data validation error: mismatched balance and accountID slice lengths - expected the same: balances length = %d, accountIDs length = %d", p.Name(), len(balances), len(accountIDs))
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
for i := range balances {
|
for i := range balances {
|
||||||
err = ynabClient.SetAccountBalance(accountIDs[i], balances[i])
|
err = ynabClient.SetAccountBalance(accountIDs[i], balances[i])
|
||||||
@ -103,5 +108,7 @@ func refreshData() {
|
|||||||
log.Printf("failed to update ynab account '%s' balance: %v", accountIDs[i], err)
|
log.Printf("failed to update ynab account '%s' balance: %v", accountIDs[i], err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
@ -6,3 +6,4 @@ A provider for bitcoin, backed by blockstream.info to query address balance and
|
|||||||
|
|
||||||
* *bitcoin_address_[0...]* - A series of bitcoin addresses with a suffix of _0 and each additional address counting up.
|
* *bitcoin_address_[0...]* - A series of bitcoin addresses with a suffix of _0 and each additional address counting up.
|
||||||
* *bitcoin_ynab_account* - The YNAB account ID used to keep track of Bitcoin balance
|
* *bitcoin_ynab_account* - The YNAB account ID used to keep track of Bitcoin balance
|
||||||
|
* *bitcoin_coingecko_api_key* - Required for fiat conversion, your addresses nor balances are not transmitted. Docs: https://docs.coingecko.com/reference/setting-up-your-api-key
|
@ -17,6 +17,7 @@ const apiBaseURL = "https://blockstream.info/api/"
|
|||||||
type client struct {
|
type client struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
transport *http.Transport
|
transport *http.Transport
|
||||||
|
coinGeckoApiKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an HTTP GET request, and return the processed response
|
// Send an HTTP GET request, and return the processed response
|
||||||
@ -61,7 +62,7 @@ func (c *client) processResponse(res *http.Response, out interface{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newClient is the factory function for clients
|
// newClient is the factory function for clients
|
||||||
func newClient() (*client, error) {
|
func newClient(coinGeckoApiKey string) *client {
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
ResponseHeaderTimeout: 5 * time.Second,
|
ResponseHeaderTimeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
@ -74,6 +75,7 @@ func newClient() (*client, error) {
|
|||||||
c := &client{
|
c := &client{
|
||||||
httpClient: httpClient,
|
httpClient: httpClient,
|
||||||
transport: transport,
|
transport: transport,
|
||||||
|
coinGeckoApiKey: coinGeckoApiKey,
|
||||||
}
|
}
|
||||||
return c, nil
|
return c
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ type coinGeckoResponse struct {
|
|||||||
func (c *client) convertBTCToCAD(amount int) (int, error) {
|
func (c *client) convertBTCToCAD(amount int) (int, error) {
|
||||||
coinGeckoData := &coinGeckoResponse{}
|
coinGeckoData := &coinGeckoResponse{}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", fiatConvertURL, nil)
|
req, err := http.NewRequest("GET", fmt.Sprintf("%s&x-cg-demo-api-key=%s", fiatConvertURL, c.coinGeckoApiKey), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to create new GET request: %v", err)
|
return 0, fmt.Errorf("failed to create new GET request: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ package bitcoin
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
@ -12,14 +13,12 @@ type Provider struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provider) Name() string {
|
func (p *Provider) Name() string {
|
||||||
return "Bitcoin - Blockstream.info"
|
return "Bitcoin - Blockstream.info / CoinGecko"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configures the provider for usage via environment variables and persistentData
|
// Configures the provider for usage via environment variables and persistentData
|
||||||
// If an error is returned, the provider will not be used
|
// If an error is returned, the provider will not be used
|
||||||
func (p *Provider) Configure() error {
|
func (p *Provider) Configure() error {
|
||||||
var err error
|
|
||||||
|
|
||||||
// Load environment variables in continous series with suffix starting at 0
|
// Load environment variables in continous series with suffix starting at 0
|
||||||
// Multiple addresses can be configured, (eg _1, _2)
|
// Multiple addresses can be configured, (eg _1, _2)
|
||||||
// As soon as the series is interrupted, we assume we're done
|
// As soon as the series is interrupted, we assume we're done
|
||||||
@ -37,10 +36,7 @@ func (p *Provider) Configure() error {
|
|||||||
p.ynabAccountID = os.Getenv("bitcoin_ynab_account")
|
p.ynabAccountID = os.Getenv("bitcoin_ynab_account")
|
||||||
|
|
||||||
// Create new HTTP client
|
// Create new HTTP client
|
||||||
p.client, err = newClient()
|
p.client = newClient(os.Getenv("bitcoin_coingecko_api_key"))
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create new bitcoin client: %v", err)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,13 +46,27 @@ func (p *Provider) GetBalances() ([]int, []string, error) {
|
|||||||
balances := make([]int, 0)
|
balances := make([]int, 0)
|
||||||
ynabAccountIDs := make([]string, 0)
|
ynabAccountIDs := make([]string, 0)
|
||||||
var satoshiBalance int
|
var satoshiBalance int
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
var goErr *error
|
||||||
|
|
||||||
for _, bitcoinAddress := range p.bitcoinAddresses {
|
for _, bitcoinAddress := range p.bitcoinAddresses {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(goErr *error) {
|
||||||
|
defer wg.Done()
|
||||||
addressResponse, err := p.client.getAddress(bitcoinAddress)
|
addressResponse, err := p.client.getAddress(bitcoinAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return balances, ynabAccountIDs, fmt.Errorf("failed to get bitcoin address '%s': %v", bitcoinAddress, err)
|
err := fmt.Errorf("failed to get BTC balance for bitcoin address '%s': %v", bitcoinAddress, err)
|
||||||
|
if err != nil {
|
||||||
|
goErr = &err
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
satoshiBalance += addressResponse.ChainStats.FundedTxoSum - addressResponse.ChainStats.SpentTxoSum
|
satoshiBalance += addressResponse.ChainStats.FundedTxoSum - addressResponse.ChainStats.SpentTxoSum
|
||||||
|
}(goErr)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if goErr != nil {
|
||||||
|
return nil, nil, *goErr
|
||||||
}
|
}
|
||||||
|
|
||||||
fiatBalance, err := p.client.convertBTCToCAD(satoshiBalance)
|
fiatBalance, err := p.client.convertBTCToCAD(satoshiBalance)
|
||||||
|
15
providers/staticjsonYahooFinance/cache.go
Normal file
15
providers/staticjsonYahooFinance/cache.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package staticjsonYahooFinance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cacheAgeSeconds = 900
|
||||||
|
|
||||||
|
// intialized in providerImpl.go's Configure
|
||||||
|
var chartCache map[string]chartCacheEntry
|
||||||
|
|
||||||
|
type chartCacheEntry struct {
|
||||||
|
Chart chartResponse
|
||||||
|
LastUpdated time.Time
|
||||||
|
}
|
@ -3,9 +3,11 @@ package staticjsonYahooFinance
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type chart struct {
|
// A chartResponse response is what we get back from Yahoo Finance
|
||||||
|
type chartResponse struct {
|
||||||
Chart struct {
|
Chart struct {
|
||||||
Result []struct {
|
Result []struct {
|
||||||
Meta struct {
|
Meta struct {
|
||||||
@ -68,8 +70,16 @@ type chart struct {
|
|||||||
} `json:"chart"`
|
} `json:"chart"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getChart first checks if the symbol chartCacheEntry is valid,
|
||||||
|
// and if not then it downloads fresh chart data
|
||||||
func (c client) getChart(symbol string) (int, error) {
|
func (c client) getChart(symbol string) (int, error) {
|
||||||
chartResponse := &chart{}
|
if cacheItem, ok := chartCache[symbol]; ok {
|
||||||
|
// if the cacheEntry is still valid, use it
|
||||||
|
if time.Now().Before(cacheItem.LastUpdated.Add(cacheAgeSeconds)) {
|
||||||
|
return int(cacheItem.Chart.Chart.Result[0].Meta.RegularMarketPrice * 1000), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chartResponse := &chartResponse{}
|
||||||
err := c.get(fmt.Sprintf("/chart/%s", symbol), chartResponse, url.Values{})
|
err := c.get(fmt.Sprintf("/chart/%s", symbol), chartResponse, url.Values{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("http get request error: %v", err)
|
return 0, fmt.Errorf("http get request error: %v", err)
|
||||||
@ -79,5 +89,7 @@ func (c client) getChart(symbol string) (int, error) {
|
|||||||
return 0, fmt.Errorf("unexpected length of results - expected 1 but got %d", len(chartResponse.Chart.Result))
|
return 0, fmt.Errorf("unexpected length of results - expected 1 but got %d", len(chartResponse.Chart.Result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chartCache[symbol] = chartCacheEntry{Chart: *chartResponse, LastUpdated: time.Now()}
|
||||||
|
|
||||||
return int(chartResponse.Chart.Result[0].Meta.RegularMarketPrice * 1000), nil
|
return int(chartResponse.Chart.Result[0].Meta.RegularMarketPrice * 1000), nil
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const apiBaseURL = "https://query1.finance.yahoo.com/v8/finance/"
|
const apiBaseURL = "https://query1.finance.yahoo.com/v8/finance"
|
||||||
|
|
||||||
// A client is the structure that will be used to consume the API
|
// A client is the structure that will be used to consume the API
|
||||||
// endpoints. It holds the login credentials, http client/transport,
|
// endpoints. It holds the login credentials, http client/transport,
|
||||||
@ -26,6 +26,9 @@ func (c *client) get(endpoint string, out interface{}, query url.Values) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Yahoo finance requires user agent string now?
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0")
|
||||||
|
|
||||||
res, err := c.httpClient.Do(req)
|
res, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("http get request failed: %v", err)
|
return fmt.Errorf("http get request failed: %v", err)
|
||||||
@ -50,7 +53,7 @@ func (c *client) processResponse(res *http.Response, out interface{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if res.StatusCode != 200 {
|
if res.StatusCode != 200 {
|
||||||
return fmt.Errorf("got http response status '%d' but expected 200", res.StatusCode)
|
return fmt.Errorf("got http response status '%d' but expected 200 for request at URL '%s'", res.StatusCode, res.Request.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(body, out)
|
err = json.Unmarshal(body, out)
|
||||||
|
@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
type security struct {
|
type security struct {
|
||||||
Symbol string `json:"symbol"`
|
Symbol string `json:"symbol"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type account struct {
|
type account struct {
|
||||||
@ -47,6 +47,8 @@ func (p *Provider) Configure() error {
|
|||||||
return fmt.Errorf("failed to create new client: %v", err)
|
return fmt.Errorf("failed to create new client: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chartCache = make(map[string]chartCacheEntry)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,7 +63,7 @@ func (p *Provider) GetBalances() ([]int, []string, error) {
|
|||||||
if err != nil {
|
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)
|
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
|
balance += int(float64(price) * p.data.Accounts[i].Securities[j].Quantity)
|
||||||
}
|
}
|
||||||
balances = append(balances, balance)
|
balances = append(balances, balance)
|
||||||
ynabAccountIDs = append(ynabAccountIDs, p.data.Accounts[i].YnabAccountID)
|
ynabAccountIDs = append(ynabAccountIDs, p.data.Accounts[i].YnabAccountID)
|
||||||
|
@ -160,6 +160,5 @@
|
|||||||
<h3 style="color:#fff";>Created by Steven Polley</h3>
|
<h3 style="color:#fff";>Created by Steven Polley</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@ -29,7 +29,6 @@ func homePageHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("error executing home.html template: %v", err)
|
log.Printf("error executing home.html template: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
//http.Redirect(w, r, fmt.Sprintf("https://app.ynab.com/%s", ynabClient.BudgetID), http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns status 200 if a refresh is not running, otherwise waits for refresh to finish
|
// Returns status 200 if a refresh is not running, otherwise waits for refresh to finish
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
// Package ynab provides a very simple API client for getting account data and setting account balances.
|
||||||
package ynab
|
package ynab
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
Reference in New Issue
Block a user