diff --git a/README.md b/README.md index 498273c..fc38d01 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # CrypFinder Bot -## Version 1.4 +## Version 1.5 ## CrypFinder Summary: -CrypFinder is a Coinbase Pro API trading bot that currently implements a basic momentum trading strategy in NodeJS using the Coinbase Pro API, as well as its own custom library for the endpoints that are not supported by the now deprecated Coinbase Pro NodeJS Library. Currently, Coinbase Pro limits the number of portfolios to five, this means that the bot can run up to four trading instances simultaneously per Coinbase Pro account. This bot can be modified to trade any product pairs available on Coinbase Pro, such as BTC-USD, ETH-USD, etc., but stablecoin (USDC to other coins) and crypto markets (coin to other coins) aren't currently supported, only USD markets (USD to coins). +CrypFinder is a Coinbase Pro API trading bot that currently implements a basic momentum trading strategy and reverse momentum trading strategy in NodeJS using the Coinbase Pro API, as well as its own custom library for the endpoints that are not supported by the now deprecated Coinbase Pro NodeJS Library. Currently, Coinbase Pro limits the number of portfolios to five, this means that the bot can run up to four trading instances simultaneously per Coinbase Pro account. This bot can be modified to trade any product pairs available on Coinbase Pro, such as BTC-USD, ETH-USD, etc., but stablecoin (USDC to other coins) and crypto markets (coin to other coins) aren't currently tested, only USD markets (USD to coins). -The currently implemented momentum strategy will work as follows: The bot will start by getting the amount of USD available for the provided API key's profile (profile=portfolio on the coinbase pro website). If the amount is greater than zero, it will monitor the price changes of the chosen product using a peak/valley system; if the price changes by the specified delta, it will purchase a position. Then, it will monitor price changes until the delta condition and profit condition are met; after selling for a profit, it can deposit a cut of the profit to a different portfolio for saving. +The momentum strategy will work as follows: The bot will start by getting the amount of USD available for the provided API key's profile (profile=portfolio on the coinbase pro website). If the amount is greater than zero, it will monitor the price changes of the chosen product using a peak/valley system; if the price changes by the specified delta, it will purchase a position. Then, it will monitor price changes until the delta condition and profit condition are met; after selling for a profit, it can deposit a cut of the profit to a different portfolio for saving. The reverse momentum trading strategy, is, as the name implies the reverse where it sells when the price goes up and buys when it goes down. The bot features a number of variables at the top that can be configured to customize how it trades. This includes the deltas that specify an amount of price change that will trigger a buy/sell, the minimum acceptable profit from a trade, the name of currency to trade, the profile names, the deposit enable/disable flag, the deposit amount, and more. Of course, any of the code can be modified to customize it fully. This project is a great way to trade crypto on Coinbase Pro through their API. @@ -54,8 +54,8 @@ Notice that the position acquired cost and price fields still exist in the file ## Running the program out of sandbox: When you're confident in the configuration/code base and want to run it in the real environment, comment out the sandbox env variables and uncomment out the real API URI variables. Update the .env file with a valid API key. You can run this program on your own machine or consider using a server such as an AWS EC2 instance with an EIP (you need to whitelist the API IP). AWS EC2 offers a free tier instance for a year that works well for hosting. -## Momentum trading strategy analyzer: -The momentumTradingAnalyzer is a way to run data against the momentum trading bot strategy to see how well it performs. It takes in a .csv file with OHLC data. Carston Klein has already compiled a massive dataset that is perfect for this task and it's available for free on Kaggle [check it out](https://www.kaggle.com/tencars/392-crypto-currency-pairs-at-minute-resolution?select=ampusd.csv). After downloading the file for the coin data you want, just trim the .csv file to the length of time you want to test and run the analyzer with the configuration you want and it will generate a report showing how it did. He also wrote [this article](https://medium.com/coinmonks/how-to-get-historical-crypto-currency-data-954062d40d2d) on how to get similar data yourself. +## Momentum and reverse momentum trading strategy analyzer: +The analyzers are a way to run data against the bot strategy to see how well it performs. It takes in a .csv file with OHLC data. Carston Klein has already compiled a massive dataset that is perfect for this task and it's available for free on Kaggle [check it out](https://www.kaggle.com/tencars/392-crypto-currency-pairs-at-minute-resolution?select=ampusd.csv). After downloading the file for the coin data you want, just trim the .csv file to the length of time you want to test and run the analyzer with the configuration you want and it will generate a report showing how it did. He also wrote [this article](https://medium.com/coinmonks/how-to-get-historical-crypto-currency-data-954062d40d2d) on how to get similar data yourself. ## Helpful links: [Coinbase Pro](https://pro.coinbase.com/trade/BTC-USD) @@ -67,10 +67,9 @@ The momentumTradingAnalyzer is a way to run data against the momentum trading bo [Flow diagram of the momentum strategy, open it in Google draw.io for best results (May be outdated, but can help to give an idea of how the program works)](https://drive.google.com/file/d/1sMg7nWcuCDwHS5wdwHgoe5qqODO7UEFA/view?usp=sharing) ## Roadmap: -- Implement a CLI (command line interface) to control the bot. This would make it so that users won't have to edit the code directly to configure and run the bot. - ### Possible future goals: - Add more strategies or make the current momentum strategy better. If making major changes to a current trading strategy, keep the old version and just add a new version of it to the same folder (momentumTradingV1, V2, etc). +- Implement a CLI (command line interface) to control the bot. This would make it so that users won't have to edit the code directly to configure and run the bot. ## Interested in the project?: Consider getting involved. Free to contact the creator on GitHub ([Levi Leuthold](https://github.com/LeviathanLevi)) for information on how to get started! Checkout the product roadmap to see what features are currently planned for the future or add your own ideas. diff --git a/index.js b/index.js index 7ec5649..584a32e 100644 --- a/index.js +++ b/index.js @@ -1,16 +1,27 @@ /* eslint-disable no-unused-vars */ /* -* This is the entry point of program. Currently, there is just one strategy but this will be the place where -* a specific strategy would be selected to start the program with. In the future, this project could use -* a command line interface here for controlling it. +* This is the entry point of program. Select the strategy or analyzer(s) */ const momentumStrategyStart = require("./strategies/momentumTrading/momentumTrading"); const momentumStrategyAnalyzerStart = require("./strategies/momentumTrading/momentumTradingAnalyzer"); -//Make sure to configure the momentumStrategy in ./strategies/momentumTrading/momentumTrading.js before launching +const reverseMomentumStrategyStart = require("./strategies/reverseMomentumTrading/reverseMomentumTrading"); +const reverseMomentumStrategyAnalyzerStart = require("./strategies/reverseMomentumTrading/reverseMomentumTradingAnalyzer"); + + +/*** Make sure to configure the momentumStrategy in ./strategies/momentumTrading/momentumTrading.js before launching ***/ //Launches the momentum strategy and starts the bot: momentumStrategyStart(); //Launches the momentum strategy anaylzer for back testing: -//momentumStrategyAnalyzerStart(); \ No newline at end of file +//momentumStrategyAnalyzerStart(); + + + +/*** Make sure to configure the momentumStrategy in ./strategies/momentumTrading/momentumTrading.js before launching ***/ +//Launches the reverse momentum strategy and starts the bot: +//reverseMomentumStrategyStart(); + +//Launches the reverse momentum strategy anaylzer for back testing: +//reverseMomentumStrategyAnalyzerStart(); \ No newline at end of file diff --git a/strategies/momentumTrading/momentumTrading.js b/strategies/momentumTrading/momentumTrading.js index b9e1d32..21e0879 100644 --- a/strategies/momentumTrading/momentumTrading.js +++ b/strategies/momentumTrading/momentumTrading.js @@ -23,7 +23,7 @@ const websocketURI = "wss://ws-feed-public.sandbox.pro.coinbase.com"; //Trading config: //Global constants, consider tuning these values to optimize the bot's trading: const sellPositionDelta = .02; //The amount of change between peak and valley to trigger a sell off -const buyPositionDelta = .015; //The amount of change between the peak and valley price to trigger a buy in +const buyPositionDelta = .015; //The amount of change between the valley and peak price to trigger a buy in const orderPriceDelta = .001; //The amount of extra room to give the sell/buy orders to go through //Currency config: diff --git a/strategies/momentumTrading/momentumTrading.md b/strategies/momentumTrading/momentumTrading.md new file mode 100644 index 0000000..950f8f4 --- /dev/null +++ b/strategies/momentumTrading/momentumTrading.md @@ -0,0 +1,4 @@ +# Momentum Trading Strategy + +## Summary: +This trading strategy works specifying a buy and sell delta. When the bot starts it waits for the buy delta condition to be reached. Say for example the buy delta is 2%, once the price rises to meet that delta then the boy will trigger a buy in. The idea behind momentum trading is that when the price goes up there's momentum that will continue to rise up. The Sell delta price is the opposite of the buy, where when the price drops by that amount it will sell out. \ No newline at end of file diff --git a/strategies/reverseMomentumTrading/buyAndSell.js b/strategies/reverseMomentumTrading/buyAndSell.js new file mode 100644 index 0000000..1a26509 --- /dev/null +++ b/strategies/reverseMomentumTrading/buyAndSell.js @@ -0,0 +1,224 @@ +/* +* This module contains methods to buy a position and sell a position. It uses a limit order then loops checking the order +* status until the order either completes, OR after 1 minute it will cancel the order. +*/ +const pino = require("pino"); +const logger = pino({ level: process.env.LOG_LEVEL || "info" }); +const fileSystem = require("fs"); + +/** + * Halts the program from running temporarily to prevent it from hitting API call limits + * + * @param {number} ms -> the number of miliseconds to wait + */ +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * Places a sell limit order then loops to check the order status until the order is filled. Once filled, the method updates the positionInfo, does any depositing based on the + * depositConfig, then ends. If the Order is done for a reason other than filled, or a profit was not made then the method throws an exception. If the order doesn't get filled + * in the alloted time span (1 minute) then the method cancels the order and throws an exception. + * + * @param {Number} balance + * @param {Object} accountIds + * @param {Object} positionInfo + * @param {Number} currentPrice + * @param {Object} authedClient + * @param {Object} coinbaseLibObject + * @param {Object} productInfo + * @param {Object} depositConfig + * @param {Object} tradingConfig + */ +async function sellPosition(balance, accountIds, positionInfo, currentPrice, authedClient, coinbaseLibObject, productInfo, depositConfig, tradingConfig) { + try { + const priceToSell = (currentPrice - (currentPrice * tradingConfig.orderPriceDelta)).toFixed(productInfo.quoteIncrementRoundValue); + + let orderSize; + if (productInfo.baseIncrementRoundValue === 0) { + orderSize = Math.trunc(balance); + } else { + orderSize = (balance).toFixed(productInfo.baseIncrementRoundValue); + } + + const orderParams = { + side: "sell", + price: priceToSell, + size: orderSize, + product_id: productInfo.productPair, + time_in_force: "FOK" + }; + + logger.info("Sell order params: " + JSON.stringify(orderParams)); + + //Place sell order + const order = await authedClient.placeOrder(orderParams); + logger.debug(order); + const orderID = order.id; + + //Loop to wait for order to be filled: + for (let i = 0; i < 10 && positionInfo.positionExists === true; ++i) { + let orderDetails; + logger.debug("Checking sell order result..."); + await sleep(6000); //wait 6 seconds + try { + orderDetails = await authedClient.getOrder(orderID); //Get latest order details + } catch(err) { + const message = "Error occured when attempting to get the order."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + continue; + } + logger.debug(orderDetails); + + if (orderDetails.status === "done") { + if (orderDetails.done_reason !== "filled") { + throw new Error("Sell order did not complete due to being filled? done_reason: " + orderDetails.done_reason); + } else { + positionInfo.positionExists = false; + + //Update positionData file: + try { + const writeData = JSON.stringify(positionInfo); + fileSystem.writeFileSync("positionData.json", writeData); + } catch(err) { + const message = "Error, failed to write the positionInfo to the positionData file in sellPosition. Continuing as normal but but positionDataTracking might not work correctly."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + } + + let profit = parseFloat(orderDetails.executed_value) - parseFloat(orderDetails.fill_fees) - positionInfo.positionAcquiredCost; + logger.info("Profit: " + profit); + + if (profit > 0) { + //Check deposit config: + if (depositConfig.depositingEnabled) { + const transferAmount = (profit * depositConfig.depositingAmount).toFixed(2); + const currency = productInfo.quoteCurrency; + + //Transfer funds to depositProfileID + const transferResult = await coinbaseLibObject.profileTransfer(accountIds.tradeProfileID, accountIds.depositProfileID, currency, transferAmount); + + logger.debug("transfer result: " + transferResult); + } + } else { + throw new Error("Sell was not profitable, terminating program. profit: " + profit); + } + } + } + } + + //Check if order wasn't filled and needs cancelled: + if (positionInfo.positionExists === true) { + const cancelOrder = await authedClient.cancelOrder(orderID); + if (cancelOrder !== orderID) { + throw new Error("Attempted to cancel failed order but it did not work. cancelOrderReturn: " + cancelOrder + "orderID: " + orderID); + } + } + + } catch (err) { + const message = "Error occured in sellPosition method."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + } +} + +/** + * This method places a buy limit order and loops waiting for it to be filled. Once filled it will update the positionInfo and end. If the + * order ends for a reason other then filled it will throw an exception. If the order doesn't get filled after 1 minute it will cancel the + * order and throw an exception. + * + * @param {Number} balance + * @param {Object} positionInfo + * @param {Number} currentPrice + * @param {Object} authedClient + * @param {Object} productInfo + * @param {Object} tradingConfig + */ +async function buyPosition(balance, positionInfo, currentPrice, authedClient, productInfo, tradingConfig) { + try { + const amountToSpend = balance - (balance * tradingConfig.highestFee); + const priceToBuy = (currentPrice + (currentPrice * tradingConfig.orderPriceDelta)).toFixed(productInfo.quoteIncrementRoundValue); + let orderSize; + + if (productInfo.baseIncrementRoundValue === 0) { + orderSize = Math.trunc(amountToSpend / priceToBuy); + } else { + orderSize = (amountToSpend / priceToBuy).toFixed(productInfo.baseIncrementRoundValue); + } + + const orderParams = { + side: "buy", + price: priceToBuy, + size: orderSize, + product_id: productInfo.productPair, + time_in_force: "FOK" + }; + + logger.info("Buy order params: " + JSON.stringify(orderParams)); + + //Place buy order + const order = await authedClient.placeOrder(orderParams); + logger.debug(order); + const orderID = order.id; + + //Loop to wait for order to be filled: + for (let i = 0; i < 10 && positionInfo.positionExists === false; ++i) { + let orderDetails; + logger.debug("Checking buy order result..."); + await sleep(6000); //wait 6 seconds + try { + orderDetails = await authedClient.getOrder(orderID); //Get latest order details + } catch(err) { + const message = "Error occured when attempting to get the order."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + continue; + } + logger.debug(orderDetails); + + if (orderDetails.status === "done") { + if (orderDetails.done_reason !== "filled") { + throw new Error("Buy order did not complete due to being filled? done_reason: " + orderDetails.done_reason); + } else { + //Update position info + positionInfo.positionExists = true; + positionInfo.positionAcquiredPrice = parseFloat(orderDetails.executed_value) / parseFloat(orderDetails.filled_size); + positionInfo.positionAcquiredCost = parseFloat(orderDetails.executed_value) + parseFloat(orderDetails.fill_fees); + + //Update positionData file: + try { + const writeData = JSON.stringify(positionInfo); + fileSystem.writeFileSync("positionData.json", writeData); + } catch(err) { + const message = "Error, failed to write the positionInfo to the positionData file in buyPosition. Continuing as normal but but positionDataTracking might not work correctly."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + } + + logger.info(positionInfo); + } + } + } + + //Check if order wasn't filled and needs cancelled + if (positionInfo.positionExists === false) { + const cancelOrder = await authedClient.cancelOrder(orderID); + if (cancelOrder !== orderID) { + throw new Error("Attempted to cancel failed order but it did not work. cancelOrderReturn: " + cancelOrder + "orderID: " + orderID); + } + } + + } catch (err) { + const message = "Error occured in buyPosition method."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + } +} + +module.exports = { + sellPosition, + buyPosition, +} \ No newline at end of file diff --git a/strategies/reverseMomentumTrading/reverseMomentumTrading.js b/strategies/reverseMomentumTrading/reverseMomentumTrading.js new file mode 100644 index 0000000..229676c --- /dev/null +++ b/strategies/reverseMomentumTrading/reverseMomentumTrading.js @@ -0,0 +1,474 @@ +const CoinbasePro = require("coinbase-pro"); +require('dotenv').config() +const {buyPosition, sellPosition} = require("./buyAndSell"); +const coinbaseProLib = require("../../coinbaseProLibrary"); +const pino = require("pino"); +const logger = pino({ level: process.env.LOG_LEVEL || "info" }); +const fileSystem = require("fs"); + +const key = `${process.env.API_KEY}`; +const secret = `${process.env.API_SECRET}`; +const passphrase = `${process.env.API_PASSPHRASE}`; + +//******************** Setup these value configurations before running the program ****************************************** + +//Real environment (uncomment out if using in the real enviornment WARNING: you can lose real money, use at your own risk): +//const apiURI = "https://api.pro.coinbase.com"; +//const websocketURI = "wss://ws-feed.pro.coinbase.com"; + +//Sandbox environment (uncomment out if using the sandbox for testing): +const apiURI = "https://api-public.sandbox.pro.coinbase.com"; +const websocketURI = "wss://ws-feed-public.sandbox.pro.coinbase.com"; + +//Trading config: +//Global constants, consider tuning these values to optimize the bot's trading: +const sellPositionDelta = .01; //The amount of change between valley and peak to trigger a sell off +const buyPositionDelta = .001; //The amount of change between the peak and valley price to trigger a buy in +const orderPriceDelta = .001; //The amount of extra room to give the sell/buy orders to go through + +//Currency config: +//The pieces of the product pair, this is the two halves of coinbase product pair (examples of product pairs: BTC-USD, DASH-BTC, ETH-USDC). For BTC-USD the base currency is BTC and the quote currency is USD +const baseCurrencyName = "BTC"; +const quoteCurrencyName = "USD"; + +//Profile config: +//Coinbase portfolios (profiles): +const tradingProfileName = "BTC trader"; //This is the name of the profile you want the bot to trade in +const depositProfileName = "default"; //This is the name of the profile you want to deposit some profits to + +//Deposit config: +const depositingEnabled = true; //Choose whether or not you want you want to deposit a cut of the profits (Options: true/false) +const depositingAmount = 0.5; //Enter the amount of profit you want deposited (Options: choose a percent between 1 and 100 in decimal form I.E. .5 = 50%) + +// Due to rounding errors the buy order may not have enough funds to execute the order. This is the minimum funds amount in dollars that +// will be left in usd account to avoid this error. Default = 6 cents (.06). +const balanceMinimum = .06; + +//*************************************************************************************************************************** + +//authedClient used to the API calls supported by the coinbase pro api node library +let authedClient = new CoinbasePro.AuthenticatedClient( + key, + secret, + passphrase, + apiURI +); + +//Custom coinbase library used for making the calls not supported by the coinbase pro api node library +const coinbaseLibObject = new coinbaseProLib(key, secret, passphrase, apiURI); + +//Global variable tracks the currentPrice. Updated by the websocket +let currentPrice; + +/** + * Makes the program sleep to avoid hitting API limits and let the websocket update + * + * @param {number} ms -> the number of miliseconds to wait + */ +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * Creates the websocket object and turns it on to update the currentPrice + * + * @param {string} productPair + */ +function listenForPriceUpdates(productPair) { + if (productPair == null) { + throw new Error("Error in listenForPriceUpdates method. ProductPair is null!"); + } + + // The websocket client provides price updates on the product, refer to the docs for more information + const websocket = new CoinbasePro.WebsocketClient( + [productPair], + websocketURI, + { + key, + secret, + passphrase, + }, + { channels: ["ticker"] } + ); + + //turn on the websocket for errors + websocket.on("error", function(err) { + const message = "Error occured in the websocket."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + listenForPriceUpdates(productPair); + }); + + //Turn on the websocket for closes to restart it + websocket.on("close", function() { + logger.debug("WebSocket closed, restarting..."); + listenForPriceUpdates(productPair); + }); + + //Turn on the websocket for messages + websocket.on("message", function(data) { + if (data.type === "ticker") { + if (currentPrice !== data.price) { + currentPrice = parseFloat(data.price); + logger.debug("Ticker price: " + currentPrice); + } + } + }); +} + +/** + * Loops forever until the conditions are right to attempt to sell the position. Every loop sleeps to let the currentPrice update + * then updates the lastPeak/lastValley price as appropiate, if the price hits a new valley price it will check if the conditions are + * met to sell the position and call the method if appropiate. + * + * @param {number} balance The amount of currency being traded with + * @param {number} lastPeakPrice Tracks the price highs + * @param {number} lastValleyPrice Tracks the price lows + * @param {Object} accountIds The coinbase account ID associated with the API key used for storing a chunk of the profits in coinbase + * @param {Object} positionInfo Contains 3 fields, positionExists (bool), positionAcquiredPrice (number), and positionAcquiredCost(number) + * @param {Object} productInfo Contains information about the quote/base increment for the product pair + * @param {Object} depositConfig Conatins information about whether to do a deposit and for how much after a sell + * @param {Object} tradingConfig Contains information about the fees and deltas + */ +async function losePosition(balance, lastPeakPrice, lastValleyPrice, accountIds, positionInfo, productInfo, depositConfig, tradingConfig) { + try { + while (positionInfo.positionExists === true) { + await sleep(250); //Let price update + + if (lastPeakPrice < currentPrice) { + //New peak hit, track peak price and check buy conditions + lastPeakPrice = currentPrice; + + const target = lastValleyPrice + (lastValleyPrice * sellPositionDelta); + const lowestSellPrice = lastPeakPrice - (lastPeakPrice * orderPriceDelta); + const receivedValue = (lowestSellPrice * balance) - ((lowestSellPrice * balance) * tradingConfig.highestFee); + + logger.debug(`Sell Position, current price: ${lastPeakPrice} needs to be greater than or equal to ${target} to sell and the receivedValue: ${receivedValue} needs to be greater than the positionAcquiredCost: ${positionInfo.positionAcquiredCost}`); + + if ((lastPeakPrice >= target) && (receivedValue > positionInfo.positionAcquiredCost)) { + logger.info("Attempting to sell position..."); + + //Create a new authenticated client to prevent it from expiring or hitting API limits + authedClient = new CoinbasePro.AuthenticatedClient( + key, + secret, + passphrase, + apiURI + ); + + await sellPosition(balance, accountIds, positionInfo, lastValleyPrice, authedClient, coinbaseLibObject, productInfo, depositConfig, tradingConfig); + } + } else if (lastValleyPrice > currentPrice) { + //New valley hit, reset values + lastPeakPrice = currentPrice; + lastValleyPrice = currentPrice; + } + } + } catch (err) { + const message = "Error occured in losePosition method."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + throw err; + } +} + +/** + * Loops forever until the conditions are right to attempt to buy a position. Every loop sleeps to let the currentPrice update + * then updates the lastPeak/lastValley price as appropiate, if the price hits a new peak price it will check if the conditions are + * met to buy the position and call the method if appropiate. + * + * @param {number} balance The amount of currency being traded with + * @param {number} lastPeakPrice Tracks the price highs + * @param {number} lastValleyPrice Tracks the price lows + * @param {Object} positionInfo Contains 3 fields, positionExists (bool), positionAcquiredPrice (number), and positionAcquiredCost(number) + * @param {Object} productInfo Contains information about the quote/base increment for the product pair + * @param {Object} tradingConfig Contains information about the fees and deltas + */ +async function gainPosition(balance, lastPeakPrice, lastValleyPrice, positionInfo, productInfo, tradingConfig) { + try { + while (positionInfo.positionExists === false) { + await sleep(250); //Let price update + + if (lastPeakPrice < currentPrice) { + //New peak hit, reset values + lastPeakPrice = currentPrice; + lastValleyPrice = currentPrice; + } else if (lastValleyPrice > currentPrice) { + //New valley hit, track valley and check buy conditions + lastValleyPrice = currentPrice; + + const target = lastPeakPrice - (lastPeakPrice * buyPositionDelta); + + logger.debug(`Buy Position, current price: ${lastValleyPrice} needs to be less than or equal to ${target} to buy`); + + if (lastValleyPrice <= target) { + logger.info("Attempting to buy position..."); + + //Create a new authenticated client to prevent it from expiring or hitting API limits + authedClient = new CoinbasePro.AuthenticatedClient( + key, + secret, + passphrase, + apiURI + ); + + await buyPosition(balance, positionInfo, lastPeakPrice, authedClient, productInfo, tradingConfig); + } + } + } + } catch (err) { + const message = "Error occured in gainPosition method."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + throw err; + } +} + +/** + * Acquires some account ID information to be used for storing and retrieving information and depositing funds after a sell. + * + * @param {Object} productInfo productInfo contains the base and quote currencies being traded with needed to grab the correct account IDs + * @return {Object} accountObject contains the needed account IDs and profile IDs needed for checking balances and making transfers + */ +async function getAccountIDs(productInfo) { + try { + let accountObject = {}; + + //Gets the account IDs for the product pairs in the portfolio + const accounts = await authedClient.getAccounts(); + + for (let i = 0; i < accounts.length; ++i) { + if (accounts[i].currency === productInfo.baseCurrency) { + accountObject.baseCurrencyAccountID = accounts[i].id; + } else if (accounts[i].currency === productInfo.quoteCurrency) { + accountObject.quoteCurrencyAccountID = accounts[i].id; + } + } + + //Gets all the profiles belonging to the user and matches the deposit and trading profile IDs + const profiles = await coinbaseLibObject.getProfiles(); + + for (let i = 0; i < profiles.length; ++i) { + if (profiles[i].name === depositProfileName) { + accountObject.depositProfileID = profiles[i].id; + } else if (profiles[i].name === tradingProfileName) { + accountObject.tradeProfileID = profiles[i].id; + } + } + + if (!accountObject.depositProfileID) { + throw new Error(`Could not find the deposit profile ID. Ensure that the depositProfileName: "${depositProfileName}" is spelt correctly.`) + } + if (!accountObject.tradeProfileID) { + throw new Error(`Could not find the trade profile ID. Ensure that the tradingProfileName: "${tradingProfileName}" is spelt correctly.`) + } + + return accountObject; + } catch (err) { + const message = "Error occured in getAccountIDs method."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + throw err; + } +} + +/** + * Gets information about the product being traded that the bot can use to determine how + * accurate the size and quote values for the order needs to be. This method parses the base and quote increment + * strings in order to determine to what precision the size and price parameters need to be when placing an order. + * + * @param {object} productInfo This object gets updated directly + */ +async function getProductInfo(productInfo) { + try { + let quoteIncrementRoundValue = 0; + let baseIncrementRoundValue = 0; + let productPairData; + + const products = await authedClient.getProducts(); + + for (let i = 0; i < products.length; ++i) { + if (products[i].id === productInfo.productPair) { + productPairData = products[i]; + } + } + + if (productPairData === undefined) { + throw new Error(`Error, could not find a valid matching product pair for "${productInfo.productPair}". Verify the product names is correct/exists.`); + } + + for (let i = 2; i < productPairData.quote_increment.length; ++i) { + if (productPairData.quote_increment[i] === "1") { + quoteIncrementRoundValue++; + break; + } else { + quoteIncrementRoundValue++; + } + } + + if (productPairData.base_increment[0] !== "1") { + for (let i = 2; i < productPairData.base_increment.length; ++i) { + if (productPairData.base_increment[i] === "1") { + baseIncrementRoundValue++; + break; + } else { + baseIncrementRoundValue++; + } + } + } + + productInfo.quoteIncrementRoundValue = Number(quoteIncrementRoundValue); + productInfo.baseIncrementRoundValue = Number(baseIncrementRoundValue); + } catch (err) { + const message = "Error occured in getProfuctInfo method."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + throw err; + } +} + +/** + * Retrieves the current maker and taker fees and returns the highest one as a number + * + * @param {number} highestFee The highest fee between the taker and maker fee + */ +async function returnHighestFee(){ + try { + const feeResult = await coinbaseLibObject.getFees(); + + let makerFee = parseFloat(feeResult.maker_fee_rate); + let takerFee = parseFloat(feeResult.taker_fee_rate); + + if (makerFee > takerFee) { + return makerFee; + } else { + return takerFee; + } + } + catch (err) { + const message = "Error occured in getFees method."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + throw err; + } +} + +/** + * This method is the entry point of the momentum strategy. It does some first time initialization then begins an infinite loop. + * The loop checks the position info to decide if the bot needs to try and buy or sell, it also checks if there's an available + * balance to be traded with. Then it calls gainPosition or losePosition appropiately and waits for them to finish and repeats. + */ +async function reverseMomentumStrategyStart() { + try { + let accountIDs = {}; + let lastPeakPrice; + let lastValleyPrice; + let highestFee = await returnHighestFee(); + + const tradingConfig = { + sellPositionDelta, + buyPositionDelta, + orderPriceDelta, + highestFee + }; + + const depositConfig = { + depositingEnabled, + depositingAmount + }; + + const productInfo = { + baseCurrency: baseCurrencyName, + quoteCurrency: quoteCurrencyName, + productPair: baseCurrencyName + "-" + quoteCurrencyName + }; + + let positionInfo; + + //Check for an existing positionData file to start the bot with: + try { + //read positionData file: + let rawFileData = fileSystem.readFileSync("positionData.json"); + positionInfo = JSON.parse(rawFileData); + logger.info("Found positionData.json file, starting with position data. Position data: " + JSON.stringify(positionInfo)); + } catch (err) { + if (err.code === "ENOENT") { + logger.info("No positionData file found, starting with no existing position."); + } else { + const message = "Error, failed to read file for a reason other than it doesn't exist. Continuing as normal but positionDataTracking might not work correctly."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + } + + positionInfo = { + positionExists: false, + }; + } + + //Retrieve product information: + await getProductInfo(productInfo); + logger.info(productInfo); + + //Retrieve account IDs: + accountIDs = await getAccountIDs(productInfo); + logger.info(accountIDs) + + //activate websocket for price data: + listenForPriceUpdates(productInfo.productPair); + + while (currentPrice == null) { + await sleep(1000); //Get a price before starting + } + + logger.info(`Starting price of ${productInfo.baseCurrency} in ${productInfo.quoteCurrency} is: ${currentPrice}`); + + // eslint-disable-next-line no-constant-condition + while (true) { + if (positionInfo.positionExists) { + tradingConfig.highestFee = await returnHighestFee(); + await sleep(1000); + const baseCurrencyAccount = await authedClient.getAccount(accountIDs.baseCurrencyAccountID); //Grab account information to view balance + + if (baseCurrencyAccount.available > 0) { + logger.info("Entering lose position with: " + baseCurrencyAccount.available + " " + productInfo.baseCurrency); + + lastPeakPrice = currentPrice; + lastValleyPrice = currentPrice; + + //Begin trying to sell position: + await losePosition(parseFloat(baseCurrencyAccount.available), lastPeakPrice, lastValleyPrice, accountIDs, positionInfo, productInfo, depositConfig, tradingConfig); + } else { + throw new Error(`Error, there is no ${productInfo.baseCurrency} balance available for use. Terminating program.`); + } + } else { + tradingConfig.highestFee = await returnHighestFee(); + await sleep(1000); + const quoteCurrencyAccount = await authedClient.getAccount(accountIDs.quoteCurrencyAccountID); //Grab account information to view balance + const availableBalance = parseFloat(quoteCurrencyAccount.available); + + if (availableBalance > 0) { + const tradeBalance = availableBalance - balanceMinimum; //Subtract this dollar amount so that there is room for rounding errors + + logger.info("Entering gain position with: " + tradeBalance + " " + productInfo.quoteCurrency); + + lastPeakPrice = currentPrice; + lastValleyPrice = currentPrice; + + //Begin trying to buy a position: + await gainPosition(tradeBalance, lastPeakPrice, lastValleyPrice, positionInfo, productInfo, tradingConfig); + } else { + throw new Error(`Error, there is no ${productInfo.quoteCurrency} balance available for use. Terminating program.`); + } + } + } + } catch (err) { + const message = "Error occured in bot, shutting down. Check the logs for more information."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + process.exit(1); + } +} + +module.exports = reverseMomentumStrategyStart; \ No newline at end of file diff --git a/strategies/reverseMomentumTrading/reverseMomentumTrading.md b/strategies/reverseMomentumTrading/reverseMomentumTrading.md new file mode 100644 index 0000000..8b75787 --- /dev/null +++ b/strategies/reverseMomentumTrading/reverseMomentumTrading.md @@ -0,0 +1,4 @@ +# Momentum Trading Strategy + +## Summary: +This trading strategy works specifying a buy and sell delta. The buy delta is how far the price has to drop before it buys in. The sell price is how far the price has to rise before the bot sells out. This is the reverse of the momentum trading strategy. \ No newline at end of file diff --git a/strategies/reverseMomentumTrading/reverseMomentumTradingAnalyzer.js b/strategies/reverseMomentumTrading/reverseMomentumTradingAnalyzer.js new file mode 100644 index 0000000..50db9ff --- /dev/null +++ b/strategies/reverseMomentumTrading/reverseMomentumTradingAnalyzer.js @@ -0,0 +1,235 @@ +/* + * Summary: The momentumTradingAnalyzer reads a CSV file for prices and runs the bot with a given configuration. + * After processing all of the price history the analyzer gives report containing how much profit it made, how many trades + * it made, etc. This can be used to test the bot against historical date and get an idea of how it performs with a specfic setup. + * Consider creating a loop to test a range of values and let the analyzer figure out the most optimal trade configuration. + * + * For more information regarding the type of data and files that it's setup to use, see the readme. + */ +require('dotenv').config() +const pino = require("pino"); +const logger = pino({ level: process.env.LOG_LEVEL || "info" }); +const fileSystem = require("fs").promises; +const csvParser = require("csv-parse/lib/sync"); + +//***************Trade configuration***************** + +//The name of the file containing the data to be tested: +const dataFileName = "btcusd.csv"; + +//The bot trading config values (See momentumTrading.js for more information on these values): +const tradingConfig = { + startingBalance: 500, //Amount of cash the bot starts with + sellPositionDelta: .1, + buyPositionDelta: .01, + orderPriceDelta: .001, + highestFee: .005, + depositingEnabled: false //Whether or not the profits are deposited or re-invested +}; + +//*************************************************** + +/** + * See losePosition in momentumTrading.js for more information, this is the same but for the analyzer + * + * @param {object} positionInfo + * @param {object} tradingConfig + * @param {object} priceInfo + * @param {object} report + */ +async function losePosition(positionInfo, tradingConfig, priceInfo, report) { + try { + if (priceInfo.lastPeakPrice < priceInfo.currentPrice) { + priceInfo.lastPeakPrice = priceInfo.currentPrice; + + const target = priceInfo.lastValleyPrice + (priceInfo.lastValleyPrice * tradingConfig.sellPositionDelta); + const lowestSellPrice = priceInfo.currentPrice - (priceInfo.currentPrice * tradingConfig.orderPriceDelta); + const receivedValue = (lowestSellPrice * positionInfo.assetAmount) - ((lowestSellPrice * positionInfo.assetAmount) * tradingConfig.highestFee); + + if ((priceInfo.lastPeakPrice >= target) && (receivedValue > positionInfo.positionAcquiredCost)) { + //Sell position: + logger.debug(`Sell position price: ${priceInfo.currentPrice}`); + report.numberOfSells += 1; + + if (tradingConfig.depositingEnabled) { + const profit = (positionInfo.assetAmount * priceInfo.currentPrice) - (tradingConfig.highestFee * (positionInfo.assetAmount * priceInfo.currentPrice)) - positionInfo.positionAcquiredCost; + report.amountOfProfitGenerated += profit; + logger.debug(`amount of profit: ${report.amountOfProfitGenerated}`); + + logger.debug(`profit: ${profit}`); + + positionInfo.fiatBalance = (positionInfo.assetAmount * priceInfo.currentPrice) - ((positionInfo.assetAmount * priceInfo.currentPrice) * tradingConfig.highestFee) - profit; + } else { + positionInfo.fiatBalance = (positionInfo.assetAmount * priceInfo.currentPrice) - ((positionInfo.assetAmount * priceInfo.currentPrice) * tradingConfig.highestFee); + } + + positionInfo.assetAmount = 0; + positionInfo.positionExists = false; + positionInfo.positionAcquiredPrice = 0; + positionInfo.positionAcquiredCost = 0; + + logger.debug(`Position info after sell: ${JSON.stringify(positionInfo)}`); + } + } else if (priceInfo.lastValleyPrice > priceInfo.currentPrice) { + priceInfo.lastPeakPrice = priceInfo.currentPrice; + priceInfo.lastValleyPrice = priceInfo.currentPrice; + } + } catch (err) { + const message = "Error occured in losePosition method."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + throw err; + } +} + +/** + * See gainPosition in momentumTrading.js for more information, this is the same but for the analyzer + * + * @param {object} positionInfo + * @param {object} tradingConfig + * @param {object} priceInfo + * @param {object} report + */ +async function gainPosition(positionInfo, tradingConfig, priceInfo, report) { + try { + if (priceInfo.lastPeakPrice < priceInfo.currentPrice) { + priceInfo.lastPeakPrice = priceInfo.currentPrice; + priceInfo.lastValleyPrice = priceInfo.currentPrice; + } else if (priceInfo.lastValleyPrice > priceInfo.currentPrice) { + priceInfo.lastValleyPrice = priceInfo.currentPrice; + + const target = priceInfo.lastPeakPrice - (priceInfo.lastPeakPrice * tradingConfig.buyPositionDelta); + + if (priceInfo.lastValleyPrice <= target) { + //buy position: + logger.debug(`Buy position price: ${priceInfo.currentPrice}`); + report.numberOfBuys += 1; + + positionInfo.positionAcquiredCost = positionInfo.fiatBalance; + positionInfo.assetAmount = (positionInfo.fiatBalance - (positionInfo.fiatBalance * tradingConfig.highestFee)) / priceInfo.currentPrice; + positionInfo.positionAcquiredPrice = priceInfo.currentPrice; + positionInfo.fiatBalance = 0; + positionInfo.positionExists = true; + + logger.debug(`Position info after buy: ${JSON.stringify(positionInfo)}`); + } + } + } catch (err) { + const message = "Error occured in gainPosition method."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + throw err; + } +} + +/** + * Entry point, sets up and calls the analyze strategy method to begin. + * This is the method someone could use to setup loops to test a range of trading config values to + * find the optimal configuration for a given set of data. + */ +async function reverseMomentumStrategyAnalyzerStart() { + try { + //Run once: + const report = await analyzeStrategy(tradingConfig, dataFileName); + logger.info(report); + + //Instead of running it once someone could configure it to run loops for a given range of values to find the most optimal config + //Just setup the tradingConfig to be your starting values then let the loops increment the values and run the report then compare for the most profitable + //Example: + + // let highestProfit = {}; + // let tradingConfigCopy = Object.assign({}, tradingConfig); + + // //baseline: + // const report = await analyzeStrategy(tradingConfig, dataFileName); + // highestProfit.report = report; + // highestProfit.configuration = Object.assign({}, tradingConfig); + + // for (let i = 0; i < 50; i += 1) { + // tradingConfigCopy.buyPositionDelta = tradingConfig.buyPositionDelta; + + // for (let j = 0; j < 50; j += 1) { + // logger.debug(tradingConfig); + + // const report = await analyzeStrategy(tradingConfigCopy, dataFileName); + + // if (highestProfit.report.amountOfProfitGenerated < report.amountOfProfitGenerated) { + // highestProfit.report = report; + // highestProfit.configuration = Object.assign({}, tradingConfigCopy); + + // logger.info(highestProfit); + // } + + // tradingConfigCopy.buyPositionDelta += .001; + // } + + // tradingConfigCopy.sellPositionDelta += .001; + // } + + // logger.info("Final Report:"); + // logger.info(highestProfit); + + } catch (err) { + const message = "Error occured in momentumStrategyAnalyzerStart method, shutting down. Check the logs for more information."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + process.exit(1); + } +} + +/** + * Tests the given tradingConfig against the data in the file to then returns a report on the results + * + * @param {object} tradingConfig + * @param {string} dataFileName + */ +async function analyzeStrategy(tradingConfig, dataFileName) { + try { + let report = { + numberOfBuys: 0, + numberOfSells: 0, + amountOfProfitGenerated: 0 + }; + let positionInfo = { + positionExists: false, + fiatBalance: tradingConfig.startingBalance + } + + const fileContent = await fileSystem.readFile(dataFileName); + const records = csvParser(fileContent, {columns: true}); + + const priceInfo = { + currentPrice: parseFloat(records[0].high), + lastPeakPrice: parseFloat(records[0].high), + lastValleyPrice: parseFloat(records[0].high) + }; + + for (let i = 1; i < records.length; ++i) { + priceInfo.currentPrice = parseFloat(records[i].high); + + if (positionInfo.positionExists) { + await losePosition(positionInfo, tradingConfig, priceInfo, report); + } else { + await gainPosition(positionInfo, tradingConfig, priceInfo, report); + } + } + + if (positionInfo.positionExists) { + report.amountOfProfitGenerated += ((positionInfo.assetAmount * priceInfo.currentPrice) - ((positionInfo.assetAmount * priceInfo.currentPrice) * tradingConfig.highestFee)) - tradingConfig.startingBalance; + } else { + if (!tradingConfig.depositingEnabled) { + report.amountOfProfitGenerated = positionInfo.fiatBalance - tradingConfig.startingBalance; + } + } + + return report; + + } catch (err) { + const message = "Error occured in analyzeStrategy method."; + const errorMsg = new Error(err); + logger.error({ message, errorMsg, err }); + throw err; + } +} + +module.exports = reverseMomentumStrategyAnalyzerStart; \ No newline at end of file