Compare commits

...

63 Commits

Author SHA1 Message Date
3c274c614b add partial share ownership support (float)... and penny rounding errors.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2025-05-07 21:14:02 -06:00
61074bfd80 implement rudimentary caching for yahoo finance to avoid http 429 rate limiting
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-07 19:59:30 -06:00
13291da691 fix double slash in URL
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-07 12:16:55 -06:00
3fbfbab7d6 output URL in the https response error message
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-07 12:14:48 -06:00
a34dca1076 spoof user agent string for yahoo finance API
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-07 12:06:39 -06:00
0110941ac7 attempt to avoid yahoo finance rate limiting
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-07 11:57:33 -06:00
43cd399c18 Fix concurrent error handling for BTC provider
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2025-01-23 23:31:25 -07:00
88552ba042 fix nil pointer dereference
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2024-11-14 09:25:39 -07:00
068004ba14 bitcoin address failure - actually check the error
All checks were successful
continuous-integration/drone/push Build is passing
2024-11-11 06:25:26 -07:00
287acc03eb change from deadbeef.codes to code.stevenpolley.net 2024-11-11 06:24:37 -07:00
c4a79b0f4c do not update bitcoin balance if getting address balance fails
All checks were successful
continuous-integration/drone/push Build is passing
2024-11-08 13:17:01 -07:00
6ed332d8b6 fix nil pointer dereference if http error when getting btc address
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2024-09-23 08:59:42 -06:00
5e401c06ae switch to free api
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-09 17:47:17 -06:00
7689e3e1f2 fix fiat conversion - requires cg api key
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-09 17:43:14 -06:00
c119f1f57c get bitcoin addresses concurrently
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2024-09-01 09:05:21 -06:00
647f9a8f7b fix waitgroup instantiation
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-01 07:30:42 -06:00
a7d0005423 sync providers concurrently instead of in series
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-01 07:27:49 -06:00
39f3b27a8b update readme links
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2024-06-28 12:30:09 -06:00
3ae78f3b32 move from deadbeef.codes to stevenpolley.net
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-28 11:59:31 -06:00
7ce58c03d7 comments and formatting
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is failing
2024-03-30 19:42:58 -06:00
54417bf436 don't export BearerToken
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-23 19:45:28 -06:00
e35f0ef659 do not export what's not required 2024-03-23 19:00:30 -06:00
92a6246052 Do not export when not required
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2024-03-23 14:06:38 -06:00
7d52632af6 Remove legacy fiat conversion using coinconvert
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-23 13:49:38 -06:00
079ab596f8 simplify ynab package
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-23 13:39:50 -06:00
e6d62a3e7b Did I really not call it a directory in the previous commit. I am tainted
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-24 16:22:02 +00:00
9d4ca8ca9f remove public folder from Dockerfile
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-24 09:14:32 -07:00
acd6728c19 Removed public directory as all front end assets are embedded in the HTML directly
Some checks failed
continuous-integration/drone/push Build is failing
2024-02-24 09:09:05 -07:00
13d3c2e77c refresh questrade client if it's nil
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-23 23:01:36 -07:00
65d4780a3b add css for loader h3
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-17 16:41:50 -07:00
6bf5a48d3f Update go.mod to 1.22
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-17 09:51:20 -07:00
b146ef3170 Add created by steven polley 2024-02-17 09:51:12 -07:00
9bc4b463ef change from coinconvert to coingecko for FIAT conversion
All checks were successful
continuous-integration/drone/push Build is passing
2023-12-28 09:33:13 -07:00
ce4615d833 use cleared balance instead of balance
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-15 20:18:18 -07:00
4f320952b0 easiest front end of my life
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-14 19:36:09 -07:00
b82aaee05d Architecture Goal
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-14 16:59:09 -07:00
0d4010f1dd add font
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 18:55:44 -07:00
47e460a9dc make private if not part of provider interface
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 18:52:24 -07:00
5eea9ede3a avoid extra API call if not required
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 18:45:35 -07:00
81672fbd19 add basic preloader (am not front end dev lol)
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 18:29:31 -07:00
a3bcb61bae initialize refreshRunning mutex
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 18:12:13 -07:00
fb4b22cd08 copy public and templates directories into image
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 18:08:52 -07:00
4c904e7196 add web client - future plans for loading screen during refresh
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 18:05:20 -07:00
0a518fd31a add README.md for providers 2023-11-13 17:40:13 -07:00
4c01dc4ea2 move providers into providers subdirectory
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 17:39:22 -07:00
64f7eed9fd Add timezone to example docker-compose.yml
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 17:32:52 -07:00
8a61bba688 make login a private method
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 17:29:45 -07:00
4ff4c62daf fix timezones
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 16:30:59 -07:00
77e467071b set timezone for memo
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 16:28:00 -07:00
f3a7df7de9 copy timezone database from build environment
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 16:18:48 -07:00
3691ab496e add timezone info
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-13 16:17:20 -07:00
80f7641bef add quoated at memo
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 15:47:26 -07:00
1328310dbd Update readme
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 15:37:40 -07:00
feaa07e251 add rate limit for refresh
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 14:55:48 -07:00
a95df7c42b add webserver for refresh hook
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 14:07:57 -07:00
7a2e79682c rename quote.go to chart.go
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 12:25:13 -07:00
2a13fc9182 static JSON examples showing multiple accounts
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 12:18:22 -07:00
5f830294b0 update readme
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 12:14:29 -07:00
6436707a5d better error and update comments 2023-11-13 12:14:19 -07:00
58d4d0ba41 Add README's for providers
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 11:56:16 -07:00
be7e068fe6 Add Static JSON Yahoo Finance provider 2023-11-13 11:52:45 -07:00
67fcfeb177 fix typo's
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-13 00:33:40 -07:00
7cdf013b1e Add finnhub static JSON provider 2023-11-13 00:33:22 -07:00
34 changed files with 1118 additions and 188 deletions

