Skip to content

Commit

Permalink
refactor: Switch to publishing this automation as a GitHub action
Browse files Browse the repository at this point in the history
  • Loading branch information
notheotherben committed Nov 25, 2023
1 parent 8e6e818 commit 7317cd8
Show file tree
Hide file tree
Showing 57 changed files with 2,028 additions and 290 deletions.
2 changes: 0 additions & 2 deletions .env.example

This file was deleted.

65 changes: 65 additions & 0 deletions .github/workflows/check-dist.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# In JavaScript actions, `dist/index.js` is a special file. When you reference
# an action with `uses:`, `dist/index.js` is the code that will be run. For this
# project, the `dist/index.js` file is generated from other source files through
# the build process. We need to make sure that the checked-in `dist/index.js`
# file matches what is expected from the build.
#
# This workflow will fail if the checked-in `dist/index.js` file does not match
# what is expected from the build.
name: Check dist/

on:
push:
branches:
- main
paths-ignore:
- '**.md'
pull_request:
paths-ignore:
- '**.md'
workflow_dispatch:

jobs:
check-dist:
name: Check dist/
runs-on: ubuntu-latest

permissions:
contents: read
statuses: write

steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install Dependencies
id: install
run: npm ci

- name: Build dist/ Directory
id: build
run: npm run build

- name: Compare Expected and Actual Directories
id: diff
run: |
if [ "$(git diff --ignore-space-at-eol --text dist/ | wc -l)" -gt "0" ]; then
echo "Detected uncommitted changes after build. See status below:"
git diff --ignore-space-at-eol --text dist/
exit 1
fi
# If index.js was different than expected, upload the expected version as
# a workflow artifact.
- uses: actions/upload-artifact@v3
if: ${{ failure() && steps.diff.conclusion == 'failure' }}
with:
name: dist
path: dist/
23 changes: 15 additions & 8 deletions .github/workflows/run.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
- run: npm ci
- run: npm run start
env:
YNAB_API_KEY: '${{ secrets.YNAB_API_KEY }}'
YNAB_BUDGET_IDS: '${{ secrets.YNAB_BUDGET_IDS }}'
ALPHAVANTAGE_API_KEY: '${{ secrets.ALPHAVANTAGE_API_KEY }}'
TIINGO_API_KEY: '${{ secrets.TIINGO_API_KEY }}'
node-version: '20'

- uses: ./
name: "Personal Budget"
with:
budget_id: "${{ secrets.YNAB_BUDGET_ID_PERSONAL }}"
api_key: "${{ secrets.YNAB_API_KEY }}"
cache: true

- uses: ./
name: "Household Budget"
with:
budget_id: "${{ secrets.YNAB_BUDGET_ID_HOUSEHOLD }}"
api_key: "${{ secrets.YNAB_API_KEY }}"
cache: true

4 changes: 1 addition & 3 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
node-version: '20'
- run: npm ci
- run: npm test
env:
ALPHAVANTAGE_API_KEY: '${{ secrets.ALPHAVANTAGE_API_KEY }}'

2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ appsettings.json
local.settings.json

node_modules
dist
.ynab-cache

# Local python packages
.python_packages/
Expand Down
26 changes: 26 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: 'You Need a Budget Automation'
description: 'Automate your YNAB budgeting with this GitHub Action, providing automated approval, replication, and stock portfolio tracking.'
author: 'Sierra Softworks'

# Define your inputs here.
inputs:
budget_id:
description: 'The YNAB budget ID which you would like this action to operate against.'
required: false
default: "default"
api_key:
description: 'The YNAB API key which you would like this action to use. You can find your API key at https://app.ynab.com/settings/developer'
required: true
cache:
description: 'Whether the cache should be enabled for use with this action.'
required: false
default: "true"

# Define your outputs here.
outputs:
success:
description: 'Indicates whether your YNAB account was updated successfully or not.'

