diff --git a/README.md b/README.md index 4b88171..dbfa402 100644 --- a/README.md +++ b/README.md @@ -407,3 +407,43 @@ node sync-bitcoin.js ``` It is recommended to run this script once per day or week. + +### Tracking Crypto Prices + +This script tracks the value of any cryptocurrency. Similar to the Bitcoin +script, it adds new transactions to keep the account balance equal to the +latest value. Values are retrieved from the Kraken API, but the script is +customizable to use other APIs if needed. + +To use, set these tags in the account notes: + +- `CRYPTO:,,...` - where each symbol is a cryptocurrency + you want to track, e.g. `CRYPTO:BTC,ETH,DOGE` +- `:[,]` - where `` is the cryptocurrency + symbol and `` is the amount you own. If using Kraken, specify the Kraken + pair to use for the price. Obtain this by visiting + [https://api.kraken.com/0/public/Ticker?pair=usd](https://api.kraken.com/0/public/Ticker?pair=btcusd) + and locating the key under `result` that matches your symbol. + +For example, if you own 0.01 BTC and 1 ETH, your account note would look like: +``` +CRYPTO:BTC,ETH +BTC:0.01,XXBTZUSD +ETH:1,XXETHZUSD +``` + +To use a different API, set the following in your `.env` file: (example using Bitcoin) +```shell +CRYPTO_PRICE_URL_BTC="https://api.kraken.com/0/public/Ticker?pair=xbtgbp" +CRYPTO_PRICE_JSON_PATH_BTC="result.XXBTZGBP.c[0]" +``` + +To run: + +```console +node sync-crypto.js +``` + +It is recommended to run this script once per day or week. If you are retrieving +BTC prices using this script, DO NOT run the `sync-bitcoin.js` script as well, as it will +set the account balance to the value of your BTC holdings only. \ No newline at end of file diff --git a/sync-crypto.js b/sync-crypto.js new file mode 100644 index 0000000..e91bf9b --- /dev/null +++ b/sync-crypto.js @@ -0,0 +1,111 @@ +const { closeBudget, openBudget, getTransactions, getAccountNote, getAccountBalance, getTagValue, ensurePayee } = require('./utils'); +const api = require('@actual-app/api'); + +function getValueAtPath(obj, path) { + const keys = path.split('.').filter(Boolean); + + return keys.reduce((acc, key) => { + const match = key.match(/^([^\[\]]+)(\[(\d+)\])?$/); + + if (match) { + const property = match[1]; + const index = match[3]; + + acc = acc[property]; + + if (index !== undefined) { + acc = acc[parseInt(index, 10)]; + } + } else { + acc = acc[key]; + } + + return acc; + }, obj); +} + +async function getCryptoPrice(crypto, krakenPath) { + const url = process.env[`CRYPTO_PRICE_URL_${crypto.toUpperCase()}`] || `https://api.kraken.com/0/public/Ticker?pair=${crypto}usd`; + if (!krakenPath && !process.env[`CRYPTO_PRICE_JSON_PATH_${crypto.toUpperCase()}`]) { + console.error(`No Kraken path provided for ${crypto}. Please set CRYPTO_PRICE_JSON_PATH_${crypto.toUpperCase()} environment variable or provide krakenPath argument.`); + return undefined; + } + const path = process.env[`CRYPTO_PRICE_JSON_PATH_${crypto.toUpperCase()}`] || `result.${krakenPath}.c[0]`; + try { + const response = await fetch(url); + const json = await response.json(); + return getValueAtPath(json, path); + } catch (error) { + console.error(`Error fetching price for ${crypto}:`, error); + return undefined; + } +} + +(async () => { + await openBudget(); + const payeeId = await ensurePayee(process.env.CRYPTO_PAYEE_NAME || 'Crypto Price Change'); + const accounts = await api.getAccounts(); + for (const account of accounts) { + if (account.closed) { + continue; + } + + // Need to set cutoff date to tomorrow to ensure we get the latest transactions + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() + 1); + + const note = await getAccountNote(account, cutoffDate); + + // Get cryptos list from the note + let cryptos = getTagValue(note, 'CRYPTO', null); + if (!cryptos) continue; + cryptos = cryptos.split(',').map(c => c.trim()).filter(c => c.length > 0); + if (cryptos.length === 0) continue; + + console.log(`Processing account: ${account.name}, Cryptos: ${cryptos.join(', ')}`); + + let totalTargetBalance = 0; + for (const crypto of cryptos) { + const cryptoTag = getTagValue(note, crypto, 0.0); + if (!cryptoTag) continue; + cryptoTagSplit = cryptoTag.split(','); + + // Get the amount from the first part of the tag + amount = parseFloat(cryptoTagSplit[0]); + if (isNaN(amount) || amount <= 0) continue; + console.log(`Crypto: ${crypto}, Amount: ${amount}`); + + // Get Kraken path from the second part of the tag, if it exists + let krakenPath = cryptoTagSplit[1] + console.log(`Kraken Path: ${krakenPath}`); + + // Get the crypto price + const cryptoPrice = await getCryptoPrice(crypto, krakenPath); + if (!cryptoPrice) { + console.error(`Unable to retrieve price for ${crypto}. Check your CRYPTO_PRICE_URL_${crypto.toUpperCase()} and CRYPTO_PRICE_JSON_PATH_${crypto.toUpperCase()} environment variables`); + continue; + } + + console.log(`Crypto: ${crypto}, Price: ${cryptoPrice}`); + + const targetBalance = Math.round(cryptoPrice * amount * 100); + totalTargetBalance += targetBalance; + } + + // Now we need to check if the total target balance is different from the current balance and update it + const currentBalance = await getAccountBalance(account, cutoffDate); + const diff = totalTargetBalance - currentBalance; + console.log(`Account: ${account.name}, Total Target Balance: ${totalTargetBalance}, Current Balance: ${currentBalance}, Diff: ${diff}`); + if (diff != 0) { + await api.importTransactions(account.id, [{ + date: new Date(), + payee: payeeId, + amount: diff, + cleared: true, + reconciled: true, + notes: `Updated Crypto Price on ${new Date().toLocaleString()} (${cryptos.join(', ')})`, + }]) + } + } + await closeBudget(); +})(); \ No newline at end of file