View File

@ -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:
@ -18,9 +18,10 @@ steps:
- go version - go version
- go get - go get
- go build -a -ldflags '-w' - go build -a -ldflags '-w'
- cp /usr/local/go/lib/time/zoneinfo.zip .
- cp /etc/ssl/certs/ca-certificates.crt . - cp /etc/ssl/certs/ca-certificates.crt .
- 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

View File

@ -1,9 +1,12 @@
FROM scratch FROM scratch
LABEL maintainer="himself@stevenpolley.net" LABEL maintainer="himself@stevenpolley.net"
COPY data data COPY data data
COPY templates templates
COPY ca-certificates.crt /etc/ssl/certs/ COPY ca-certificates.crt /etc/ssl/certs/
COPY zoneinfo.zip zoneinfo.zip
COPY ynab-portfolio-monitor . COPY ynab-portfolio-monitor .
EXPOSE 8080 EXPOSE 8080
ENV ZONEINFO zoneinfo.zip
CMD [ "./ynab-portfolio-monitor" ] CMD [ "./ynab-portfolio-monitor" ]

View File

@ -1,6 +1,6 @@
# ynab-portfolio-monitor # ynab-portfolio-monitor
[![Build Status](https://drone.deadbeef.codes/api/badges/steven/ynab-portfolio-monitor/status.svg)](https://drone.deadbeef.codes/steven/ynab-portfolio-monitor) [![Build Status](https://drone.stevenpolley.net/api/badges/steven/ynab-portfolio-monitor/status.svg)](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,11 +8,47 @@ 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
No bloat, uses only the standard library, separate all integrations into their own package.
#### Main Package
Each account provider / integration is defined in its own package and adheres to the interface specified in accountProviders.go. When the program starts, init() inside main.go attempts to configure all providers, and uses providers where configuration succeeds and ignores providers if configuration fails (eg: missing environment variable or config file). init() will run again if SIGHUP is received to reload configuration.
The main() program loop refreshes data every 6 hours by looping through each configured provider and calling AccountProvider.GetBalances(). This returns a slice of balances, and a slice of YNAB account IDs. The index position of the balance slice maps with the index of the YNAB account ID slice. This allows a single provider to provide multiple balances for different YNAB accounts depending on the provider's configuration (eg: if you have multiple accounts at your broker). We loop through the balance slice and update each YNAB account balance with the value. Finally, sleep for 6 hours before doing it again.
#### Provider Packages
Provider packages are used for any integration and are found in their own sub directories. Providers must adhere to the interface speficied in accountProviders.go.
```golang
// AccountProvider is the base set of requirements to be implemented for any integration
type AccountProvider interface {
Name() string // Returns the name of the provider
Configure() error // Configures the provider for first use - if an error is returned the provider is not used
GetBalances() ([]int, []string, error) // A slice of balances, and an index mapped slice of ynab account IDs this provider handles is returned
}
```
By convention, these methods are implemented in a file called providerImpl.go in each of the provider packages.
The following providers are currently available:
* bitcoin
* questrade
* staticjsonFinnhub
* staticjsonYahooFinance
#### YNAB Package
This is a special package used for messaging to and from YNAB, instead of adhering to the AccountProvider interface, it exposes multiple public methods for use in the Main package's business logic.
### Example docker-compose.yml ### Example docker-compose.yml
The values below are examples only. You can configure as many account pairings as you want using environment variables in a continuous series starting from 0 as shown below. Two example accounts are configured ending in _0 and _1 but a third can be added by adding account numbers/ID with _2. The values below are examples only, and show how to configure the Questrade and Bitcoin providers. With these providers, you can configure as many account pairings as you want using environment variables in a continuous series starting from 0 as shown below. Two example Questrade accounts are configured ending in _0 and _1 but a third can be added by adding account numbers/ID with _2. See the respective README.md files inside the provider directories for more details.
```yaml ```yaml
version: '3.8' version: '3.8'
@ -20,9 +56,10 @@ 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
- questrade_refresh_token=4dsO6652dS3cxtcctscd3ds4Df2E0 - questrade_refresh_token=4dsO6652dS3cxtcctscd3ds4Df2E0
- questrade_account_0=51000001 # TFSA - questrade_account_0=51000001 # TFSA
- questrade_account_1=51000002 # RRSP - questrade_account_1=51000002 # RRSP
@ -32,9 +69,15 @@ services:
- ynab_secret=98Q_J655F_TAyGnhCCDS4uqRe4R5654DT2d-ZXdssZ - ynab_secret=98Q_J655F_TAyGnhCCDS4uqRe4R5654DT2d-ZXdssZ
- bitcoin_address_0=bc1qg0edu4tr7pza8qsxf576r0eulr2ygt3mhldswg - bitcoin_address_0=bc1qg0edu4tr7pza8qsxf576r0eulr2ygt3mhldswg
- bitcoin_address_1=bc1qeh0dkdqvjyt646657lge0nxqj67z5xa8zxl8q3 - bitcoin_address_1=bc1qeh0dkdqvjyt646657lge0nxqj67z5xa8zxl8q3
- bitcoin_address_2=bc1qsfsdqvj4443t64dfssfgexsaqj67z44dsjkfkj
- bitcoin_ynab_account=1f5bec0d-f852-2fbe-bbee-02fa98ded566 # Bitcoin addresses map to single YNAB account - bitcoin_ynab_account=1f5bec0d-f852-2fbe-bbee-02fa98ded566 # Bitcoin addresses map to single YNAB account
volumes: volumes:
- /data/ynab-portfolio-monitor-data:/data - /data/ynab-portfolio-monitor-data:/data
``` ```
### Static JSON Providers
If your broker doesn't have a provider available, or if you don't want to connect this application to your brokerage account, there is an offline solution available where you can configure your holdings in local static JSON files - and these only need to be updated if your holdings change such as after a trade has been made, or a stock split.
For more details, see the README.md files located in the provider directories prefixed with "staticjson".

View File

@ -1,8 +1,10 @@
package main package main
import ( import (
"deadbeef.codes/steven/ynab-portfolio-monitor/bitcoin" "code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/bitcoin"
"deadbeef.codes/steven/ynab-portfolio-monitor/questrade" "code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/questrade"
"code.stevenpolley.net/steven/ynab-portfolio-monitor/providers/staticjsonFinnhub"
"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
@ -17,4 +19,6 @@ type AccountProvider interface {
var allProviders []AccountProvider = []AccountProvider{ var allProviders []AccountProvider = []AccountProvider{
&questrade.Provider{}, &questrade.Provider{},
&bitcoin.Provider{}, &bitcoin.Provider{},
&staticjsonFinnhub.Provider{},
&staticjsonYahooFinance.Provider{},
} }

View File

@ -1,41 +0,0 @@
package bitcoin
import (
"fmt"
"net/http"
)
// example: https://api.coinconvert.net/convert/btc/cad?amount=1
const fiatConvertURL = "https://api.coinconvert.net/convert/btc/cad"
type FiatConversion struct {
Status string `json:"status"`
BTC int `json:"BTC"`
CAD float64 `json:"CAD"`
}
// BTC to CAD FIAT conversion - accepts an
// amount in satoshi's and returns a CAD amount * 1000
func (c *client) ConvertBTCToCAD(amount int) (int, error) {
fiatConversion := &FiatConversion{}
req, err := http.NewRequest("GET", fiatConvertURL+"?amount=1", nil)
if err != nil {
return 0, fmt.Errorf("failed to create new GET request: %v", err)
}
res, err := c.httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("http GET request failed: %v", err)
}
err = c.processResponse(res, fiatConversion)
if err != nil {
return 0, fmt.Errorf("failed to process response: %v", err)
}
if fiatConversion.Status != "success" {
return 0, fmt.Errorf("fiat conversion status was '%s' but expected 'success'", fiatConversion.Status)
}
return (amount * int(fiatConversion.CAD*1000)) / 100000000, nil // one BTC = one hundred million satoshi's
}

6
go.mod
View File

@ -1,3 +1,5 @@
module deadbeef.codes/steven/ynab-portfolio-monitor module code.stevenpolley.net/steven/ynab-portfolio-monitor
go 1.21.4 go 1.22
// Goal is no third party dependencies

60
main.go
View File

@ -3,14 +3,19 @@ package main
import ( import (
"log" "log"
"os" "os"
"sync"
"text/template"
"time" "time"
"deadbeef.codes/steven/ynab-portfolio-monitor/ynab" "code.stevenpolley.net/steven/ynab-portfolio-monitor/ynab"
) )
var ( var (
configuredProviders []AccountProvider // Any providers that are successfully configured get added to this slice configuredProviders []AccountProvider // Any account providers that are successfully configured get added to this slice
ynabClient *ynab.Client // YNAB HTTP client ynabClient *ynab.Client // YNAB HTTP client
lastRefresh time.Time // Timestamp that last data refresh ran - used for rate limiting
refreshRunning *sync.Mutex // True if a refresh is currently running
t *template.Template // HTML templates in the templates directory
) )
// Called at program startup or if SIGHUP is received // Called at program startup or if SIGHUP is received
@ -29,7 +34,8 @@ func init() {
} }
} }
// Loop through all providers and attempt to configure them // Loop through all account providers and attempt to configure them
// if configuration fails, the provider will not be used
configuredProviders = make([]AccountProvider, 0) configuredProviders = make([]AccountProvider, 0)
for _, p := range allProviders { for _, p := range allProviders {
err := p.Configure() err := p.Configure()
@ -47,21 +53,54 @@ func init() {
if err != nil { if err != nil {
log.Fatalf("failed to create ynab client: %v", err) log.Fatalf("failed to create ynab client: %v", err)
} }
// Web Templates
// Parse all template files at startup
t, err = template.ParseGlob("./templates/*")
if err != nil {
log.Fatalf("couldn't parse HTML templates: %v", err)
}
refreshRunning = &sync.Mutex{}
} }
// Main program loop
func main() { func main() {
for { go webServer()
for { // Main program loop
refreshData()
log.Print("Sleeping for 6 hours...")
time.Sleep(time.Hour * 6)
}
}
func refreshData() {
refreshRunning.Lock()
defer refreshRunning.Unlock()
// Only allow a refresh at most once every 5 minutes
if time.Now().Before(lastRefresh.Add(time.Minute * 5)) {
log.Printf("refresh rate limited")
return
}
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("mismatched balance and accountID slice lengths - expected the same: balances length = %d, accountIDs length = %d", 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])
@ -69,8 +108,7 @@ func main() {
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)
} }
} }
}()
} }
log.Print("Sleeping for 6 hours...") wg.Wait()
time.Sleep(time.Hour * 6)
}
} }