runs:
using: node20
main: dist/index.js
32 changes: 26 additions & 6 deletions automations/stocks.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
import * as assert from "assert";
import { StockChecker } from "./stocks";
import { CurrencyDataSource, StockDataSource } from "../datasources/datasource";

describe("stocks", () => {
const stockChecker = new StockChecker();
class MockDataSource implements StockDataSource, CurrencyDataSource {
async getStockData(symbol: string): Promise<{
symbol: string;
currency: string;
price: number;
}> {
return {
symbol,
currency: "USD",
price: 100
}
}

async getCurrencyData(from: string, to: string): Promise<number> {
if (from === to) return 1
return 0.5
}
}

const stockChecker = new StockChecker(new MockDataSource(), new MockDataSource());

describe.skip("fetching ticker data", () => {
it("should fetch ticker data", async () => {
const actual = await stockChecker.getTicker("AAPL");
assert.ok(actual.price > 0);
assert.ok(actual.price === 100);
});

it("should cache ticker data", async () => {
const actual = await stockChecker.getTicker("AAPL");
assert.ok(actual.price > 0);
assert.ok(actual.price === 100);
});
});

Expand All @@ -24,17 +44,17 @@ describe("stocks", () => {

it("should fetch currency rates", async () => {
const actual = await stockChecker.getRate("USD", "GBP");
assert.ok(actual > 0);
assert.ok(actual === 0.5);
});

it("should cache currency rates", async () => {
const actual = await stockChecker.getRate("USD", "GBP");
assert.ok(actual > 0);
assert.ok(actual === 0.5);
});

it("should cache the inverse rates", async () => {
const actual = await stockChecker.getRate("GBP", "USD");
assert.ok(actual > 0);
assert.ok(actual === 2);
})
});

Expand Down
16 changes: 12 additions & 4 deletions automations/stocks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { Automation } from "../automation"
import { getCurrencyData, getStockData } from "../utils/tiingo"
import { StockDataSource, CurrencyDataSource } from "../datasources/datasource"

import { Account, API, BudgetDetail, SaveTransaction, TransactionClearedStatus } from "ynab"

export class StockAutomation extends Automation {
private stockChecker = new StockChecker()
constructor(api: API, stocks: StockDataSource, currencies: CurrencyDataSource) {
super(api)

this.stockChecker = new StockChecker(stocks, currencies)
}

private stockChecker: StockChecker

public get kind() {
return "stocks"
Expand Down Expand Up @@ -62,13 +68,15 @@ interface TickerData {
}

export class StockChecker {
constructor(protected stocks: StockDataSource, protected currencies: CurrencyDataSource) { }

private readonly tickers: { [symbol: string]: TickerData } = {}

private readonly rates: { [conversion: string]: number } = {}

public async getTicker(symbol: string): Promise<TickerData> {
if (this.tickers[symbol]) return this.tickers[symbol]
const ticker = await getStockData(symbol)
const ticker = await this.stocks.getStockData(symbol)
return this.tickers[symbol] = {
currency: ticker.currency,
price: ticker.price
Expand All @@ -81,7 +89,7 @@ export class StockChecker {
const conversion = `${from}:${to}`
if (this.rates[conversion]) return this.rates[conversion]
const inverse = `${to}:${from}`
const rate = await getCurrencyData(from, to)
const rate = await this.currencies.getCurrencyData(from, to)
this.rates[inverse] = 1 / rate
return this.rates[conversion] = rate
}
Expand Down
23 changes: 23 additions & 0 deletions datasources/alphavantage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as assert from "assert"
import {AlphaVantage} from "./alphavantage"

describe.skip("AlphaVantage Stock API", () => {
const alphaVantage = new AlphaVantage(process.env.ALPHAVANTAGE_API_KEY)

describe("getCurrencyData", () => {
it("should return currency data for a pair of currencies", async () => {
const rate = await alphaVantage.getCurrencyData("USD", "EUR")

assert.notEqual(rate, 0, "it should return a current price (i.e non-zero)")
})
})

describe("getStockData", () => {
it("should return data for a stock", async () => {
const data = await alphaVantage.getStockData("HCP")
assert.equal(data.symbol, "HCP", "it should return the correct company symbol")
assert.equal(data.currency, "USD", "it should return the correct data")
assert.ok(data.price, "it should return a current price")
})
})
})
97 changes: 97 additions & 0 deletions datasources/alphavantage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { buildUrl, fetchSafe } from "../utils/http"
import {CurrencyDataSource, DataSource, StockDataSource} from "./datasource"

export class AlphaVantage extends DataSource implements StockDataSource, CurrencyDataSource {
constructor(protected apiKey: string = process.env.ALPHAVANTAGE_API_KEY) {
super("alphavantage")
}

async getCompanyData(symbol: string): Promise<CompanyInfo> {
return await this.cached(
`company-${symbol}`,
"year",
() => fetchSafe(buildUrl(companyUrl, { SYMBOL: symbol.toUpperCase(), ALPHAVANTAGE_API_KEY: this.apiKey })))
}

async getStockData(symbol: string): Promise<StockQuote> {
/*
The API returns an object with the following structure:
{
"Global Quote": {
"01. symbol": "IBM",
"02. open": "133.2600",
"03. high": "134.6100",
"04. low": "133.1000",
"05. price": "134.2400",
"06. volume": "3168419",
"07. latest trading day": "2023-07-17",
"08. previous close": "133.4000",
"09. change": "0.8400",
"10. change percent": "0.6297%"
}
}
*/

const companyInfo = await this.getCompanyData(symbol.toUpperCase())

const response: any = await this.cached(
`stock-${symbol}`,
"day",
() => fetchSafe(buildUrl(stockUrl, { SYMBOL: symbol.toUpperCase(), ALPHAVANTAGE_API_KEY: this.apiKey })))

return {
symbol: companyInfo.Symbol,
currency: companyInfo.Currency,
price: parseFloat(response["Global Quote"]["05. price"])
}
}

async getCurrencyData(from: string, to: string): Promise<number> {
from = from.toUpperCase()
to = to.toUpperCase()

/*
The API returns an object with the following structure:
{
"Realtime Currency Exchange Rate": {
"1. From_Currency Code": "USD",
"2. From_Currency Name": "United States Dollar",
"3. To_Currency Code": "JPY",
"4. To_Currency Name": "Japanese Yen",
"5. Exchange Rate": "138.99100000",
"6. Last Refreshed": "2023-07-18 19:39:02",
"7. Time Zone": "UTC",
"8. Bid Price": "138.98920000",
"9. Ask Price": "138.99750000"
}
}
*/

const response: any = await this.cached(
`currency-${from}-${to}`,
"day",
() => fetchSafe(buildUrl(currencyUrl, { FROM_SYMBOL: from.toUpperCase(), TO_SYMBOL: to.toUpperCase(), ALPHAVANTAGE_API_KEY: this.apiKey })))

return parseFloat(response["Realtime Currency Exchange Rate"]["5. Exchange Rate"])
}
}

const companyUrl = "https://www.alphavantage.co/query?function=OVERVIEW&symbol={SYMBOL}&apikey={ALPHAVANTAGE_API_KEY}"

const stockUrl = "https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol={SYMBOL}&apikey={ALPHAVANTAGE_API_KEY}"

const currencyUrl = "https://www.alphavantage.co/query?function=CURRENCY_EXCHANGE_RATE&from_currency={FROM_SYMBOL}&to_currency={TO_SYMBOL}&apikey={ALPHAVANTAGE_API_KEY}"

interface CompanyInfo {
Symbol: string
Name: string
Exchange: string
Currency: string
}

interface StockQuote {
symbol: string,
currency: string
price: number
}
Loading

0 comments on commit 7317cd8

Please sign in to comment.