21
providers/README.md Normal file
View File

@ -0,0 +1,21 @@
# Provider Packages
Provider packages are used for any integration and are found in their own sub directories. Providers must adhere to the interface speficied in accountProviders.go.
```golang
// AccountProvider is the base set of requirements to be implemented for any integration
type AccountProvider interface {
Name() string // Returns the name of the provider
Configure() error // Configures the provider for first use - if an error is returned the provider is not used
GetBalances() ([]int, []string, error) // A slice of balances, and an index mapped slice of ynab account IDs this provider handles is returned
}
```
By convention, these methods are implemented in a file called providerImpl.go in each of the provider packages.
The following providers are currently available:
* bitcoin
* questrade
* staticjsonFinnhub
* staticjsonYahooFinance

View File

@ -0,0 +1,9 @@
# Bitcoin Provider
A provider for bitcoin, backed by blockstream.info to query address balance and coinconvert.net for fiat conversion.
### Environment Variables
* *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_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

View File

@ -5,7 +5,7 @@ import (
"net/url" "net/url"
) )
type Address struct { type addressData struct {
Address string `json:"address"` Address string `json:"address"`
ChainStats struct { ChainStats struct {
FundedTxoCount int `json:"funded_txo_count"` FundedTxoCount int `json:"funded_txo_count"`
@ -25,8 +25,8 @@ type Address struct {
// GetAddress returns an Address struct populated with data from blockstream.info // GetAddress returns an Address struct populated with data from blockstream.info
// for a given BTC address // for a given BTC address
func (c *client) GetAddress(address string) (*Address, error) { func (c *client) getAddress(address string) (*addressData, error) {
addressResponse := &Address{} addressResponse := &addressData{}
err := c.get(fmt.Sprintf("address/%s", address), addressResponse, url.Values{}) err := c.get(fmt.Sprintf("address/%s", address), addressResponse, url.Values{})
if err != nil { if err != nil {

View File

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

37
providers/bitcoin/fiat.go Normal file
View File

@ -0,0 +1,37 @@
package bitcoin
import (
"fmt"
"net/http"
)
const fiatConvertURL = "https://api.coingecko.com/api/v3/coins/bitcoin?localization=false&tickers=false&market_data=true&community_data=false&developer_data=false&sparkline=false"
type coinGeckoResponse struct {
MarketData struct {
CurrentPrice struct {
CAD int `json:"cad"`
} `json:"current_price"`
} `json:"market_data"`
}
func (c *client) convertBTCToCAD(amount int) (int, error) {
coinGeckoData := &coinGeckoResponse{}
req, err := http.NewRequest("GET", fmt.Sprintf("%s&x-cg-demo-api-key=%s", fiatConvertURL, c.coinGeckoApiKey), nil)
if err != nil {
return 0, fmt.Errorf("failed to create new GET request: %v", err)
}
res, err := c.httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("http GET request failed: %v", err)
}
err = c.processResponse(res, coinGeckoData)
if err != nil {
return 0, fmt.Errorf("failed to process response: %v", err)
}
return (amount * int(coinGeckoData.MarketData.CurrentPrice.CAD*1000)) / 100000000, nil // one BTC = one hundred million satoshi's
}

View File

@ -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,16 +46,30 @@ 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 {
addressResponse, err := p.client.GetAddress(bitcoinAddress) wg.Add(1)
go func(goErr *error) {
defer wg.Done()
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)
if err != nil { if err != nil {
return balances, ynabAccountIDs, fmt.Errorf("failed to convert satoshi balance to fiat balance: %v", err) return balances, ynabAccountIDs, fmt.Errorf("failed to convert satoshi balance to fiat balance: %v", err)
} }

View File

@ -0,0 +1,8 @@
# Questrade Provider
A provider for Questrade using the Questrade API.
### Environment Variables
* *questrade_account_[0...]* - A series of Questrade account numbers with a suffix of _0 and each additional account counting up.
* *questrade_ynab_account_[0...]* - The YNAB account ID for the matching questrade account. Each configured questrade account must have a matching YNAB account.

View File

@ -10,7 +10,7 @@ import (
// of which the API client is authorized. // of which the API client is authorized.
// //
// Ref: http://www.questrade.com/api/documentation/rest-operations/account-calls/accounts // Ref: http://www.questrade.com/api/documentation/rest-operations/account-calls/accounts
type Account struct { type account struct {
// Type of the account (e.g., "Cash", "Margin"). // Type of the account (e.g., "Cash", "Margin").
Type string `json:"type"` Type string `json:"type"`
@ -34,7 +34,7 @@ type Account struct {
// Balance belonging to an Account // Balance belonging to an Account
// //
// Ref: http://www.questrade.com/api/documentation/rest-operations/account-calls/accounts-id-balances // Ref: http://www.questrade.com/api/documentation/rest-operations/account-calls/accounts-id-balances
type Balance struct { type balance struct {
// Currency of the balance figure(e.g., "USD" or "CAD"). // Currency of the balance figure(e.g., "USD" or "CAD").
Currency string `json:"currency"` Currency string `json:"currency"`
@ -61,35 +61,35 @@ type Balance struct {
// AccountBalances represents per-currency and combined balances for a specified account. // AccountBalances represents per-currency and combined balances for a specified account.
// //
// Ref: http://www.questrade.com/api/documentation/rest-operations/account-calls/accounts-id-balances // Ref: http://www.questrade.com/api/documentation/rest-operations/account-calls/accounts-id-balances
type AccountBalances struct { type accountBalances struct {
PerCurrencyBalances []Balance `json:"perCurrencyBalances"` PerCurrencyBalances []balance `json:"perCurrencyBalances"`
CombinedBalances []Balance `json:"combinedBalances"` CombinedBalances []balance `json:"combinedBalances"`
SODPerCurrencyBalances []Balance `json:"sodPerCurrencyBalances"` SODPerCurrencyBalances []balance `json:"sodPerCurrencyBalances"`
SODCombinedBalances []Balance `json:"sodCombinedBalances"` SODCombinedBalances []balance `json:"sodCombinedBalances"`
} }
// GetAccounts returns the logged-in User ID, and a list of accounts // GetAccounts returns the logged-in User ID, and a list of accounts
// belonging to that user. // belonging to that user.
func (c *client) GetAccounts() (int, []Account, error) { func (c *client) GetAccounts() (int, []account, error) {
list := struct { list := struct {
UserID int `json:"userId"` UserID int `json:"userId"`
Accounts []Account `json:"accounts"` Accounts []account `json:"accounts"`
}{} }{}
err := c.get("v1/accounts", &list, url.Values{}) err := c.get("v1/accounts", &list, url.Values{})
if err != nil { if err != nil {
return 0, []Account{}, err return 0, []account{}, err
} }
return list.UserID, list.Accounts, nil return list.UserID, list.Accounts, nil
} }
// GetBalances returns the balances for the account with the specified account number // GetBalances returns the balances for the account with the specified account number
func (c *client) GetBalances(number string) (AccountBalances, error) { func (c *client) GetBalances(number string) (accountBalances, error) {
bal := AccountBalances{} bal := accountBalances{}
err := c.get("v1/accounts/"+number+"/balances", &bal, url.Values{}) err := c.get("v1/accounts/"+number+"/balances", &bal, url.Values{})
if err != nil { if err != nil {
return AccountBalances{}, err return accountBalances{}, err
} }
return bal, nil return bal, nil
} }

View File

@ -12,7 +12,7 @@ import (
const loginServerURL = "https://login.questrade.com/oauth2/" const loginServerURL = "https://login.questrade.com/oauth2/"
type LoginCredentials struct { type loginCredentials struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
@ -24,7 +24,7 @@ type LoginCredentials struct {
// endpoints. It holds the login credentials, http client/transport, // endpoints. It holds the login credentials, http client/transport,
// rate limit information, and the login session timer. // rate limit information, and the login session timer.
type client struct { type client struct {
Credentials LoginCredentials Credentials loginCredentials
SessionTimer *time.Timer SessionTimer *time.Timer
RateLimitRemaining int RateLimitRemaining int
RateLimitReset time.Time RateLimitReset time.Time
@ -34,13 +34,13 @@ type client struct {
// authHeader is a shortcut that returns a string to be placed // authHeader is a shortcut that returns a string to be placed
// in the authorization header of API calls // in the authorization header of API calls
func (l LoginCredentials) authHeader() string { func (l loginCredentials) authHeader() string {
return l.TokenType + " " + l.AccessToken return l.TokenType + " " + l.AccessToken
} }
// 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
} }
@ -88,11 +88,9 @@ func (c *client) processResponse(res *http.Response, out interface{}) error {
// and exchanges it for an access token. Returns a timer that // and exchanges it for an access token. Returns a timer that
// expires when the login session is over. // expires when the login session is over.
// TODO - Return a proper error when login fails with HTTP 400 - Bad Request // TODO - Return a proper error when login fails with HTTP 400 - Bad Request
func (c *client) Login() error { func (c *client) login() error {
login := loginServerURL
vars := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {c.Credentials.RefreshToken}} vars := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {c.Credentials.RefreshToken}}
res, err := c.httpClient.PostForm(login+"token", vars) res, err := c.httpClient.PostForm(loginServerURL+"token", vars)
if err != nil { if err != nil {
return err return err
@ -119,14 +117,14 @@ func newClient(refreshToken string) (*client, error) {
// Create a new client // Create a new client
c := &client{ c := &client{
Credentials: LoginCredentials{ Credentials: loginCredentials{
RefreshToken: refreshToken, RefreshToken: refreshToken,
}, },
httpClient: httpClient, httpClient: httpClient,
transport: transport, transport: transport,
} }
err := c.Login() err := c.login()
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -71,7 +71,7 @@ func (p *Provider) Configure() error {
// Returns slices of account balances and mapped YNAB account IDs, along with an error // Returns slices of account balances and mapped YNAB account IDs, along with an error
func (p *Provider) GetBalances() ([]int, []string, error) { func (p *Provider) GetBalances() ([]int, []string, error) {
// Refresh credentials if past half way until expiration // Refresh credentials if past half way until expiration
if p.lastRefresh.Add(time.Second * time.Duration(p.client.Credentials.ExpiresIn) / 2).Before(time.Now()) { if p.lastRefresh.Add(time.Second*time.Duration(p.client.Credentials.ExpiresIn)/2).Before(time.Now()) || p.client == nil {
err := p.refresh() err := p.refresh()
if err != nil { if err != nil {
return make([]int, 0), make([]string, 0), fmt.Errorf("failed to refresh http client: %v", err) return make([]int, 0), make([]string, 0), fmt.Errorf("failed to refresh http client: %v", 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)

View File

@ -0,0 +1,48 @@
# 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
},
{
"symbol":"BRK.A",
"quantity":5
},
{
"symbol":"CAPE",
"quantity":69
}
]
},
{
"ynabAccountId":"fdfedg45-c2g1-445-abdd-9dsa445sd54",
"securities":[
{
"symbol":"VCN.TO",
"quantity":100
},
{
"symbol":"XSB.TO",
"quantity":50
},
{
"symbol":"DLR.TO",
"quantity":20
}
]
}
]
}
```

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/staticjsonFinnhub-data.json")
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("this account provider is not configured: missing data/staticjsonFinnhub-data.json")
}
defer f.Close()
b, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("failed to read file data/staticjsonFinnhub-data.json: %v", err)
}
err = json.Unmarshal(b, data)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal data/staticjsonFinnhub-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
}

View File

@ -0,0 +1,48 @@
# 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
},
{
"symbol":"BRK.A",
"quantity":5
},
{
"symbol":"CAPE",
"quantity":69
}
]
},
{
"ynabAccountId":"fdfedg45-c2g1-445-abdd-9dsa445sd54",
"securities":[
{
"symbol":"VCN.TO",
"quantity":100
},
{
"symbol":"XSB.TO",
"quantity":50
},
{
"symbol":"DLR.TO",
"quantity":20
}
]
}
]
}
```

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

View File

@ -0,0 +1,95 @@
package staticjsonYahooFinance
import (
"fmt"
"net/url"
"time"
)
// A chartResponse response is what we get back from Yahoo Finance
type chartResponse 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"`
}
// 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) {
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{})
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))
}
chartCache[symbol] = chartCacheEntry{Chart: *chartResponse, LastUpdated: time.Now()}
return int(chartResponse.Chart.Result[0].Meta.RegularMarketPrice * 1000), nil
}

View File

@ -0,0 +1,84 @@
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
}
// 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)
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 for request at URL '%s'", res.StatusCode, res.Request.URL)
}
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
}

View File

@ -0,0 +1,94 @@
package staticjsonYahooFinance
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
)
type security struct {
Symbol string `json:"symbol"`
Quantity float64 `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)
}
chartCache = make(map[string]chartCacheEntry)
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 += int(float64(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
}

3
templates/README.md Normal file
View File

@ -0,0 +1,3 @@
# Templates
This directory contains HTML templates used by the Go templating engine.

164
templates/home.html Normal file
View File

@ -0,0 +1,164 @@
<html>
<head>
<title>YNAB Portfolio Monitor</title>
<script>
req = new XMLHttpRequest();
req.open("GET", "/status");
req.send();
req.onload = () => {
if (req.readyState == 4 && req.status == 200) {
// redirect to budget when refresh complete signal is received
window.location.href = "https://app.ynab.com/{{ .BudgetID }}"
}
};
</script>
<style>
.loader {
background: #2c396a;
bottom: 0;
left: 0;
overflow: hidden;
position: fixed;
right: 0;
top: 0;
z-index: 99999;
}
.loader h1 {
color: #fff;
color: var(--label_dark_background);
font-family: 'Figtree', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 2rem;
margin: 0;
padding: 0;
text-align: center
}
.loader h3 {
color: #fff;
color: var(--label_dark_background);
font-family: 'Figtree', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 1rem;
margin: 0;
padding: 0;
text-align: center
}
.loader-inner {
bottom: 0;
height: 60px;
left: 0;
margin: auto;
position: absolute;
right: 0;
top: 0;
width: 100px;
}
.loader-inner-header {
bottom: 0;
height: 80px;
left: 0;
margin: auto;
position: absolute;
right: 0;
top: 250;
width: 600px;
}
.loader-line-wrap {
animation:
spin 2000ms cubic-bezier(.175, .885, .32, 1.275) infinite
;
box-sizing: border-box;
height: 50px;
left: 0;
overflow: hidden;
position: absolute;
top: 0;
transform-origin: 50% 100%;
width: 100px;
}
.loader-line {
border: 4px solid transparent;
border-radius: 100%;
box-sizing: border-box;
height: 100px;
left: 0;
margin: 0 auto;
position: absolute;
right: 0;
top: 0;
width: 100px;
}
.loader-line-wrap:nth-child(1) { animation-delay: -50ms; }
.loader-line-wrap:nth-child(2) { animation-delay: -100ms; }
.loader-line-wrap:nth-child(3) { animation-delay: -150ms; }
.loader-line-wrap:nth-child(4) { animation-delay: -200ms; }
.loader-line-wrap:nth-child(5) { animation-delay: -250ms; }
.loader-line-wrap:nth-child(1) .loader-line {
border-color: hsl(0, 80%, 60%);
height: 90px;
width: 90px;
top: 7px;
}
.loader-line-wrap:nth-child(2) .loader-line {
border-color: hsl(60, 80%, 60%);
height: 76px;
width: 76px;
top: 14px;
}
.loader-line-wrap:nth-child(3) .loader-line {
border-color: hsl(120, 80%, 60%);
height: 62px;
width: 62px;
top: 21px;
}
.loader-line-wrap:nth-child(4) .loader-line {
border-color: hsl(180, 80%, 60%);
height: 48px;
width: 48px;
top: 28px;
}
.loader-line-wrap:nth-child(5) .loader-line {
border-color: hsl(240, 80%, 60%);
height: 34px;
width: 34px;
top: 35px;
}
@keyframes spin {
0%, 15% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body style="background-color: rgb(44, 57, 106);font-family: 'Figtree','Helvetica Neue',Helvetica,Arial,sans-serif;">
<div class="loader">
<div class="loader-inner">
<div class="loader-line-wrap">
<div class="loader-line"></div>
</div>
<div class="loader-line-wrap">
<div class="loader-line"></div>
</div>
<div class="loader-line-wrap">
<div class="loader-line"></div>
</div>
<div class="loader-line-wrap">
<div class="loader-line"></div>
</div>
<div class="loader-line-wrap">
<div class="loader-line"></div>
</div>
</div>
<div class="loader-inner-header">
<h1 style="color:white";>YNAB Portfolio Sync</h1>
<h3 style="color:#fff";>Created by Steven Polley</h3>
</div>
</div>
</body>
</html>

40
webServer.go Normal file
View File

@ -0,0 +1,40 @@
package main
import (
"log"
"net/http"
)
func webServer() {
// Page Handlers
// Anything that is responsible for the base elements of a viewable web page
http.HandleFunc("/", homePageHandler)
http.HandleFunc("/status", statusHandler)
// Start web server
log.Print("Service listening on :8080")
log.Printf("Web server unexpectedly exiting!: %v", http.ListenAndServe(":8080", nil))
}
type homePageData struct {
BudgetID string
}
func homePageHandler(w http.ResponseWriter, r *http.Request) {
go refreshData()
pageData := &homePageData{BudgetID: ynabClient.BudgetID}
err := t.ExecuteTemplate(w, "home.html", pageData)
if err != nil {
log.Printf("error executing home.html template: %v", err)
}
}
// Returns status 200 if a refresh is not running, otherwise waits for refresh to finish
// Can be used by clients to tell when a refresh finishes
func statusHandler(w http.ResponseWriter, r *http.Request) {
refreshRunning.Lock()
w.WriteHeader(http.StatusOK)
refreshRunning.Unlock()
}

4
ynab/README.md Normal file
View File

@ -0,0 +1,4 @@
# YNAB API
This is not considered an account provider as it does not use the AccountProvider interface, instead this package is used for interacting with YNAB, creating transactions, updating transactions, getting account balances, etc.

View File

@ -8,7 +8,7 @@ import (
// Reference: https://api.ynab.com/v1#/Accounts/ // Reference: https://api.ynab.com/v1#/Accounts/
type Accounts struct { type accounts struct {
Data struct { Data struct {
Account struct { Account struct {
ID string `json:"id"` ID string `json:"id"`
@ -29,8 +29,8 @@ type Accounts struct {
} `json:"data"` } `json:"data"`
} }
func (c *Client) GetAccount(accountID string) (*Accounts, error) { func (c *Client) getAccount(accountID string) (*accounts, error) {
response := Accounts{} response := accounts{}
err := c.get(fmt.Sprintf("/accounts/%s", accountID), &response, url.Values{}) err := c.get(fmt.Sprintf("/accounts/%s", accountID), &response, url.Values{})
if err != nil { if err != nil {
@ -49,33 +49,26 @@ func (c *Client) SetAccountBalance(accountID string, balance int) error {
return fmt.Errorf("failed to get ynab capital gains transaction ID: %v", err) return fmt.Errorf("failed to get ynab capital gains transaction ID: %v", err)
} }
ynabAccount, err := c.GetAccount(accountID) ynabAccount, err := c.getAccount(accountID)
if err != nil { if err != nil {
return fmt.Errorf("failed to get ynab account with id '%s': %v", accountID, err) return fmt.Errorf("failed to get ynab account with id '%s': %v", accountID, err)
} }
balanceDelta := balance - ynabAccount.Data.Account.Balance if balance == ynabAccount.Data.Account.ClearedBalance {
return nil // The YNAB account already has the balance we're requesting, so there is no need to do anything
}
balanceDelta := balance - ynabAccount.Data.Account.ClearedBalance
balanceDelta += ynabTransactionAmount // Take into account the existing transaction balanceDelta += ynabTransactionAmount // Take into account the existing transaction
if balanceDelta == 0 { if balanceDelta == 0 {
return nil // If balanceDelta is 0 do not create a transaction i.e. market is closed today return nil // If balanceDelta is 0 do not create a transaction i.e. market is closed today
} }
if ynabTransactionID == "" {
// there is no transaction - so create a new one
err = c.createTodayYNABCapitalGainsTransaction(accountID, balanceDelta)
if err != nil {
return fmt.Errorf("failed to create YNAB capital gains transaction for account ID '%s': %v", accountID, err)
}
log.Printf("Creating new capital gains transaction for YNAB account '%s' for amount: %d", accountID, balanceDelta)
} else {
// there is an existing transaction - so update the existing one
err = c.updateTodayYNABCapitalGainsTransaction(accountID, ynabTransactionID, balanceDelta) err = c.updateTodayYNABCapitalGainsTransaction(accountID, ynabTransactionID, balanceDelta)
if err != nil { if err != nil {
return fmt.Errorf("failed to update YNAB capital gains transaction for account ID '%s': %v", accountID, err) return fmt.Errorf("failed to update YNAB capital gains transaction for account ID '%s': %v", accountID, err)
} }
log.Printf("Updating existing capital gains transaction for YNAB account '%s' for amount: %d", accountID, balanceDelta) log.Printf("Capital gains transaction for YNAB account '%s' amount is: %d", accountID, balanceDelta)
}
return nil return nil
} }

View File

@ -7,6 +7,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"time" "time"
) )
@ -18,10 +19,11 @@ const apiBaseURL = "https://api.ynab.com/v1/budgets/"
// endpoints. It holds the login credentials, http client/transport, // endpoints. It holds the login credentials, http client/transport,
// rate limit information, and the login session timer. // rate limit information, and the login session timer.
type Client struct { type Client struct {
BearerToken string bearerToken string
BudgetID string BudgetID string
httpClient *http.Client httpClient *http.Client
transport *http.Transport transport *http.Transport
loc *time.Location
} }
// Send an HTTP GET request, and return the processed response // Send an HTTP GET request, and return the processed response
@ -30,7 +32,7 @@ func (c *Client) get(endpoint string, out interface{}, query url.Values) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to create new GET request: %v", err) return fmt.Errorf("failed to create new GET request: %v", err)
} }
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.BearerToken)) req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken))
res, err := c.httpClient.Do(req) res, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@ -56,7 +58,7 @@ func (c *Client) post(endpoint string, out interface{}, body interface{}) error
if err != nil { if err != nil {
return fmt.Errorf("failed to create new POST request: %v", err) return fmt.Errorf("failed to create new POST request: %v", err)
} }
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.BearerToken)) req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken))
req.Header.Add("Content-Type", "application/json") req.Header.Add("Content-Type", "application/json")
res, err := c.httpClient.Do(req) res, err := c.httpClient.Do(req)
@ -83,7 +85,7 @@ func (c *Client) put(endpoint string, out interface{}, body interface{}) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to create new POST request: %v", err) return fmt.Errorf("failed to create new POST request: %v", err)
} }
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.BearerToken)) req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken))
req.Header.Add("Content-Type", "application/json") req.Header.Add("Content-Type", "application/json")
res, err := c.httpClient.Do(req) res, err := c.httpClient.Do(req)
@ -130,12 +132,18 @@ func NewClient(budgetID, bearerToken string) (*Client, error) {
Transport: transport, Transport: transport,
} }
loc, err := time.LoadLocation(os.Getenv("TZ"))
if err != nil {
return nil, fmt.Errorf("failed to load timezone location '%s': %v", os.Getenv("TZ"), err)
}
// Create a new client // Create a new client
c := &Client{ c := &Client{
BudgetID: budgetID, BudgetID: budgetID,
BearerToken: bearerToken, bearerToken: bearerToken,
httpClient: client, httpClient: client,
transport: transport, transport: transport,
loc: loc,
} }
return c, nil return c, nil
} }

View File

@ -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 (
@ -7,8 +8,7 @@ import (
) )
// Reference: https://api.ynab.com/v1#/Transactions/ // Reference: https://api.ynab.com/v1#/Transactions/
type transaction struct {
type BaseTransaction struct {
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
ParentTransactionID interface{} `json:"parent_transaction_id,omitempty"` ParentTransactionID interface{} `json:"parent_transaction_id,omitempty"`
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
@ -31,32 +31,32 @@ type BaseTransaction struct {
Deleted bool `json:"deleted,omitempty"` Deleted bool `json:"deleted,omitempty"`
} }
// Used for single transaction requests
type transactionRequest struct {
Transaction transaction `json:"transaction,omitempty"`
}
// Used for single transaction responses // Used for single transaction responses
type Transaction struct { type transactionResponse struct {
Data struct { Data struct {
TransactionIDs []string `json:"transaction_ids,omitempty"` TransactionIDs []string `json:"transaction_ids,omitempty"`
Transaction BaseTransaction `json:"transaction"` Transaction transaction `json:"transaction"`
ServerKnowledge int `json:"server_knowledge,omitempty"` ServerKnowledge int `json:"server_knowledge,omitempty"`
} }
} }
// Used for single transaction requests
type TransactionRequest struct {
Transaction BaseTransaction `json:"transaction,omitempty"`
}
// Used for multiple transaction requests / responses // Used for multiple transaction requests / responses
type Transactions struct { type transactionListResponse struct {
Data struct { Data struct {
Transactions []BaseTransaction `json:"transactions"` Transactions []transaction `json:"transactions"`
ServerKnowledge int `json:"server_knowledge"` ServerKnowledge int `json:"server_knowledge"`
} `json:"data"` } `json:"data"`
} }
// Accepts a YNAB account ID and timestamp and returns all transactions in that account // Accepts a YNAB account ID and timestamp and returns all transactions in that account
// since the date provided // since the date provided
func (c *Client) GetAccountTransactions(accountID string, sinceDate time.Time) (*Transactions, error) { func (c *Client) GetAccountTransactions(accountID string, sinceDate time.Time) (*transactionListResponse, error) {
response := Transactions{} response := transactionListResponse{}
urlQuery := url.Values{} urlQuery := url.Values{}
urlQuery.Add("since_date", sinceDate.Format("2006-01-02")) urlQuery.Add("since_date", sinceDate.Format("2006-01-02"))
@ -70,7 +70,7 @@ func (c *Client) GetAccountTransactions(accountID string, sinceDate time.Time) (
// Accepts a YNAB account ID and returns the transaction ID, amount and an error for the // Accepts a YNAB account ID and returns the transaction ID, amount and an error for the
// the first transaction found with Payee Name "Capital Gains or Losses" // the first transaction found with Payee Name "Capital Gains or Losses"
func (c *Client) getTodayYnabCapitalGainsTransaction(accountID string) (string, int, error) { func (c *Client) getTodayYnabCapitalGainsTransaction(accountID string) (string, int, error) {
ynabTransactions, err := c.GetAccountTransactions(accountID, time.Now()) ynabTransactions, err := c.GetAccountTransactions(accountID, time.Now().In(c.loc))
if err != nil { if err != nil {
return "", 0, fmt.Errorf("failed to get ynab transactions: %v", err) return "", 0, fmt.Errorf("failed to get ynab transactions: %v", err)
} }
@ -84,41 +84,30 @@ func (c *Client) getTodayYnabCapitalGainsTransaction(accountID string) (string,
return "", 0, nil return "", 0, nil
} }
// Accepts a YNAB account ID and transaction amount and creates a new YNAB transaction
func (c *Client) createTodayYNABCapitalGainsTransaction(accountID string, amount int) error {
transaction := TransactionRequest{}
transaction.Transaction.AccountID = accountID
transaction.Transaction.Amount = amount
transaction.Transaction.Date = time.Now().Format("2006-01-02")
transaction.Transaction.Cleared = "cleared"
transaction.Transaction.Approved = true
transaction.Transaction.PayeeName = "Capital Gains or Losses"
response := &Transaction{}
err := c.post("/transactions", response, transaction)
if err != nil {
return fmt.Errorf("failed to post transaction: %v", err)
}
return nil
}
// Accepts a YNAB account ID, transaction ID and transaction amount and updates the YNAB transaction with the matching ID // Accepts a YNAB account ID, transaction ID and transaction amount and updates the YNAB transaction with the matching ID
// If transaction ID is blank, a new transaction is created for the amount specified
func (c *Client) updateTodayYNABCapitalGainsTransaction(accountID string, transactionID string, amount int) error { func (c *Client) updateTodayYNABCapitalGainsTransaction(accountID string, transactionID string, amount int) error {
transaction := TransactionRequest{} request := transactionRequest{
transaction.Transaction.AccountID = accountID Transaction: transaction{
transaction.Transaction.ID = transactionID AccountID: accountID,
transaction.Transaction.Amount = amount Amount: amount,
transaction.Transaction.Date = time.Now().Format("2006-01-02") Date: time.Now().In(c.loc).Format("2006-01-02"),
transaction.Transaction.Cleared = "cleared" Cleared: "cleared",
transaction.Transaction.Approved = true Approved: true,
transaction.Transaction.PayeeName = "Capital Gains or Losses" PayeeName: "Capital Gains or Losses",
Memo: fmt.Sprintf("Quoted at: %s", time.Now().In(c.loc).Format("2006-01-02 15:04:05")),
},
}
response := &transactionResponse{}
var err error
if transactionID == "" { // create transaction
err = c.post("/transactions", response, request)
} else { // update existing transaction
err = c.put(fmt.Sprintf("/transactions/%s", transactionID), response, request)
}
response := &Transaction{}
err := c.put(fmt.Sprintf("/transactions/%s", transactionID), response, transaction)
if err != nil { if err != nil {
return fmt.Errorf("failed to put transaction: %v", err) return fmt.Errorf("request failed: %v", err)
} }
return nil return nil
} }