diff --git a/.vscode/settings.json b/.vscode/settings.json index 1bd32bc7..f99f008a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,6 @@ "editor.insertSpaces": true, "editor.formatOnSave": true, "editor.tabSize": 2, - "eslint.alwaysShowStatus": true, "files.trimTrailingWhitespace": true, "files.exclude": {}, "files.insertFinalNewline": true, @@ -18,10 +17,13 @@ "cSpell.words": [ "bbands", "Bollinger", + "buildx", "chrisleekr", "cummulative", + "hgetall", "MACD", "mrkdwn", + "redlock", "tradingview", "tulind", "uuidv" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dbfa0bb..6ff007ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. +## Unreleased + +- Added multiple TradingView indicators - [#539](https://github.com/chrisleekr/binance-trading-bot/pull/539) + ## [0.0.98] - 2023-04-13 - Added the prefix to environment parameter for `TRADINGVIEW` related - [#616](https://github.com/chrisleekr/binance-trading-bot/pull/616) diff --git a/Gruntfile.js b/Gruntfile.js index 14e1cc85..ca68248c 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -52,12 +52,18 @@ module.exports = grunt => { './public/dist/js/SymbolEditLastBuyPriceIcon.min.js', './public/dist/js/SymbolTriggerSellIcon.min.js', './public/dist/js/SymbolTriggerBuyIcon.min.js', + './public/dist/js/SymbolSettingActionResetGridTrade.min.js', + './public/dist/js/SymbolSettingActionResetToGlobalSetting.min.js', + './public/dist/js/SymbolSettingActions.min.js', + './public/dist/js/SymbolSettingIconBotOptionsAutoTriggerBuy.min.js', './public/dist/js/SymbolSettingIconBotOptions.min.js', + './public/dist/js/SymbolSettingIconTradingView.min.js', './public/dist/js/SymbolSettingIconGridBuy.min.js', './public/dist/js/SymbolSettingIconGridSell.min.js', './public/dist/js/SymbolSettingIcon.min.js', './public/dist/js/SymbolLogsIcon.min.js', './public/dist/js/CoinWrapperSymbol.min.js', + './public/dist/js/CoinWrapperTradingViews.min.js', './public/dist/js/CoinWrapperTradingView.min.js', './public/dist/js/CoinWrapper.min.js', './public/dist/js/DustTransferIcon.min.js', @@ -68,6 +74,12 @@ module.exports = grunt => { './public/dist/js/SettingIconActions.min.js', './public/dist/js/SettingIconGridSell.min.js', './public/dist/js/SettingIconGridBuy.min.js', + './public/dist/js/SettingIconTradingView.min.js', + './public/dist/js/SettingIconBotOptionsLogs.min.js', + './public/dist/js/SettingIconBotOptionsOrderLimit.min.js', + './public/dist/js/SettingIconBotOptionsAutoTriggerBuy.min.js', + './public/dist/js/SettingIconBotOptionsAuthentication.min.js', + './public/dist/js/SettingIconBotOptionsTradingView.min.js', './public/dist/js/SettingIconBotOptions.min.js', './public/dist/js/SettingIconLastBuyPriceRemoveThreshold.min.js', './public/dist/js/SettingIcon.min.js', diff --git a/app/__tests__/error-handler.test.js b/app/__tests__/error-handler.test.js index 6a6190b2..27009257 100644 --- a/app/__tests__/error-handler.test.js +++ b/app/__tests__/error-handler.test.js @@ -217,5 +217,23 @@ describe('error-handler', () => { }).toThrow(`something-unhandled`); }); }); + + describe(`redlock error`, () => { + it('does not throws an error', async () => { + expect(() => { + process.on = jest.fn().mockImplementation((event, error) => { + if (event === 'uncaughtException') { + error({ + message: `redlock:lock-XRPBUSD`, + code: 500 + }); + } + }); + + const { runErrorHandler } = require('../error-handler'); + runErrorHandler(mockLogger); + }).not.toThrow(); + }); + }); }); }); diff --git a/app/__tests__/server-cronjob.test.js b/app/__tests__/server-cronjob.test.js index bb79cb3a..9fd18902 100644 --- a/app/__tests__/server-cronjob.test.js +++ b/app/__tests__/server-cronjob.test.js @@ -9,6 +9,7 @@ describe('server-cronjob', () => { let mockExecuteAlive; let mockExecuteTrailingTradeIndicator; + let mockExecuteTradingView; describe('cronjob running fine', () => { beforeEach(async () => { @@ -19,10 +20,12 @@ describe('server-cronjob', () => { mockExecuteAlive = jest.fn().mockResolvedValue(true); mockExecuteTrailingTradeIndicator = jest.fn().mockResolvedValue(true); + mockExecuteTradingView = jest.fn().mockResolvedValue(true); jest.mock('../cronjob', () => ({ executeAlive: mockExecuteAlive, - executeTrailingTradeIndicator: mockExecuteTrailingTradeIndicator + executeTrailingTradeIndicator: mockExecuteTrailingTradeIndicator, + executeTradingView: mockExecuteTradingView })); mockCronJob = jest @@ -201,10 +204,12 @@ describe('server-cronjob', () => { mockExecuteTrailingTradeIndicator = jest.fn().mockImplementation(() => { setTimeout(() => Promise.resolve(true), 30000); }); + mockExecuteTradingView = jest.fn().mockResolvedValue(true); jest.mock('../cronjob', () => ({ executeAlive: mockExecuteAlive, - executeTrailingTradeIndicator: mockExecuteTrailingTradeIndicator + executeTrailingTradeIndicator: mockExecuteTrailingTradeIndicator, + executeTradingView: mockExecuteTradingView })); mockCronJob = jest diff --git a/app/binance/__tests__/orders.test.js b/app/binance/__tests__/orders.test.js index e9c6a230..82f298be 100644 --- a/app/binance/__tests__/orders.test.js +++ b/app/binance/__tests__/orders.test.js @@ -7,6 +7,7 @@ describe('orders.js', () => { let spyOnClearInterval; let mockUpdateGridTradeLastOrder; + let mockDeleteGridTradeOrder; let mockGetOpenOrdersFromAPI; let mockErrorHandlerWrapper; @@ -216,6 +217,7 @@ describe('orders.js', () => { ); }); }); + describe('when database orders not found', () => { beforeEach(async () => { mongoMock.findAll = jest.fn().mockResolvedValue([]); @@ -245,5 +247,53 @@ describe('orders.js', () => { expect(mockUpdateGridTradeLastOrder).not.toHaveBeenCalled(); }); }); + + describe('when an error is occurred', () => { + beforeEach(async () => { + mongoMock.findAll = jest.fn().mockResolvedValue([ + { + key: 'BTCUSDT', + order: { + symbol: 'BTCUSDT', + cummulativeQuoteQty: '0.00000000', + executedQty: '0.00000000', + isWorking: false, + orderId: 7479643460, + origQty: '0.00920000', + price: '3248.37000000', + side: 'BUY', + status: 'NEW', + stopPrice: '3245.19000000', + type: 'STOP_LOSS_LIMIT', + updateTime: 1642713283562 + } + } + ]); + + binanceMock.client.getOrder = jest + .fn() + .mockRejectedValue(new Error('something happened')); + + mockUpdateGridTradeLastOrder = jest.fn(); + + mockDeleteGridTradeOrder = jest.fn().mockResolvedValue(true); + + jest.mock('../../cronjob/trailingTradeHelper/order', () => ({ + updateGridTradeLastOrder: mockUpdateGridTradeLastOrder, + deleteGridTradeOrder: mockDeleteGridTradeOrder + })); + + const { syncDatabaseOrders } = require('../orders'); + + await syncDatabaseOrders(loggerMock); + }); + + it('triggers deleteGridTradeOrder', () => { + expect(mockDeleteGridTradeOrder).toHaveBeenCalledWith( + loggerMock, + 'BTCUSDT' + ); + }); + }); }); }); diff --git a/app/binance/orders.js b/app/binance/orders.js index 30ae4f6a..491dde30 100644 --- a/app/binance/orders.js +++ b/app/binance/orders.js @@ -4,7 +4,8 @@ const { getOpenOrdersFromAPI } = require('../cronjob/trailingTradeHelper/common'); const { - updateGridTradeLastOrder + updateGridTradeLastOrder, + deleteGridTradeOrder } = require('../cronjob/trailingTradeHelper/order'); const { errorHandlerWrapper } = require('../error-handler'); @@ -70,22 +71,32 @@ const syncDatabaseOrders = async logger => { {} ); - await Promise.all( + return Promise.all( databaseOrders.map(async databaseOrder => { - const { order } = databaseOrder; + const { key, order } = databaseOrder; const { symbol, orderId } = order; - const orderResult = await binance.client.getOrder({ - symbol, - orderId - }); + try { + const orderResult = await binance.client.getOrder({ + symbol, + orderId + }); - const { side } = orderResult; + const { side } = orderResult; - return updateGridTradeLastOrder(logger, symbol, side.toLowerCase(), { - ...order, - ...orderResult - }); + return updateGridTradeLastOrder(logger, symbol, side.toLowerCase(), { + ...order, + ...orderResult + }); + } catch (e) { + logger.info( + { databaseOrder, saveLog: true, e }, + `There was an error that occurred while retrieving the last grid order. ` + + `Delete the order - ${symbol} - ${orderId}` + ); + + return deleteGridTradeOrder(logger, key); + } }) ); }; diff --git a/app/cronjob/__tests__/index.test.js b/app/cronjob/__tests__/index.test.js index 370db6e1..e18e02ce 100644 --- a/app/cronjob/__tests__/index.test.js +++ b/app/cronjob/__tests__/index.test.js @@ -5,7 +5,8 @@ describe('index', () => { expect(index).toStrictEqual({ executeAlive: expect.any(Function), executeTrailingTrade: expect.any(Function), - executeTrailingTradeIndicator: expect.any(Function) + executeTrailingTradeIndicator: expect.any(Function), + executeTradingView: expect.any(Function) }); }); }); diff --git a/app/cronjob/__tests__/tradingView.test.js b/app/cronjob/__tests__/tradingView.test.js new file mode 100644 index 00000000..14594924 --- /dev/null +++ b/app/cronjob/__tests__/tradingView.test.js @@ -0,0 +1,86 @@ +/* eslint-disable max-classes-per-file */ +/* eslint-disable global-require */ +const { logger } = require('../../helpers'); + +describe('tradingView', () => { + let mockLoggerInfo; + + let mockGetGlobalConfiguration; + let mockGetTradingView; + + let mockErrorHandlerWrapper; + + beforeEach(() => { + jest.clearAllMocks().resetModules(); + + mockLoggerInfo = jest.fn(); + + logger.info = mockLoggerInfo; + jest.mock('../../helpers', () => ({ + logger: { + info: mockLoggerInfo, + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + child: jest.fn() + } + })); + + mockErrorHandlerWrapper = jest + .fn() + .mockImplementation((_logger, _job, callback) => + Promise.resolve(callback()) + ); + + jest.mock('../../error-handler', () => ({ + errorHandlerWrapper: mockErrorHandlerWrapper + })); + }); + + const mockSteps = () => { + mockGetGlobalConfiguration = jest + .fn() + .mockImplementation((_logger, rawData) => ({ + ...rawData, + ...{ + globalConfiguration: { + global: 'configuration data' + } + } + })); + + mockGetTradingView = jest.fn().mockImplementation((_logger, rawData) => ({ + ...rawData, + ...{ + tradingView: { tradingview: 'data' } + } + })); + + jest.mock('../trailingTradeIndicator/steps', () => ({ + getGlobalConfiguration: mockGetGlobalConfiguration, + getTradingView: mockGetTradingView + })); + }; + + describe('without any error', () => { + beforeEach(async () => { + mockSteps(); + + const { execute: tradingViewExecute } = require('../tradingView'); + + await tradingViewExecute(logger); + }); + + it('returns expected result', () => { + expect(mockLoggerInfo).toHaveBeenCalledWith( + { + data: { + globalConfiguration: { global: 'configuration data' }, + tradingView: { tradingview: 'data' } + } + }, + 'TradingView: Finish process...' + ); + }); + }); +}); diff --git a/app/cronjob/__tests__/trailingTradeIndicator.test.js b/app/cronjob/__tests__/trailingTradeIndicator.test.js index b0fafe1e..095a9c7f 100644 --- a/app/cronjob/__tests__/trailingTradeIndicator.test.js +++ b/app/cronjob/__tests__/trailingTradeIndicator.test.js @@ -15,7 +15,6 @@ describe('trailingTradeIndicator', () => { let mockExecuteDustTransfer; let mockGetClosedTrades; let mockGetOrderStats; - let mockGetTradingView; let mockSaveDataToCache; let mockExecute; @@ -132,13 +131,6 @@ describe('trailingTradeIndicator', () => { } })); - mockGetTradingView = jest.fn().mockImplementation((_logger, rawData) => ({ - ...rawData, - ...{ - tradingView: 'retrieved' - } - })); - mockSaveDataToCache = jest.fn().mockImplementation((_logger, rawData) => ({ ...rawData, ...{ @@ -155,7 +147,6 @@ describe('trailingTradeIndicator', () => { executeDustTransfer: mockExecuteDustTransfer, getClosedTrades: mockGetClosedTrades, getOrderStats: mockGetOrderStats, - getTradingView: mockGetTradingView, saveDataToCache: mockSaveDataToCache })); }; @@ -207,7 +198,6 @@ describe('trailingTradeIndicator', () => { dustTransfer: 'dust-transfer', getClosedTrades: 'executed', getOrderStats: 'retrieved', - tradingView: 'retrieved', saved: 'data-to-cache' } }, diff --git a/app/cronjob/index.js b/app/cronjob/index.js index 8630f6a4..bc40d0d1 100644 --- a/app/cronjob/index.js +++ b/app/cronjob/index.js @@ -3,9 +3,11 @@ const { execute: executeTrailingTrade } = require('./trailingTrade'); const { execute: executeTrailingTradeIndicator } = require('./trailingTradeIndicator'); +const { execute: executeTradingView } = require('./tradingView'); module.exports = { executeAlive, executeTrailingTrade, - executeTrailingTradeIndicator + executeTrailingTradeIndicator, + executeTradingView }; diff --git a/app/cronjob/tradingView.js b/app/cronjob/tradingView.js new file mode 100644 index 00000000..98d5370c --- /dev/null +++ b/app/cronjob/tradingView.js @@ -0,0 +1,37 @@ +const { + getGlobalConfiguration, + getTradingView +} = require('./trailingTradeIndicator/steps'); +const { errorHandlerWrapper } = require('../error-handler'); + +const execute = async logger => { + // Define sekeleton of data structure + let data = { + globalConfiguration: {}, + tradingView: {} + }; + + await errorHandlerWrapper(logger, 'TradingView', async () => { + data = await getGlobalConfiguration(logger, data); + + // eslint-disable-next-line no-restricted-syntax + for (const { stepName, stepFunc } of [ + { + stepName: 'get-tradingview', + stepFunc: getTradingView + } + ]) { + const stepLogger = logger.child({ stepName, symbol: data.symbol }); + + stepLogger.info({ data }, `Start step - ${stepName}`); + + // eslint-disable-next-line no-await-in-loop + data = await stepFunc(stepLogger, data); + stepLogger.info({ data }, `Finish step - ${stepName}`); + } + + logger.info({ data }, 'TradingView: Finish process...'); + }); +}; + +module.exports = { execute }; diff --git a/app/cronjob/trailingTrade/step/__tests__/determine-action.test.js b/app/cronjob/trailingTrade/step/__tests__/determine-action.test.js index d61a3e24..2e59617a 100644 --- a/app/cronjob/trailingTrade/step/__tests__/determine-action.test.js +++ b/app/cronjob/trailingTrade/step/__tests__/determine-action.test.js @@ -1,5 +1,4 @@ /* eslint-disable global-require */ -const moment = require('moment'); const _ = require('lodash'); describe('determine-action.js', () => { @@ -19,6 +18,7 @@ describe('determine-action.js', () => { let mockGetGridTradeOrder; let mockIsExceedingMaxOpenTrades; + let mockShouldForceSellByTradingView; describe('execute', () => { beforeEach(() => { @@ -1254,6 +1254,98 @@ describe('determine-action.js', () => { ); describe('sell - when last buy price is set and has enough to sell', () => { + describe('shouldForceSellByTradingView', () => { + beforeEach(() => { + mockIsActionDisabled = jest.fn().mockResolvedValue({ + isDisabled: false + }); + + jest.mock('../../../trailingTradeHelper/common', () => ({ + isActionDisabled: mockIsActionDisabled, + getNumberOfBuyOpenOrders: mockGetNumberOfBuyOpenOrders, + getNumberOfOpenTrades: mockGetNumberOfOpenTrades, + getAPILimit: mockGetAPILimit + })); + + mockGetGridTradeOrder = jest.fn().mockResolvedValue(null); + jest.mock('../../../trailingTradeHelper/order', () => ({ + getGridTradeOrder: mockGetGridTradeOrder + })); + + rawData = _.cloneDeep(orgRawData); + + // Set total value more than notional value + rawData.baseAssetBalance.total = 0.0006; + + rawData.buy = { + currentPrice: 31000, + triggerPrice: 28000, + athRestrictionPrice: 27000 + }; + + rawData.sell = { + currentProfit: 1000, + currentPrice: 30800, + triggerPrice: 30900, + lastBuyPrice: 30000, + stopLossTriggerPrice: 24000 + }; + }); + + describe('when shouldForceSell is true', () => { + beforeEach(async () => { + mockShouldForceSellByTradingView = jest.fn().mockReturnValue({ + shouldForceSell: true, + forceSellMessage: 'you must sell' + }); + + jest.mock('../../../trailingTradeHelper/tradingview', () => ({ + shouldForceSellByTradingView: mockShouldForceSellByTradingView + })); + + step = require('../determine-action'); + + result = await step.execute(loggerMock, rawData); + }); + + it('should wait for a sell order because grid trade is found', () => { + expect(result).toMatchObject({ + action: 'sell-stop-loss', + sell: { + processMessage: 'you must sell' + } + }); + }); + }); + + describe('when shouldForceSell is false', () => { + beforeEach(async () => { + mockShouldForceSellByTradingView = jest.fn().mockReturnValue({ + shouldForceSell: false, + forceSellMessage: '' + }); + + jest.mock('../../../trailingTradeHelper/tradingview', () => ({ + shouldForceSellByTradingView: mockShouldForceSellByTradingView + })); + + step = require('../determine-action'); + + result = await step.execute(loggerMock, rawData); + }); + + it('should wait for a sell order because grid trade is found', () => { + expect(result).toMatchObject({ + action: 'sell-wait', + sell: { + processMessage: + 'The current price is lower than the selling trigger price for the grid trade #1. Wait.' + } + }); + }); + }); + }); + describe('isHigherThanSellTriggerPrice - when current price is higher than trigger price', () => { beforeEach(() => { mockIsActionDisabled = jest.fn().mockResolvedValue({ @@ -1460,566 +1552,6 @@ describe('determine-action.js', () => { }); }); - describe('shouldForceSellByTradingViewRecommendation', () => { - beforeEach(() => { - mockIsActionDisabled = jest.fn().mockResolvedValue({ - isDisabled: false - }); - - jest.mock('../../../trailingTradeHelper/common', () => ({ - isActionDisabled: mockIsActionDisabled, - getNumberOfBuyOpenOrders: mockGetNumberOfBuyOpenOrders, - getNumberOfOpenTrades: mockGetNumberOfOpenTrades, - getAPILimit: mockGetAPILimit - })); - - mockGetGridTradeOrder = jest.fn().mockResolvedValue(null); - jest.mock('../../../trailingTradeHelper/order', () => ({ - getGridTradeOrder: mockGetGridTradeOrder - })); - - rawData = _.cloneDeep(orgRawData); - - // Set total value more than notional value - rawData.baseAssetBalance.total = 0.0006; - - rawData.buy = { - currentPrice: 31000, - triggerPrice: 28000, - athRestrictionPrice: 27000 - }; - - rawData.sell = { - currentProfit: 1000, - currentPrice: 31000, - triggerPrice: 30900, - lastBuyPrice: 30000, - stopLossTriggerPrice: 24000 - }; - }); - - [ - { - name: 'when tradingView recommendation option is not enabled', - rawData: { - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: false, - whenSell: false, - whenStrongSell: false - } - } - } - }, - tradingView: { - result: { - time: moment().toISOString(), - summary: { - RECOMMENDATION: 'SELL' - } - } - } - }, - expectedAction: 'sell' - }, - { - name: 'when tradingView time is not defined', - rawData: { - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: true, - whenSell: true, - whenStrongSell: true - } - } - } - }, - tradingView: { - result: { - time: undefined, - summary: { - RECOMMENDATION: 'SELL' - } - } - } - }, - expectedAction: 'sell' - }, - { - name: 'when tradingView recommendation is not defined', - rawData: { - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: true, - whenSell: true, - whenStrongSell: true - } - } - } - }, - tradingView: { - result: { - time: moment().toISOString(), - summary: { - RECOMMENDATION: undefined - } - } - } - }, - expectedAction: 'sell' - }, - { - name: 'when tradingView data is old', - rawData: { - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: true, - whenSell: true, - whenStrongSell: true - } - } - } - }, - tradingView: { - result: { - time: moment().subtract('6', 'minutes').toISOString(), - summary: { - RECOMMENDATION: 'SELL' - } - } - } - }, - expectedAction: 'sell' - }, - { - name: 'when current profit is less than 0', - rawData: { - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: true, - whenSell: true, - whenStrongSell: true - } - } - } - }, - sell: { - currentProfit: -1 - }, - tradingView: { - result: { - time: moment().toISOString(), - summary: { - RECOMMENDATION: 'SELL' - } - } - } - }, - expectedAction: 'sell' - }, - { - name: 'when current price is higher than trigger price', - rawData: { - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: true, - whenSell: true, - whenStrongSell: true - } - } - } - }, - sell: { - currentProfit: 1000, - currentPrice: 31000, - triggerPrice: 30900, - lastBuyPrice: 30000, - stopLossTriggerPrice: 24000 - }, - tradingView: { - result: { - time: moment().toISOString(), - summary: { - RECOMMENDATION: 'SELL' - } - } - } - }, - expectedAction: 'sell' - }, - { - name: 'when current balance is less than minimum notional - BTCUSDT', - rawData: { - baseAssetBalance: { - free: 0.000323 - }, - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: true, - whenSell: true, - whenStrongSell: true - } - } - } - }, - sell: { - currentProfit: 960, - currentPrice: 30960, - triggerPrice: 31000, - lastBuyPrice: 30000, - stopLossTriggerPrice: 24000 - }, - tradingView: { - result: { - time: moment().toISOString(), - summary: { - RECOMMENDATION: 'SELL' - } - } - } - }, - expectedAction: 'sell-wait' - }, - { - name: 'when current balance is less than minimum notional - ALPHABTC', - rawData: { - symbol: 'ALPHABTC', - symbolInfo: { - baseAsset: 'ALPHA', - quoteAsset: 'BTC', - filterLotSize: { - stepSize: '1.00000000', - minQty: '1.00000000' - }, - filterPrice: { tickSize: '0.00000001' }, - filterMinNotional: { minNotional: '0.00010000' } - }, - baseAssetBalance: { - free: 3, - total: 10 - }, - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: true, - whenSell: true, - whenStrongSell: true - } - } - } - }, - sell: { - currentProfit: 2, - currentPrice: 0.00003813, - triggerPrice: 0.000039264, - lastBuyPrice: 0.00003812, - stopLossTriggerPrice: 0.00030496 - }, - tradingView: { - result: { - time: moment().toISOString(), - summary: { - RECOMMENDATION: 'SELL' - } - } - } - }, - expectedAction: 'sell-wait' - }, - { - name: 'when tradingView recommendation is neutral, and allowed neutral', - rawData: { - baseAssetBalance: { - free: 0.0004, - total: 0.0004 - }, - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: true, - whenSell: true, - whenStrongSell: true - } - } - } - }, - sell: { - currentProfit: 100, - currentPrice: 30100, - triggerPrice: 30900, - lastBuyPrice: 30000, - stopLossTriggerPrice: 24000 - }, - tradingView: { - result: { - time: moment().toISOString(), - summary: { - RECOMMENDATION: 'NEUTRAL' - } - } - } - }, - expectedAction: 'sell-stop-loss', - expectedProcessMessage: - `TradingView recommendation is NEUTRAL. The current profit (100) is more than 0 and ` + - `the current price (30100) is under trigger price (30900). Sell at market price.` - }, - { - name: 'when tradingView recommendation is neutral, but only allowed sell', - rawData: { - baseAssetBalance: { - free: 0.0004, - total: 0.0004 - }, - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: false, - whenSell: true, - whenStrongSell: false - } - } - } - }, - sell: { - currentProfit: 100, - currentPrice: 30100, - triggerPrice: 30900, - lastBuyPrice: 30000, - stopLossTriggerPrice: 24000 - }, - tradingView: { - result: { - time: moment().toISOString(), - summary: { - RECOMMENDATION: 'NEUTRAL' - } - } - } - }, - expectedAction: 'sell-wait' - }, - { - name: 'when tradingView recommendation is sell, but only allowed strong_sell', - rawData: { - baseAssetBalance: { - free: 0.0004, - total: 0.0004 - }, - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: true, - whenSell: false, - whenStrongSell: true - } - } - } - }, - sell: { - currentProfit: 100, - currentPrice: 30100, - triggerPrice: 30900, - lastBuyPrice: 30000, - stopLossTriggerPrice: 24000 - }, - tradingView: { - result: { - time: moment().toISOString(), - summary: { - RECOMMENDATION: 'SELL' - } - } - } - }, - expectedAction: 'sell-wait' - }, - { - name: 'when tradingView recommendation is strong sell, but only allowed sell', - rawData: { - baseAssetBalance: { - free: 0.0004, - total: 0.0004 - }, - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: true, - whenSell: true, - whenStrongSell: false - } - } - } - }, - sell: { - currentProfit: 100, - currentPrice: 30100, - triggerPrice: 30900, - lastBuyPrice: 30000, - stopLossTriggerPrice: 24000 - }, - tradingView: { - result: { - time: moment().toISOString(), - summary: { - RECOMMENDATION: 'STRONG_SELL' - } - } - } - }, - expectedAction: 'sell-wait' - }, - { - name: 'when tradingView recommendation is sell, and allowed sell', - rawData: { - baseAssetBalance: { - free: 0.0004, - total: 0.0004 - }, - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: true, - whenSell: true, - whenStrongSell: true - } - } - } - }, - sell: { - currentProfit: 100, - currentPrice: 30100, - triggerPrice: 30900, - lastBuyPrice: 30000, - stopLossTriggerPrice: 24000 - }, - tradingView: { - result: { - time: moment().toISOString(), - summary: { - RECOMMENDATION: 'SELL' - } - } - } - }, - expectedAction: 'sell-stop-loss', - expectedProcessMessage: - `TradingView recommendation is SELL. The current profit (100) is more than 0 and ` + - `the current price (30100) is under trigger price (30900). Sell at market price.` - }, - { - name: 'when tradingView recommendation is strong sell, and allowed strong sell', - rawData: { - baseAssetBalance: { - free: 0.0004, - total: 0.0004 - }, - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: true, - whenSell: true, - whenStrongSell: true - } - } - } - }, - sell: { - currentProfit: 100, - currentPrice: 30100, - triggerPrice: 30900, - lastBuyPrice: 30000, - stopLossTriggerPrice: 24000 - }, - tradingView: { - result: { - time: moment().toISOString(), - summary: { - RECOMMENDATION: 'STRONG_SELL' - } - } - } - }, - expectedAction: 'sell-stop-loss', - expectedProcessMessage: - `TradingView recommendation is STRONG_SELL. The current profit (100) is more than 0 and ` + - `the current price (30100) is under trigger price (30900). Sell at market price.` - } - ].forEach(t => { - describe(`${t.name}`, () => { - let testRawData; - beforeEach(async () => { - testRawData = _.mergeWith(rawData, t.rawData); - - step = require('../determine-action'); - - result = await step.execute(loggerMock, testRawData); - }); - - if (t.expectedAction === 'sell-stop-loss') { - it('should place a stop-loss order after checking tradingView', () => { - expect(result).toMatchObject({ - action: 'sell-stop-loss', - baseAssetBalance: testRawData.baseAssetBalance, - sell: { - ...testRawData.sell, - processMessage: t.expectedProcessMessage, - updatedAt: expect.any(Object) - } - }); - }); - } else if (t.expectedAction === 'sell') { - it('should place an order after ignoring tradingView', () => { - expect(result).toMatchObject({ - action: 'sell', - baseAssetBalance: testRawData.baseAssetBalance, - sell: { - ...testRawData.sell, - processMessage: - "The current price is more than the trigger price. Let's sell.", - updatedAt: expect.any(Object) - } - }); - }); - } else { - it('should wait for sell orderorder after ignoring tradingView', () => { - expect(result).toMatchObject({ - action: 'sell-wait', - baseAssetBalance: testRawData.baseAssetBalance, - sell: { - ...testRawData.sell, - processMessage: - 'The current price is lower than the selling trigger price for the grid trade #1. Wait.', - updatedAt: expect.any(Object) - } - }); - }); - } - }); - }); - }); - describe('isLowerThanStopLossTriggerPrice - when current price is less than stop loss trigger price', () => { describe('isActionDisabled - when stop loss is disabled', () => { beforeEach(async () => { diff --git a/app/cronjob/trailingTrade/step/__tests__/get-indicators.test.js b/app/cronjob/trailingTrade/step/__tests__/get-indicators.test.js index 02364425..0ebc9085 100644 --- a/app/cronjob/trailingTrade/step/__tests__/get-indicators.test.js +++ b/app/cronjob/trailingTrade/step/__tests__/get-indicators.test.js @@ -110,15 +110,45 @@ describe('get-indicators.js', () => { })); }; + const mockCacheHGetAll = ( + conditions = { + 'trailing-trade-tradingview:BTCUSDT:': { + '15m': JSON.stringify({ + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } + } + }) + } + } + ) => { + cacheMock.hgetall = jest + .fn() + .mockImplementation((prefix, _key) => conditions[prefix] ?? null); + }; + const baseRawData = { symbol: 'BTCUSDT', symbolInfo: { filterMinNotional: { minNotional: '10.000' } } }; + describe('execute', () => { beforeEach(() => { clearMocks(); + + mockCacheHGetAll(); }); describe('with no open orders and no last buy price', () => { @@ -177,6 +207,16 @@ describe('get-indicators.js', () => { enabled: false, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { total: 0 }, @@ -235,22 +275,24 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } } } - } + ] }); }); }); @@ -311,6 +353,16 @@ describe('get-indicators.js', () => { enabled: false, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { total: 0.1 }, @@ -369,22 +421,24 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } } } - } + ] }); }); }); @@ -425,6 +479,10 @@ describe('get-indicators.js', () => { enabled: false, factor: 0.5 } + }, + botOptions: { + // For some reason, not defined. + tradingViews: undefined } }, baseAssetBalance: { total: 0 }, @@ -492,22 +550,7 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 - } - } - } + tradingViews: [] }); }); }); @@ -563,6 +606,16 @@ describe('get-indicators.js', () => { enabled: false, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { total: 0.1 }, @@ -630,22 +683,24 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } } } - } + ] }); }); }); @@ -702,6 +757,16 @@ describe('get-indicators.js', () => { enabled: false, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { total: 1 }, @@ -769,22 +834,24 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } } } - } + ] }); }); }); @@ -840,6 +907,16 @@ describe('get-indicators.js', () => { enabled: false, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { total: 0 }, @@ -907,22 +984,24 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } } } - } + ] }); }); }); @@ -982,6 +1061,16 @@ describe('get-indicators.js', () => { enabled: true, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { total: 1 }, @@ -1049,22 +1138,24 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } } } - } + ] }); }); }); @@ -1127,6 +1218,16 @@ describe('get-indicators.js', () => { enabled: true, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { total: 2 }, @@ -1194,22 +1295,24 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } } } - } + ] }); }); }); @@ -1251,6 +1354,16 @@ describe('get-indicators.js', () => { enabled: false, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { total: 0.001 }, @@ -1433,22 +1546,24 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } } } - } + ] }); }); }); @@ -1504,6 +1619,16 @@ describe('get-indicators.js', () => { enabled: false, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { total: 0.001 }, @@ -1686,22 +1811,24 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } } } - } + ] }); }); }); @@ -1759,6 +1886,16 @@ describe('get-indicators.js', () => { enabled: false, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { total: 1 }, @@ -1941,22 +2078,24 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } } } - } + ] }); }); }); @@ -2013,6 +2152,16 @@ describe('get-indicators.js', () => { enabled: true, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { total: 1 }, @@ -2195,22 +2344,24 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } } } - } + ] }); }); }); @@ -2223,6 +2374,8 @@ describe('get-indicators.js', () => { mockLatestCandle(8900); + mockCacheHGetAll(); + step = require('../get-indicators'); rawData = { @@ -2287,6 +2440,16 @@ describe('get-indicators.js', () => { enabled: true, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { total: 1.5 }, @@ -2469,22 +2632,24 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } } } - } + ] }); }); }); @@ -2547,6 +2712,16 @@ describe('get-indicators.js', () => { enabled: false, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { @@ -2722,22 +2897,24 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } } } - } + ] }); }); }); @@ -2799,6 +2976,16 @@ describe('get-indicators.js', () => { enabled: false, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { @@ -2865,22 +3052,24 @@ describe('get-indicators.js', () => { processMessage: 'World', updatedAt: expect.any(Object) }, - tradingView: { - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'SELL', - BUY: 4, - SELL: 14, - NEUTRAL: 8 + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } } } - } + ] }); }); }); @@ -2943,6 +3132,16 @@ describe('get-indicators.js', () => { enabled: false, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { total: 0.1 }, @@ -3020,6 +3219,16 @@ describe('get-indicators.js', () => { enabled: false, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { total: 0.1 }, @@ -3080,7 +3289,24 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: {} + tradingViews: [ + { + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL', + BUY: 4, + SELL: 14, + NEUTRAL: 8 + } + } + } + ] }); }); }); @@ -3091,6 +3317,8 @@ describe('get-indicators.js', () => { mockLatestCandle(9899.05); + mockCacheHGetAll([]); + cacheMock.hget = jest.fn().mockImplementation((hash, key) => { if ( hash === 'trailing-trade-symbols' && @@ -3155,6 +3383,16 @@ describe('get-indicators.js', () => { enabled: false, factor: 0.5 } + }, + botOptions: { + tradingViews: [ + { + interval: '5m' + }, + { + interval: '15m' + } + ] } }, baseAssetBalance: { @@ -3215,7 +3453,7 @@ describe('get-indicators.js', () => { processMessage: '', updatedAt: expect.any(Object) }, - tradingView: {} + tradingViews: [] }); }); }); diff --git a/app/cronjob/trailingTrade/step/__tests__/get-override-action.test.js b/app/cronjob/trailingTrade/step/__tests__/get-override-action.test.js index b51b4abd..58d0738d 100644 --- a/app/cronjob/trailingTrade/step/__tests__/get-override-action.test.js +++ b/app/cronjob/trailingTrade/step/__tests__/get-override-action.test.js @@ -1,5 +1,4 @@ /* eslint-disable global-require */ -const _ = require('lodash'); describe('get-override-action.js', () => { let result; @@ -336,11 +335,7 @@ describe('get-override-action.js', () => { triggerAfter: 20, conditions: { whenLessThanATHRestriction: true, - afterDisabledPeriod: false, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } + afterDisabledPeriod: false } } } @@ -401,11 +396,7 @@ describe('get-override-action.js', () => { triggerAfter: 20, conditions: { whenLessThanATHRestriction: true, - afterDisabledPeriod: false, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } + afterDisabledPeriod: false } } } @@ -441,11 +432,7 @@ describe('get-override-action.js', () => { triggerAfter: 20, conditions: { whenLessThanATHRestriction: true, - afterDisabledPeriod: false, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } + afterDisabledPeriod: false } } } @@ -493,11 +480,7 @@ describe('get-override-action.js', () => { triggerAfter: 20, conditions: { whenLessThanATHRestriction: true, - afterDisabledPeriod: false, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } + afterDisabledPeriod: false } } } @@ -537,11 +520,7 @@ describe('get-override-action.js', () => { triggerAfter: 20, conditions: { whenLessThanATHRestriction: false, - afterDisabledPeriod: false, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } + afterDisabledPeriod: false } } } @@ -589,11 +568,7 @@ describe('get-override-action.js', () => { triggerAfter: 20, conditions: { whenLessThanATHRestriction: false, - afterDisabledPeriod: false, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } + afterDisabledPeriod: false } } } @@ -652,11 +627,7 @@ describe('get-override-action.js', () => { triggerAfter: 20, conditions: { whenLessThanATHRestriction: false, - afterDisabledPeriod: true, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } + afterDisabledPeriod: true } } } @@ -715,11 +686,7 @@ describe('get-override-action.js', () => { triggerAfter: 20, conditions: { whenLessThanATHRestriction: false, - afterDisabledPeriod: true, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } + afterDisabledPeriod: true } } } @@ -755,11 +722,7 @@ describe('get-override-action.js', () => { triggerAfter: 20, conditions: { whenLessThanATHRestriction: false, - afterDisabledPeriod: false, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } + afterDisabledPeriod: false } } } @@ -807,11 +770,7 @@ describe('get-override-action.js', () => { triggerAfter: 20, conditions: { whenLessThanATHRestriction: false, - afterDisabledPeriod: false, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } + afterDisabledPeriod: false } } } @@ -834,297 +793,6 @@ describe('get-override-action.js', () => { }); }); }); - - describe('recommendation', () => { - beforeEach(() => { - rawData = { - action: 'not-determined', - symbol: 'BTCUSDT', - symbolConfiguration: { - buy: { - athRestriction: { - enabled: true - } - }, - botOptions: { - autoTriggerBuy: { - triggerAfter: 20, - conditions: { - whenLessThanATHRestriction: true, - afterDisabledPeriod: false, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } - } - } - } - }, - buy: { - currentPrice: 1000, - athRestrictionPrice: 1100 - }, - tradingView: {} - }; - }); - - describe('when recommendation is empty', () => { - beforeEach(async () => { - const step = require('../get-override-action'); - result = await step.execute(loggerMock, rawData); - }); - - it('triggers getOverrideDataForSymbol', () => { - expect(mockGetOverrideDataForSymbol).toHaveBeenCalledWith( - loggerMock, - 'BTCUSDT' - ); - }); - - it('triggers removeOverrideDataForSymbol', () => { - expect(mockRemoveOverrideDataForSymbol).toHaveBeenCalledWith( - loggerMock, - 'BTCUSDT' - ); - }); - - it('does not trigger saveOverrideAction', () => { - expect(mockSaveOverrideAction).not.toHaveBeenCalled(); - }); - - it('retruns expected result', () => { - expect(result).toMatchObject({ - action: 'buy', - order: { - some: 'data' - }, - overrideData: { - action: 'buy', - order: { - some: 'data' - }, - triggeredBy: 'auto-trigger' - } - }); - }); - }); - - describe('when recommendation is not enabled', () => { - beforeEach(async () => { - rawData.symbolConfiguration.botOptions.autoTriggerBuy.conditions.tradingView.whenStrongBuy = false; - rawData.symbolConfiguration.botOptions.autoTriggerBuy.conditions.tradingView.whenBuy = false; - - const step = require('../get-override-action'); - result = await step.execute(loggerMock, rawData); - }); - - it('triggers getOverrideDataForSymbol', () => { - expect(mockGetOverrideDataForSymbol).toHaveBeenCalledWith( - loggerMock, - 'BTCUSDT' - ); - }); - - it('triggers removeOverrideDataForSymbol', () => { - expect(mockRemoveOverrideDataForSymbol).toHaveBeenCalledWith( - loggerMock, - 'BTCUSDT' - ); - }); - - it('does not trigger saveOverrideAction', () => { - expect(mockSaveOverrideAction).not.toHaveBeenCalled(); - }); - - it('retruns expected result', () => { - expect(result).toMatchObject({ - action: 'buy', - order: { - some: 'data' - }, - overrideData: { - action: 'buy', - order: { - some: 'data' - }, - triggeredBy: 'auto-trigger' - } - }); - }); - }); - - describe('when recommendations are enabled', () => { - describe('when summary recommendation is strong buy', () => { - beforeEach(async () => { - // Set recommendation as strong buy - rawData = _.set( - rawData, - 'tradingView.result.summary.RECOMMENDATION', - 'STRONG_BUY' - ); - const step = require('../get-override-action'); - result = await step.execute(loggerMock, rawData); - }); - - it('triggers getOverrideDataForSymbol', () => { - expect(mockGetOverrideDataForSymbol).toHaveBeenCalledWith( - loggerMock, - 'BTCUSDT' - ); - }); - - it('triggers removeOverrideDataForSymbol', () => { - expect(mockRemoveOverrideDataForSymbol).toHaveBeenCalledWith( - loggerMock, - 'BTCUSDT' - ); - }); - - it('does not trigger saveOverrideAction', () => { - expect(mockSaveOverrideAction).not.toHaveBeenCalled(); - }); - - it('retruns expected result', () => { - expect(result).toMatchObject({ - action: 'buy', - order: { - some: 'data' - }, - overrideData: { - action: 'buy', - order: { - some: 'data' - }, - triggeredBy: 'auto-trigger' - }, - tradingView: { - result: { - summary: { - RECOMMENDATION: 'STRONG_BUY' - } - } - } - }); - }); - }); - - describe('when summary recommendation is buy', () => { - beforeEach(async () => { - // Set recommendation as strong buy - rawData = _.set( - rawData, - 'tradingView.result.summary.RECOMMENDATION', - 'BUY' - ); - const step = require('../get-override-action'); - result = await step.execute(loggerMock, rawData); - }); - - it('triggers getOverrideDataForSymbol', () => { - expect(mockGetOverrideDataForSymbol).toHaveBeenCalledWith( - loggerMock, - 'BTCUSDT' - ); - }); - - it('triggers removeOverrideDataForSymbol', () => { - expect(mockRemoveOverrideDataForSymbol).toHaveBeenCalledWith( - loggerMock, - 'BTCUSDT' - ); - }); - - it('does not trigger saveOverrideAction', () => { - expect(mockSaveOverrideAction).not.toHaveBeenCalled(); - }); - - it('retruns expected result', () => { - expect(result).toMatchObject({ - action: 'buy', - order: { - some: 'data' - }, - overrideData: { - action: 'buy', - order: { - some: 'data' - }, - triggeredBy: 'auto-trigger' - }, - tradingView: { - result: { - summary: { - RECOMMENDATION: 'BUY' - } - } - } - }); - }); - }); - - describe('when summary recommendation is not strong buy or buy', () => { - beforeEach(async () => { - // Set recommendation as neutral - rawData = _.set( - rawData, - 'tradingView.result.summary.RECOMMENDATION', - 'NEUTRAL' - ); - const step = require('../get-override-action'); - result = await step.execute(loggerMock, rawData); - }); - - it('triggers getOverrideDataForSymbol', () => { - expect(mockGetOverrideDataForSymbol).toHaveBeenCalledWith( - loggerMock, - 'BTCUSDT' - ); - }); - - it('does not trigger removeOverrideDataForSymbol', () => { - expect( - mockRemoveOverrideDataForSymbol - ).not.toHaveBeenCalled(); - }); - - it('triggers saveOverrideAction', () => { - expect(mockSaveOverrideAction).toHaveBeenCalledWith( - loggerMock, - 'BTCUSDT', - { - action: 'buy', - actionAt: expect.any(String), - order: { some: 'data' }, - triggeredBy: 'auto-trigger', - notify: false - }, - `The auto-trigger buy action needs to be re-scheduled ` + - `because the TradingView recommendation is NEUTRAL.` - ); - }); - - it('retruns expected result', () => { - expect(result).toMatchObject({ - action: 'not-determined', - overrideData: { - action: 'buy', - order: { - some: 'data' - }, - triggeredBy: 'auto-trigger' - }, - tradingView: { - result: { - summary: { - RECOMMENDATION: 'NEUTRAL' - } - } - } - }); - }); - }); - }); - }); }); }); diff --git a/app/cronjob/trailingTrade/step/__tests__/place-buy-order.test.js b/app/cronjob/trailingTrade/step/__tests__/place-buy-order.test.js index 0506a28c..fa95833a 100644 --- a/app/cronjob/trailingTrade/step/__tests__/place-buy-order.test.js +++ b/app/cronjob/trailingTrade/step/__tests__/place-buy-order.test.js @@ -11,15 +11,22 @@ describe('place-buy-order.js', () => { let slackMock; let loggerMock; - let mockGetAccountInfoFromAPI; let mockIsExceedAPILimit; let mockGetAPILimit; let mockSaveOrderStats; let mockSaveOverrideAction; - let mockGetAndCacheOpenOrdersForSymbol; + + let mockRefreshOpenOrdersAndAccountInfo; let mockSaveGridTradeOrder; + let mockIsBuyAllowedByTradingView; + + const tradingViewValidTime = moment() + .utc() + .subtract('1', 'minute') + .format('YYYY-MM-DDTHH:mm:ss.SSSSSS'); + describe('execute', () => { beforeEach(() => { jest.clearAllMocks().resetModules(); @@ -41,26 +48,36 @@ describe('place-buy-order.js', () => { mockSaveGridTradeOrder = jest.fn().mockResolvedValue(true); mockSaveOrderStats = jest.fn().mockResolvedValue(true); mockSaveOverrideAction = jest.fn().mockResolvedValue(true); - - mockGetAccountInfoFromAPI = jest.fn().mockResolvedValue({ - account: 'info' + mockRefreshOpenOrdersAndAccountInfo = jest.fn().mockResolvedValue({ + accountInfo: { + accountInfo: 'updated' + }, + openOrders: [{ openOrders: 'retrieved' }], + buyOpenOrders: [{ buyOpenOrders: 'retrived' }], + sellOpenOrders: [{ sellOpenOrders: 'retrived' }] }); - mockGetAndCacheOpenOrdersForSymbol = jest.fn().mockResolvedValue([]); + mockIsBuyAllowedByTradingView = jest.fn().mockReturnValue({ + isTradingViewAllowed: true, + tradingViewRejectedReason: '' + }); jest.mock('../../../trailingTradeHelper/common', () => ({ - getAccountInfoFromAPI: mockGetAccountInfoFromAPI, isExceedAPILimit: mockIsExceedAPILimit, getAPILimit: mockGetAPILimit, saveOrderStats: mockSaveOrderStats, saveOverrideAction: mockSaveOverrideAction, - getAndCacheOpenOrdersForSymbol: mockGetAndCacheOpenOrdersForSymbol + refreshOpenOrdersAndAccountInfo: mockRefreshOpenOrdersAndAccountInfo })); jest.mock('../../../trailingTradeHelper/order', () => ({ saveGridTradeOrder: mockSaveGridTradeOrder })); + jest.mock('../../../trailingTradeHelper/tradingview', () => ({ + isBuyAllowedByTradingView: mockIsBuyAllowedByTradingView + })); + orgRawData = { symbol: 'BTCUPUSDT', featureToggle: { @@ -93,7 +110,23 @@ describe('place-buy-order.js', () => { } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -105,7 +138,26 @@ describe('place-buy-order.js', () => { action: 'buy', quoteAssetBalance: { free: 0, locked: 0 }, buy: { currentPrice: 200, openOrders: [] }, - tradingView: {}, + tradingViews: [ + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'SELL' }, + time: tradingViewValidTime + } + }, + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: tradingViewValidTime + } + } + ], overrideData: {} }; }); @@ -115,12 +167,8 @@ describe('place-buy-order.js', () => { expect(binanceMock.client.order).not.toHaveBeenCalled(); }); - it('does not trigger getAndCacheOpenOrdersForSymbol', () => { - expect(mockGetAndCacheOpenOrdersForSymbol).not.toHaveBeenCalled(); - }); - - it('does not trigger getAccountInfoFromAPI', () => { - expect(mockGetAccountInfoFromAPI).not.toHaveBeenCalled(); + it('does not trigger refreshOpenOrdersAndAccountInfo', () => { + expect(mockRefreshOpenOrdersAndAccountInfo).not.toHaveBeenCalled(); }); it('does not trigger saveGridTradeOrder', () => { @@ -172,738 +220,92 @@ describe('place-buy-order.js', () => { it('retruns expected value', () => { expect(result).toMatchObject({ - buy: { - currentPrice: 200, - openOrders: [ - { - orderId: 46838, - type: 'STOP_LOSS_LIMIT', - side: 'BUY', - price: '201.000000', - origQty: '0.5', - stopPrice: '200.000000' - } - ], - processMessage: - 'There are open orders for BTCUPUSDT. Do not place an order for the grid trade #1.', - updatedAt: expect.any(Object) - } - }); - }); - }); - - describe('when current grid trade is not defined', () => { - beforeEach(async () => { - const step = require('../place-buy-order'); - - rawData = _.cloneDeep(orgRawData); - rawData.symbolConfiguration.buy = { - enabled: true, - currentGridTradeIndex: -1, - currentGridTrade: null - }; - - result = await step.execute(loggerMock, rawData); - }); - - doNotProcessTests(); - - it('retruns expected value', () => { - expect(result).toMatchObject({ - buy: { - currentPrice: 200, - openOrders: [], - processMessage: - 'Current grid trade is not defined. Cannot place an order.', - updatedAt: expect.any(Object) - } - }); - }); - }); - - describe('when tradingView recommendation is not allowed', () => { - [ - { - name: 'when tradingView is not enabled, then place an order', - rawData: { - symbol: 'BTCUPUSDT', - featureToggle: { notifyDebug: true, notifyOrderConfirm: true }, - symbolConfiguration: { - buy: { - enabled: true, - currentGridTradeIndex: 0, - currentGridTrade: { - triggerPercentage: 1, - minPurchaseAmount: 10, - maxPurchaseAmount: 50, - stopPercentage: 1.01, - limitPercentage: 1.011, - executed: false, - executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false - } - }, - botOptions: { - tradingView: { - useOnlyWithin: 5, - ifExpires: 'ignore' - } - } - }, - action: 'buy', - quoteAssetBalance: { free: 101, locked: 0 }, - buy: { - currentPrice: 200, - openOrders: [] - }, - tradingView: { - result: { - time: moment() - .utc() - .subtract('1', 'minute') - .format('YYYY-MM-DDTHH:mm:ss.SSSSSS'), - summary: { - RECOMMENDATION: 'NEUTRAL' - } - } - }, - overrideData: {} - }, - expectedToPlaceOrder: true - }, - { - name: - `when tradingView are enabled but tradingView time is not recorded, ` + - `then place an order`, - rawData: { - symbol: 'BTCUPUSDT', - featureToggle: { notifyDebug: true, notifyOrderConfirm: false }, - symbolConfiguration: { - buy: { - enabled: true, - currentGridTradeIndex: 0, - currentGridTrade: { - triggerPercentage: 1, - minPurchaseAmount: 10, - maxPurchaseAmount: 50, - stopPercentage: 1.01, - limitPercentage: 1.011, - executed: false, - executedOrder: null - }, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } - }, - botOptions: { - tradingView: { - useOnlyWithin: 5, - ifExpires: 'ignore' - } - } - }, - action: 'buy', - quoteAssetBalance: { free: 101, locked: 0 }, - buy: { - currentPrice: 200, - openOrders: [] - }, - tradingView: { - result: {} - }, - overrideData: {} - }, - expectedToPlaceOrder: true - }, - { - name: - `when tradingView are enabled but tradingView recommendation is not recorded, ` + - `then place an order`, - rawData: { - symbol: 'BTCUPUSDT', - featureToggle: { notifyDebug: true, notifyOrderConfirm: true }, - symbolConfiguration: { - buy: { - enabled: true, - currentGridTradeIndex: 0, - currentGridTrade: { - triggerPercentage: 1, - minPurchaseAmount: 10, - maxPurchaseAmount: 50, - stopPercentage: 1.01, - limitPercentage: 1.011, - executed: false, - executedOrder: null - }, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } - }, - botOptions: { - tradingView: { - useOnlyWithin: 5, - ifExpires: 'ignore' - } - } - }, - action: 'buy', - quoteAssetBalance: { free: 101, locked: 0 }, - buy: { - currentPrice: 200, - openOrders: [] - }, - tradingView: { - result: { - time: moment() - .utc() - .subtract('1', 'minute') - .format('YYYY-MM-DDTHH:mm:ss.SSSSSS') - } - }, - overrideData: {} - }, - expectedToPlaceOrder: true - }, - { - name: - `when tradingView was updated older than configured minutes and set as ignore ` + - `if expires, then place an order`, - rawData: { - symbol: 'BTCUPUSDT', - featureToggle: { notifyDebug: false, notifyOrderConfirm: false }, - symbolConfiguration: { - buy: { - enabled: true, - currentGridTradeIndex: 0, - currentGridTrade: { - triggerPercentage: 1, - minPurchaseAmount: 10, - maxPurchaseAmount: 50, - stopPercentage: 1.01, - limitPercentage: 1.011, - executed: false, - executedOrder: null - }, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } - }, - botOptions: { - tradingView: { - useOnlyWithin: 5, - ifExpires: 'ignore' - } - } - }, - action: 'buy', - quoteAssetBalance: { free: 101, locked: 0 }, - buy: { - currentPrice: 200, - openOrders: [] - }, - tradingView: { - result: { - time: moment() - .utc() - .subtract('6', 'minute') - .format('YYYY-MM-DDTHH:mm:ss.SSSSSS'), - summary: { - RECOMMENDATION: 'NEUTRAL' - } - } - }, - overrideData: {} - }, - expectedToPlaceOrder: true - }, - { - name: - `when tradingView was updated older than configured minutes and set as do-not-buy ` + - `if expires, then do not place an order`, - rawData: { - symbol: 'BTCUPUSDT', - featureToggle: { notifyDebug: false, notifyOrderConfirm: true }, - symbolConfiguration: { - buy: { - enabled: true, - currentGridTradeIndex: 0, - currentGridTrade: { - triggerPercentage: 1, - minPurchaseAmount: 10, - maxPurchaseAmount: 50, - stopPercentage: 1.01, - limitPercentage: 1.011, - executed: false, - executedOrder: null - }, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } - }, - botOptions: { - tradingView: { - useOnlyWithin: 5, - ifExpires: 'do-not-buy' - } - } - }, - action: 'buy', - quoteAssetBalance: { free: 101, locked: 0 }, - buy: { - currentPrice: 200, - openOrders: [] - }, - tradingView: { - result: { - time: moment() - .utc() - .subtract('6', 'minute') - .format('YYYY-MM-DDTHH:mm:ss.SSSSSS'), - summary: { - RECOMMENDATION: 'NEUTRAL' - } - } - }, - overrideData: {} - }, - expectedToPlaceOrder: false, - expectedProcessedMessage: - 'Do not place an order because TradingView data is older than 5 minutes.' - }, - { - name: 'when tradingView are enabled and recommendation is strong buy, then place an order', - rawData: { - symbol: 'BTCUPUSDT', - featureToggle: { notifyDebug: true, notifyOrderConfirm: false }, - symbolConfiguration: { - buy: { - enabled: true, - currentGridTradeIndex: 0, - currentGridTrade: { - triggerPercentage: 1, - minPurchaseAmount: 10, - maxPurchaseAmount: 50, - stopPercentage: 1.01, - limitPercentage: 1.011, - executed: false, - executedOrder: null - }, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } - }, - botOptions: { - tradingView: { - useOnlyWithin: 5, - ifExpires: 'ignore' - } - } - }, - action: 'buy', - quoteAssetBalance: { free: 101, locked: 0 }, - buy: { - currentPrice: 200, - openOrders: [] - }, - tradingView: { - result: { - time: moment() - .utc() - .subtract('1', 'minute') - .format('YYYY-MM-DDTHH:mm:ss.SSSSSS'), - summary: { - RECOMMENDATION: 'STRONG_BUY' - } - } - }, - overrideData: {} - }, - expectedToPlaceOrder: true - }, - { - name: 'when tradingView are enabled and recommendation is buy, then place an order', - rawData: { - symbol: 'BTCUPUSDT', - featureToggle: { notifyDebug: false, notifyOrderConfirm: true }, - symbolConfiguration: { - buy: { - enabled: true, - currentGridTradeIndex: 0, - currentGridTrade: { - triggerPercentage: 1, - minPurchaseAmount: 10, - maxPurchaseAmount: 50, - stopPercentage: 1.01, - limitPercentage: 1.011, - executed: false, - executedOrder: null - }, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } - }, - botOptions: { - tradingView: { - useOnlyWithin: 5, - ifExpires: 'ignore' - } - } - }, - action: 'buy', - quoteAssetBalance: { free: 101, locked: 0 }, - buy: { - currentPrice: 200, - openOrders: [] - }, - tradingView: { - result: { - time: moment() - .utc() - .subtract('1', 'minute') - .format('YYYY-MM-DDTHH:mm:ss.SSSSSS'), - summary: { - RECOMMENDATION: 'BUY' - } - } - }, - overrideData: {} - }, - expectedToPlaceOrder: true - }, - { - name: 'when tradingView are enabled and recommendation is not strong buy or buy, do not place an order', - rawData: { - symbol: 'BTCUPUSDT', - featureToggle: { notifyDebug: false, notifyOrderConfirm: false }, - symbolConfiguration: { - buy: { - enabled: true, - currentGridTradeIndex: 0, - currentGridTrade: { - triggerPercentage: 1, - minPurchaseAmount: 10, - maxPurchaseAmount: 50, - stopPercentage: 1.01, - limitPercentage: 1.011, - executed: false, - executedOrder: null - }, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } - }, - botOptions: { - tradingView: { - useOnlyWithin: 5, - ifExpires: 'ignore' - } - } - }, - action: 'buy', - quoteAssetBalance: { free: 101, locked: 0 }, - buy: { - currentPrice: 200, - openOrders: [] - }, - tradingView: { - result: { - time: moment() - .utc() - .subtract('1', 'minute') - .format('YYYY-MM-DDTHH:mm:ss.SSSSSS'), - summary: { - RECOMMENDATION: 'NEUTRAL' - } - } - }, - overrideData: {} - }, - expectedToPlaceOrder: false, - expectedProcessedMessage: - 'Do not place an order because TradingView recommendation is NEUTRAL.' - }, - { - name: - `when tradingView are enabled and recommendation is neutral, ` + - `but the action is overriden and checking tradingView is true, then do not place an order`, - rawData: { - symbol: 'BTCUPUSDT', - featureToggle: { notifyDebug: true }, - symbolConfiguration: { - buy: { - enabled: true, - currentGridTradeIndex: 0, - currentGridTrade: { - triggerPercentage: 1, - minPurchaseAmount: 10, - maxPurchaseAmount: 50, - stopPercentage: 1.01, - limitPercentage: 1.011, - executed: false, - executedOrder: null - }, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } - }, - botOptions: { - tradingView: { - useOnlyWithin: 5, - ifExpires: 'ignore' - } - } - }, - action: 'buy', - quoteAssetBalance: { free: 101, locked: 0 }, - buy: { - currentPrice: 200, - openOrders: [] - }, - tradingView: { - result: { - time: moment() - .utc() - .subtract('1', 'minute') - .format('YYYY-MM-DDTHH:mm:ss.SSSSSS'), - summary: { - RECOMMENDATION: 'NEUTRAL' - } - } - }, - overrideData: { - action: 'buy', - checkTradingView: true - } - }, - expectedToPlaceOrder: false, - expectedProcessedMessage: - 'Do not place an order because TradingView recommendation is NEUTRAL.' - }, - { - name: - `when tradingView are enabled and recommendation is neutral, ` + - `but the action is overriden and checking tradingView is false, then place an order`, - rawData: { - symbol: 'BTCUPUSDT', - featureToggle: { notifyDebug: false, notifyOrderConfirm: true }, - symbolConfiguration: { - buy: { - enabled: true, - currentGridTradeIndex: 0, - currentGridTrade: { - triggerPercentage: 1, - minPurchaseAmount: 10, - maxPurchaseAmount: 50, - stopPercentage: 1.01, - limitPercentage: 1.011, - executed: false, - executedOrder: null - }, - tradingView: { - whenStrongBuy: true, - whenBuy: true - } - }, - botOptions: { - tradingView: { - useOnlyWithin: 5, - ifExpires: 'ignore' - } - } - }, - action: 'buy', - quoteAssetBalance: { free: 101, locked: 0 }, - buy: { - currentPrice: 200, - openOrders: [] - }, - tradingView: { - result: { - time: moment() - .utc() - .subtract('1', 'minute') - .format('YYYY-MM-DDTHH:mm:ss.SSSSSS'), - summary: { - RECOMMENDATION: 'NEUTRAL' - } - } - }, - overrideData: { - action: 'buy', - checkTradingView: false - } - }, - expectedToPlaceOrder: true, - expectedProcessedMessage: '' - } - ].forEach(t => { - describe(`${t.name}`, () => { - beforeEach(async () => { - mockGetAndCacheOpenOrdersForSymbol = jest.fn().mockResolvedValue([ + buy: { + currentPrice: 200, + openOrders: [ { - orderId: 123, - price: 202.2, - quantity: 0.24, - side: 'buy', - stopPrice: 202, - symbol: 'BTCUPUSDT', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' + orderId: 46838, + type: 'STOP_LOSS_LIMIT', + side: 'BUY', + price: '201.000000', + origQty: '0.5', + stopPrice: '200.000000' } - ]); - - binanceMock.client.order = jest.fn().mockResolvedValue({ - symbol: 'BTCUPUSDT', - orderId: 2701762317, - orderListId: -1, - clientOrderId: '6eGYHaJbmJrIS40eoq8ziM', - transactTime: 1626946722520 - }); + ], + processMessage: + 'There are open orders for BTCUPUSDT. Do not place an order for the grid trade #1.', + updatedAt: expect.any(Object) + } + }); + }); + }); - jest.mock('../../../trailingTradeHelper/common', () => ({ - getAccountInfoFromAPI: mockGetAccountInfoFromAPI, - isExceedAPILimit: mockIsExceedAPILimit, - getAPILimit: mockGetAPILimit, - saveOrderStats: mockSaveOrderStats, - saveOverrideAction: mockSaveOverrideAction, - getAndCacheOpenOrdersForSymbol: mockGetAndCacheOpenOrdersForSymbol - })); + describe('when current grid trade is not defined', () => { + beforeEach(async () => { + const step = require('../place-buy-order'); - rawData = _.cloneDeep(orgRawData); - rawData = _.merge(rawData, t.rawData); + rawData = _.cloneDeep(orgRawData); + rawData.symbolConfiguration.buy = { + enabled: true, + currentGridTradeIndex: -1, + currentGridTrade: null + }; - const step = require('../place-buy-order'); - result = await step.execute(loggerMock, rawData); - }); + result = await step.execute(loggerMock, rawData); + }); - if (t.expectedToPlaceOrder === true) { - if ( - t.rawData.featureToggle.notifyDebug === true || - t.rawData.featureToggle.notifyOrderConfirm === true - ) { - it('triggers slack.sendMessage for buy action', () => { - expect(slackMock.sendMessage.mock.calls[0][0]).toContain( - 'Action - Buy Trade #1: *STOP_LOSS_LIMIT' - ); - }); - - it('triggers slack.sendMessage for buy result', () => { - expect(slackMock.sendMessage.mock.calls[1][0]).toContain( - 'Buy Action Grid Trade #1 Result: *STOP_LOSS_LIMIT*' - ); - }); - } + doNotProcessTests(); - it('triggers binance.client.order', () => { - expect(binanceMock.client.order).toHaveBeenCalledWith({ - price: 202.2, - quantity: 0.24, - side: 'buy', - stopPrice: 202, - symbol: 'BTCUPUSDT', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - }); - }); + it('retruns expected value', () => { + expect(result).toMatchObject({ + buy: { + currentPrice: 200, + openOrders: [], + processMessage: + 'Current grid trade is not defined. Cannot place an order.', + updatedAt: expect.any(Object) + } + }); + }); + }); - it('triggers saveGridTradeOrder for grid trade last buy order', () => { - expect(mockSaveGridTradeOrder).toHaveBeenCalledWith( - loggerMock, - `${t.rawData.symbol}-grid-trade-last-buy-order`, - { - symbol: 'BTCUPUSDT', - orderId: 2701762317, - orderListId: -1, - clientOrderId: '6eGYHaJbmJrIS40eoq8ziM', - transactTime: 1626946722520, - currentGridTradeIndex: - t.rawData.symbolConfiguration.buy.currentGridTradeIndex - } - ); - }); + describe('when tradingView recommendation is not allowed', () => { + beforeEach(async () => { + const step = require('../place-buy-order'); - it('triggers getAndCacheOpenOrdersForSymbol', () => { - expect(mockGetAndCacheOpenOrdersForSymbol).toHaveBeenCalledWith( - loggerMock, - t.rawData.symbol - ); - }); + rawData = _.cloneDeep(orgRawData); + rawData.symbolConfiguration.buy = { + enabled: true, + currentGridTradeIndex: 0, + currentGridTrade: { + triggerPercentage: 1, + minPurchaseAmount: -1, + maxPurchaseAmount: -1, + stopPercentage: 1.01, + limitPercentage: 1.011, + executed: false, + executedOrder: null + } + }; - it('triggers getAccountInfoFromAPI', () => { - expect(mockGetAccountInfoFromAPI).toHaveBeenCalledWith( - loggerMock - ); - }); + mockIsBuyAllowedByTradingView = jest.fn().mockReturnValue({ + isTradingViewAllowed: false, + tradingViewRejectedReason: '' + }); - it('triggers saveOrderStats', () => { - expect(mockSaveOrderStats).toHaveBeenCalledWith(loggerMock, [ - 'BTCUPUSDT', - 'ETHBTC', - 'ALPHABTC', - 'BTCBRL', - 'BNBUSDT' - ]); - }); + result = await step.execute(loggerMock, rawData); + }); - it('retruns expected value', () => { - expect(result).toMatchObject({ - openOrders: [ - { - orderId: 123, - price: 202.2, - quantity: 0.24, - side: 'buy', - stopPrice: 202, - symbol: 'BTCUPUSDT', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], - buy: { - currentPrice: 200, - openOrders: [ - { - orderId: 123, - price: 202.2, - quantity: 0.24, - side: 'buy', - stopPrice: 202, - symbol: 'BTCUPUSDT', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], - processMessage: `Placed new stop loss limit order for buying of grid trade #${ - t.rawData.symbolConfiguration.buy.currentGridTradeIndex + 1 - }.`, - updatedAt: expect.any(Object) - } - }); - }); - } else { - doNotProcessTests(); + doNotProcessTests(); - it('returns expected value', () => { - expect(result).toMatchObject({ - buy: { - currentPrice: 200, - openOrders: [], - processMessage: t.expectedProcessedMessage, - updatedAt: expect.any(Object) - } - }); - }); + it('retruns expected value', () => { + expect(result).toMatchObject({ + buy: { + currentPrice: 200, + openOrders: [], + processMessage: + 'Min purchase amount must be configured. Please configure symbol settings.', + updatedAt: expect.any(Object) } }); }); @@ -911,6 +313,14 @@ describe('place-buy-order.js', () => { describe('when min purchase amount is not configured for some reason', () => { beforeEach(async () => { + mockIsBuyAllowedByTradingView = jest.fn().mockReturnValue({ + isTradingViewAllowed: false, + tradingViewRejectedReason: 'rejected reason' + }); + jest.mock('../../../trailingTradeHelper/tradingview', () => ({ + isBuyAllowedByTradingView: mockIsBuyAllowedByTradingView + })); + const step = require('../place-buy-order'); rawData = _.cloneDeep(orgRawData); @@ -925,25 +335,43 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }; + rawData.symbolConfiguration.botOptions.tradingViews = [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ]; result = await step.execute(loggerMock, rawData); }); doNotProcessTests(); + it('saves override action', () => { + expect(mockSaveOverrideAction).toHaveBeenCalledWith( + loggerMock, + 'BTCUPUSDT', + { + action: 'buy', + actionAt: expect.any(String), + checkTradingView: true, + notify: false, + triggeredBy: 'buy-order-trading-view' + }, + 'The bot queued the action to trigger the grid trade #1 for buying. rejected reason' + ); + }); it('retruns expected value', () => { expect(result).toMatchObject({ buy: { currentPrice: 200, openOrders: [], - processMessage: - 'Min purchase amount must be configured. Please configure symbol settings.', + processMessage: 'rejected reason', updatedAt: expect.any(Object) } }); @@ -966,13 +394,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }; + rawData.symbolConfiguration.botOptions.tradingViews = [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ]; + result = await step.execute(loggerMock, rawData); }); @@ -1019,14 +453,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -1040,7 +479,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 200, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: tradingViewValidTime + } + } + ] }, expected: { buy: { @@ -1078,14 +528,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -1099,7 +554,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 0.044866, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: tradingViewValidTime + } + } + ] }, expected: { buy: { @@ -1137,14 +603,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -1158,7 +629,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 0.00003771, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: tradingViewValidTime + } + } + ] }, expected: { buy: { @@ -1196,14 +678,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -1217,7 +704,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 268748, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: tradingViewValidTime + } + } + ] }, expected: { buy: { @@ -1283,7 +781,16 @@ describe('place-buy-order.js', () => { } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -1297,7 +804,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 200, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: tradingViewValidTime + } + } + ] }, expected: { buy: { @@ -1337,14 +855,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -1358,7 +881,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 0.044866, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: tradingViewValidTime + } + } + ] }, expected: { buy: { @@ -1398,14 +932,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -1419,7 +958,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 0.00003771, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: tradingViewValidTime + } + } + ] }, expected: { buy: { @@ -1457,16 +1007,21 @@ describe('place-buy-order.js', () => { maxPurchaseAmount: 50, stopPercentage: 1.01, limitPercentage: 1.011, - executed: false, - executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false + executed: false, + executedOrder: null } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -1480,7 +1035,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 268748, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: tradingViewValidTime + } + } + ] }, expected: { buy: { @@ -1532,10 +1098,6 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }; @@ -1562,12 +1124,11 @@ describe('place-buy-order.js', () => { mockIsExceedAPILimit = jest.fn().mockReturnValue(true); jest.mock('../../../trailingTradeHelper/common', () => ({ - getAccountInfoFromAPI: mockGetAccountInfoFromAPI, isExceedAPILimit: mockIsExceedAPILimit, getAPILimit: mockGetAPILimit, saveOrderStats: mockSaveOrderStats, saveOverrideAction: mockSaveOverrideAction, - getAndCacheOpenOrdersForSymbol: mockGetAndCacheOpenOrdersForSymbol + refreshOpenOrdersAndAccountInfo: mockRefreshOpenOrdersAndAccountInfo })); const step = require('../place-buy-order'); @@ -1597,19 +1158,6 @@ describe('place-buy-order.js', () => { describe('when free balance is less than minimum purchase amount', () => { describe('BTCUPUSDT', () => { beforeEach(async () => { - mockGetAndCacheOpenOrdersForSymbol = jest.fn().mockResolvedValue([ - { - orderId: 123, - price: 202.2, - quantity: 0.05, - side: 'buy', - stopPrice: 202, - symbol: 'BTCUPUSDT', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ]); - binanceMock.client.order = jest.fn().mockResolvedValue({ symbol: 'BTCUPUSDT', orderId: 2701762317, @@ -1619,12 +1167,12 @@ describe('place-buy-order.js', () => { }); jest.mock('../../../trailingTradeHelper/common', () => ({ - getAccountInfoFromAPI: mockGetAccountInfoFromAPI, isExceedAPILimit: mockIsExceedAPILimit, getAPILimit: mockGetAPILimit, saveOrderStats: mockSaveOrderStats, saveOverrideAction: mockSaveOverrideAction, - getAndCacheOpenOrdersForSymbol: mockGetAndCacheOpenOrdersForSymbol + refreshOpenOrdersAndAccountInfo: + mockRefreshOpenOrdersAndAccountInfo })); const step = require('../place-buy-order'); @@ -1660,14 +1208,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -1681,7 +1234,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 200, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: tradingViewValidTime + } + } + ] }; result = await step.execute(loggerMock, rawData); @@ -1708,18 +1272,6 @@ describe('place-buy-order.js', () => { [ { symbol: 'BTCUPUSDT', - mockGetAndCacheOpenOrdersForSymbol: [ - { - orderId: 123, - price: 202.2, - quantity: 0.05, - side: 'buy', - stopPrice: 202, - symbol: 'BTCUPUSDT', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], binanceMockClientOrderResult: { symbol: 'BTCUPUSDT', orderId: 2701762317, @@ -1758,14 +1310,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -1779,7 +1336,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 200, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: tradingViewValidTime + } + } + ] }, binanceMockClientOrderCalledWith: { price: 202.2, @@ -1800,32 +1368,10 @@ describe('place-buy-order.js', () => { }, expectedBalances: [{ asset: 'USDT', free: 39.89, locked: 20.11 }], expected: { - openOrders: [ - { - orderId: 123, - price: 202.2, - quantity: 0.05, - side: 'buy', - stopPrice: 202, - symbol: 'BTCUPUSDT', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ openOrders: 'retrieved' }], buy: { currentPrice: 200, - openOrders: [ - { - orderId: 123, - price: 202.2, - quantity: 0.05, - side: 'buy', - stopPrice: 202, - symbol: 'BTCUPUSDT', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ buyOpenOrders: 'retrived' }], processMessage: 'Placed new stop loss limit order for buying of grid trade #1.', updatedAt: expect.any(Object) @@ -1834,18 +1380,6 @@ describe('place-buy-order.js', () => { }, { symbol: 'ETHBTC', - mockGetAndCacheOpenOrdersForSymbol: [ - { - orderId: 456, - price: 0.045359, - quantity: 0.003, - side: 'buy', - stopPrice: 0.045314, - symbol: 'ETHBTC', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], binanceMockClientOrderResult: { symbol: 'ETHBTC', orderId: 2701762317, @@ -1884,14 +1418,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -1905,7 +1444,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 0.044866, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: tradingViewValidTime + } + } + ] }, binanceMockClientOrderCalledWith: { price: 0.045359, @@ -1928,32 +1478,10 @@ describe('place-buy-order.js', () => { { asset: 'BTC', free: 0.001863923, locked: 0.001136077 } ], expected: { - openOrders: [ - { - orderId: 456, - price: 0.045359, - quantity: 0.003, - side: 'buy', - stopPrice: 0.045314, - symbol: 'ETHBTC', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ openOrders: 'retrieved' }], buy: { currentPrice: 0.044866, - openOrders: [ - { - orderId: 456, - price: 0.045359, - quantity: 0.003, - side: 'buy', - stopPrice: 0.045314, - symbol: 'ETHBTC', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ buyOpenOrders: 'retrived' }], processMessage: 'Placed new stop loss limit order for buying of grid trade #1.', updatedAt: expect.any(Object) @@ -1962,18 +1490,6 @@ describe('place-buy-order.js', () => { }, { symbol: 'ALPHABTC', - mockGetAndCacheOpenOrdersForSymbol: [ - { - orderId: 456, - price: 0.00003812, - quantity: 3, - side: 'buy', - stopPrice: 0.00003808, - symbol: 'ALPHABTC', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], binanceMockClientOrderResult: { symbol: 'ALPHABTC', orderId: 2701762317, @@ -2012,14 +1528,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -2033,7 +1554,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 0.00003771, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: tradingViewValidTime + } + } + ] }, binanceMockClientOrderCalledWith: { price: 0.00003812, @@ -2056,32 +1588,10 @@ describe('place-buy-order.js', () => { { asset: 'BTC', free: 0.00188564, locked: 0.00011436000000000001 } ], expected: { - openOrders: [ - { - orderId: 456, - price: 0.00003812, - quantity: 3, - side: 'buy', - stopPrice: 0.00003808, - symbol: 'ALPHABTC', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ openOrders: 'retrieved' }], buy: { currentPrice: 0.00003771, - openOrders: [ - { - orderId: 456, - price: 0.00003812, - quantity: 3, - side: 'buy', - stopPrice: 0.00003808, - symbol: 'ALPHABTC', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ buyOpenOrders: 'retrived' }], processMessage: 'Placed new stop loss limit order for buying of grid trade #1.', updatedAt: expect.any(Object) @@ -2090,18 +1600,6 @@ describe('place-buy-order.js', () => { }, { symbol: 'BTCBRL', - mockGetAndCacheOpenOrdersForSymbol: [ - { - orderId: 456, - price: 271704, - quantity: 0.000037, - side: 'buy', - stopPrice: 271435, - symbol: 'BTCBRL', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], binanceMockClientOrderResult: { symbol: 'BTCBRL', orderId: 2701762317, @@ -2140,14 +1638,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -2161,7 +1664,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 268748, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: tradingViewValidTime + } + } + ] }, binanceMockClientOrderCalledWith: { price: 271704, @@ -2188,32 +1702,10 @@ describe('place-buy-order.js', () => { } ], expected: { - openOrders: [ - { - orderId: 456, - price: 271704, - quantity: 0.000037, - side: 'buy', - stopPrice: 271435, - symbol: 'BTCBRL', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ openOrders: 'retrieved' }], buy: { currentPrice: 268748, - openOrders: [ - { - orderId: 456, - price: 271704, - quantity: 0.000037, - side: 'buy', - stopPrice: 271435, - symbol: 'BTCBRL', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ buyOpenOrders: 'retrived' }], processMessage: 'Placed new stop loss limit order for buying of grid trade #1.', updatedAt: expect.any(Object) @@ -2222,18 +1714,6 @@ describe('place-buy-order.js', () => { }, { symbol: 'BNBUSDT', - mockGetAndCacheOpenOrdersForSymbol: [ - { - orderId: 456, - price: 271704, - quantity: 0.000037, - side: 'buy', - stopPrice: 271435, - symbol: 'BNBUSDT', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], binanceMockClientOrderResult: { symbol: 'BNBUSDT', orderId: 2701762317, @@ -2272,14 +1752,19 @@ describe('place-buy-order.js', () => { maxPurchaseAmount: 10, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -2293,7 +1778,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 289.48, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: tradingViewValidTime + } + } + ] }, binanceMockClientOrderCalledWith: { price: 297, @@ -2316,32 +1812,10 @@ describe('place-buy-order.js', () => { { asset: 'USDT', free: 89.9614, locked: 20.0386 } ], expected: { - openOrders: [ - { - orderId: 456, - price: 271704, - quantity: 0.000037, - side: 'buy', - stopPrice: 271435, - symbol: 'BNBUSDT', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ openOrders: 'retrieved' }], buy: { currentPrice: 289.48, - openOrders: [ - { - orderId: 456, - price: 271704, - quantity: 0.000037, - side: 'buy', - stopPrice: 271435, - symbol: 'BNBUSDT', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ buyOpenOrders: 'retrived' }], processMessage: 'Placed new stop loss limit order for buying of grid trade #2.', updatedAt: expect.any(Object) @@ -2351,21 +1825,17 @@ describe('place-buy-order.js', () => { ].forEach(t => { describe(`${t.symbol}`, () => { beforeEach(async () => { - mockGetAndCacheOpenOrdersForSymbol = jest - .fn() - .mockResolvedValue(t.mockGetAndCacheOpenOrdersForSymbol); binanceMock.client.order = jest .fn() .mockResolvedValue(t.binanceMockClientOrderResult); jest.mock('../../../trailingTradeHelper/common', () => ({ - getAccountInfoFromAPI: mockGetAccountInfoFromAPI, isExceedAPILimit: mockIsExceedAPILimit, getAPILimit: mockGetAPILimit, saveOrderStats: mockSaveOrderStats, saveOverrideAction: mockSaveOverrideAction, - getAndCacheOpenOrdersForSymbol: - mockGetAndCacheOpenOrdersForSymbol + refreshOpenOrdersAndAccountInfo: + mockRefreshOpenOrdersAndAccountInfo })); const step = require('../place-buy-order'); @@ -2389,19 +1859,13 @@ describe('place-buy-order.js', () => { ); }); - it('triggers getAndCacheOpenOrdersForSymbol', () => { - expect(mockGetAndCacheOpenOrdersForSymbol).toHaveBeenCalledWith( + it('triggers refreshOpenOrdersAndAccountInfo', () => { + expect(mockRefreshOpenOrdersAndAccountInfo).toHaveBeenCalledWith( loggerMock, t.symbol ); }); - it('triggers getAccountInfoFromAPI', () => { - expect(mockGetAccountInfoFromAPI).toHaveBeenCalledWith( - loggerMock - ); - }); - it('triggers saveOrderStats', () => { expect(mockSaveOrderStats).toHaveBeenCalledWith( loggerMock, @@ -2420,18 +1884,6 @@ describe('place-buy-order.js', () => { [ { symbol: 'BTCUPUSDT', - openOrders: [ - { - orderId: 123, - price: 202.2, - quantity: 0.24, - side: 'buy', - stopPrice: 202, - symbol: 'BTCUPUSDT', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], binanceMockClientOrderResult: { symbol: 'BTCUPUSDT', orderId: 2701762317, @@ -2470,14 +1922,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -2491,7 +1948,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 200, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: tradingViewValidTime + } + } + ] }, binanceMockClientOrderCalledWith: { price: 202.2, @@ -2518,32 +1986,10 @@ describe('place-buy-order.js', () => { } ], expected: { - openOrders: [ - { - orderId: 123, - price: 202.2, - quantity: 0.24, - side: 'buy', - stopPrice: 202, - symbol: 'BTCUPUSDT', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ openOrders: 'retrieved' }], buy: { currentPrice: 200, - openOrders: [ - { - orderId: 123, - price: 202.2, - quantity: 0.24, - side: 'buy', - stopPrice: 202, - symbol: 'BTCUPUSDT', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ buyOpenOrders: 'retrived' }], processMessage: 'Placed new stop loss limit order for buying of grid trade #1.', updatedAt: expect.any(Object) @@ -2552,18 +1998,6 @@ describe('place-buy-order.js', () => { }, { symbol: 'ETHBTC', - openOrders: [ - { - orderId: 456, - price: 0.045359, - quantity: 0.022, - side: 'buy', - stopPrice: 0.045314, - symbol: 'ETHBTC', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], binanceMockClientOrderResult: { symbol: 'ETHBTC', orderId: 2701762317, @@ -2602,14 +2036,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -2623,7 +2062,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 0.044866, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: tradingViewValidTime + } + } + ] }, binanceMockClientOrderCalledWith: { price: 0.045359, @@ -2650,32 +2100,10 @@ describe('place-buy-order.js', () => { } ], expected: { - openOrders: [ - { - orderId: 456, - price: 0.045359, - quantity: 0.022, - side: 'buy', - stopPrice: 0.045314, - symbol: 'ETHBTC', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ openOrders: 'retrieved' }], buy: { currentPrice: 0.044866, - openOrders: [ - { - orderId: 456, - price: 0.045359, - quantity: 0.022, - side: 'buy', - stopPrice: 0.045314, - symbol: 'ETHBTC', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ buyOpenOrders: 'retrived' }], processMessage: 'Placed new stop loss limit order for buying of grid trade #1.', updatedAt: expect.any(Object) @@ -2684,18 +2112,6 @@ describe('place-buy-order.js', () => { }, { symbol: 'ALPHABTC', - openOrders: [ - { - orderId: 456, - price: 0.00003812, - quantity: 26, - side: 'buy', - stopPrice: 0.00003808, - symbol: 'ALPHABTC', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], binanceMockClientOrderResult: { symbol: 'ALPHABTC', orderId: 2701762317, @@ -2734,14 +2150,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -2755,7 +2176,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 0.00003771, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: tradingViewValidTime + } + } + ] }, binanceMockClientOrderCalledWith: { price: 0.00003812, @@ -2782,32 +2214,10 @@ describe('place-buy-order.js', () => { } ], expected: { - openOrders: [ - { - orderId: 456, - price: 0.00003812, - quantity: 26, - side: 'buy', - stopPrice: 0.00003808, - symbol: 'ALPHABTC', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ openOrders: 'retrieved' }], buy: { currentPrice: 0.00003771, - openOrders: [ - { - orderId: 456, - price: 0.00003812, - quantity: 26, - side: 'buy', - stopPrice: 0.00003808, - symbol: 'ALPHABTC', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ buyOpenOrders: 'retrived' }], processMessage: 'Placed new stop loss limit order for buying of grid trade #1.', updatedAt: expect.any(Object) @@ -2866,14 +2276,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -2887,7 +2302,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 268748, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: tradingViewValidTime + } + } + ] }, binanceMockClientOrderCalledWith: { price: 271704, @@ -2914,32 +2340,10 @@ describe('place-buy-order.js', () => { } ], expected: { - openOrders: [ - { - orderId: 456, - price: 271704, - quantity: 0.00004, - side: 'buy', - stopPrice: 271435, - symbol: 'BTCBRL', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ openOrders: 'retrieved' }], buy: { currentPrice: 268748, - openOrders: [ - { - orderId: 456, - price: 271704, - quantity: 0.00004, - side: 'buy', - stopPrice: 271435, - symbol: 'BTCBRL', - timeInForce: 'GTC', - type: 'STOP_LOSS_LIMIT' - } - ], + openOrders: [{ buyOpenOrders: 'retrived' }], processMessage: 'Placed new stop loss limit order for buying of grid trade #1.', updatedAt: expect.any(Object) @@ -2949,22 +2353,17 @@ describe('place-buy-order.js', () => { ].forEach(t => { describe(`${t.symbol}`, () => { beforeEach(async () => { - mockGetAndCacheOpenOrdersForSymbol = jest - .fn() - .mockResolvedValue(t.openOrders); - binanceMock.client.order = jest .fn() .mockResolvedValue(t.binanceMockClientOrderResult); jest.mock('../../../trailingTradeHelper/common', () => ({ - getAccountInfoFromAPI: mockGetAccountInfoFromAPI, isExceedAPILimit: mockIsExceedAPILimit, getAPILimit: mockGetAPILimit, saveOrderStats: mockSaveOrderStats, saveOverrideAction: mockSaveOverrideAction, - getAndCacheOpenOrdersForSymbol: - mockGetAndCacheOpenOrdersForSymbol + refreshOpenOrdersAndAccountInfo: + mockRefreshOpenOrdersAndAccountInfo })); const step = require('../place-buy-order'); @@ -2988,19 +2387,13 @@ describe('place-buy-order.js', () => { ); }); - it('triggers getAndCacheOpenOrdersForSymbol', () => { - expect(mockGetAndCacheOpenOrdersForSymbol).toHaveBeenCalledWith( + it('triggers refreshOpenOrdersAndAccountInfo', () => { + expect(mockRefreshOpenOrdersAndAccountInfo).toHaveBeenCalledWith( loggerMock, t.symbol ); }); - it('triggers getAccountInfoFromAPI', () => { - expect(mockGetAccountInfoFromAPI).toHaveBeenCalledWith( - loggerMock - ); - }); - it('triggers saveOrderStats', () => { expect(mockSaveOrderStats).toHaveBeenCalledWith( loggerMock, @@ -3017,8 +2410,6 @@ describe('place-buy-order.js', () => { describe('when order is placed, but cache is not returning open orders due to a cache error', () => { beforeEach(async () => { - mockGetAndCacheOpenOrdersForSymbol = jest.fn().mockResolvedValue([]); - binanceMock.client.order = jest.fn().mockResolvedValue({ symbol: 'BTCUPUSDT', orderId: 2701762317, @@ -3028,12 +2419,11 @@ describe('place-buy-order.js', () => { }); jest.mock('../../../trailingTradeHelper/common', () => ({ - getAccountInfoFromAPI: mockGetAccountInfoFromAPI, isExceedAPILimit: mockIsExceedAPILimit, getAPILimit: mockGetAPILimit, saveOrderStats: mockSaveOrderStats, saveOverrideAction: mockSaveOverrideAction, - getAndCacheOpenOrdersForSymbol: mockGetAndCacheOpenOrdersForSymbol + refreshOpenOrdersAndAccountInfo: mockRefreshOpenOrdersAndAccountInfo })); const step = require('../place-buy-order'); @@ -3064,14 +2454,19 @@ describe('place-buy-order.js', () => { limitPercentage: 1.011, executed: false, executedOrder: null - }, - tradingView: { - whenStrongBuy: false, - whenBuy: false } }, botOptions: { - tradingView: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } @@ -3085,7 +2480,18 @@ describe('place-buy-order.js', () => { buy: { currentPrice: 200, openOrders: [] - } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: tradingViewValidTime + } + } + ] }); result = await step.execute(loggerMock, rawData); @@ -3118,17 +2524,13 @@ describe('place-buy-order.js', () => { ); }); - it('triggers getAndCacheOpenOrdersForSymbol', () => { - expect(mockGetAndCacheOpenOrdersForSymbol).toHaveBeenCalledWith( + it('triggers refreshOpenOrdersAndAccountInfo', () => { + expect(mockRefreshOpenOrdersAndAccountInfo).toHaveBeenCalledWith( loggerMock, 'BTCUPUSDT' ); }); - it('triggers getAccountInfoFromAPI', () => { - expect(mockGetAccountInfoFromAPI).toHaveBeenCalledWith(loggerMock); - }); - it('triggers saveOrderStats', () => { expect(mockSaveOrderStats).toHaveBeenCalledWith(loggerMock, [ 'BTCUPUSDT', @@ -3153,10 +2555,10 @@ describe('place-buy-order.js', () => { it('retruns expected value', () => { expect(result).toMatchObject({ - openOrders: [], + openOrders: [{ openOrders: 'retrieved' }], buy: { currentPrice: 200, - openOrders: [], + openOrders: [{ buyOpenOrders: 'retrived' }], processMessage: 'Placed new stop loss limit order for buying of grid trade #1.', updatedAt: expect.any(Object) diff --git a/app/cronjob/trailingTrade/step/__tests__/save-data-to-cache.test.js b/app/cronjob/trailingTrade/step/__tests__/save-data-to-cache.test.js index 2f39e001..1a1b1176 100644 --- a/app/cronjob/trailingTrade/step/__tests__/save-data-to-cache.test.js +++ b/app/cronjob/trailingTrade/step/__tests__/save-data-to-cache.test.js @@ -47,9 +47,11 @@ describe('save-data-to-cache.js', () => { symbols: ['BTCUSDT', 'ETHUSDT'] }, other: 'data', - tradingView: { - some: 'thing' - } + tradingViews: [ + { + some: 'thing' + } + ] }; result = await step.execute(logger, rawData); diff --git a/app/cronjob/trailingTrade/step/determine-action.js b/app/cronjob/trailingTrade/step/determine-action.js index db9f26e6..20f212e7 100644 --- a/app/cronjob/trailingTrade/step/determine-action.js +++ b/app/cronjob/trailingTrade/step/determine-action.js @@ -9,6 +9,9 @@ const { getAPILimit } = require('../../trailingTradeHelper/common'); const { getGridTradeOrder } = require('../../trailingTradeHelper/order'); +const { + shouldForceSellByTradingView +} = require('../../trailingTradeHelper/tradingview'); /** * Check whether current price is lower or equal than stop loss trigger price @@ -213,158 +216,6 @@ const isHigherThanSellTriggerPrice = data => { return sellCurrentPrice >= sellTriggerPrice; }; -/** - * Check whether should execute stop-loss if recommendation is neutral, sell or strong sell - * - * @param {*} logger - * @param {*} data - * @returns - */ -const shouldForceSellByTradingViewRecommendation = (logger, data) => { - const { - symbolInfo: { - filterLotSize: { stepSize }, - filterMinNotional: { minNotional } - }, - symbolConfiguration: { - sell: { - tradingView: { - forceSellOverZeroBelowTriggerPrice: { - whenNeutral: tradingViewForceSellWhenNeutral, - whenSell: tradingViewForceSellWhenSell, - whenStrongSell: tradingViewForceSellWhenStrongSell - } - } - }, - botOptions: { - tradingView: { useOnlyWithin: tradingViewUseOnlyWithin } - } - }, - baseAssetBalance: { free: baseAssetFreeBalance }, - sell: { - currentProfit: sellCurrentProfit, - currentPrice: sellCurrentPrice, - triggerPrice: sellTriggerPrice - }, - tradingView - } = data; - - // If tradingView force sell configuration is not enabled, then no need to process. - if ( - tradingViewForceSellWhenNeutral === false && - tradingViewForceSellWhenSell === false && - tradingViewForceSellWhenStrongSell === false - ) { - logger.info( - { tradingViewForceSellWhenSell, tradingViewForceSellWhenStrongSell }, - 'TradingView recommendation is not enabled.' - ); - - return { shouldForceSell: false, forceSellMessage: '' }; - } - - const tradingViewTime = _.get(tradingView, 'result.time', ''); - - const tradingViewSummaryRecommendation = _.get( - tradingView, - 'result.summary.RECOMMENDATION', - '' - ); - - if (tradingViewTime === '' || tradingViewSummaryRecommendation === '') { - logger.info( - { tradingViewTime, tradingViewSummaryRecommendation }, - 'TradingView time or recommendation is empty. Ignore TradingView recommendation.' - ); - - return { shouldForceSell: false, forceSellMessage: '' }; - } - - // If tradingViewTime is more than configured time, then ignore TradingView recommendation. - const tradingViewUpdatedAt = moment - .utc(tradingViewTime, 'YYYY-MM-DDTHH:mm:ss.SSSSSS') - .add(tradingViewUseOnlyWithin, 'minutes'); - const currentTime = moment.utc(); - if (tradingViewUpdatedAt.isBefore(currentTime)) { - logger.info( - { - tradingViewUpdatedAt: tradingViewUpdatedAt.toISOString(), - currentTime: currentTime.toISOString() - }, - `TradingView data is older than ${tradingViewUseOnlyWithin} minutes. Ignore TradingView recommendation.` - ); - - return { - shouldForceSell: false, - forceSellMessage: - `TradingView data is older than ${tradingViewUseOnlyWithin} minutes. ` + - `Ignore TradingView recommendation.` - }; - } - - // If current profit is less than 0 or current price is more than trigger price - if (sellCurrentProfit <= 0 || sellCurrentPrice > sellTriggerPrice) { - logger.info( - { sellCurrentProfit, sellCurrentPrice, sellTriggerPrice }, - `Current profit if equal or less than 0 or ` + - `current price is more than trigger price. Ignore TradingView recommendation.` - ); - - return { shouldForceSell: false, forceSellMessage: '' }; - } - - // Only execute when the free balance is more than minimum notional value. - const lotPrecision = parseFloat(stepSize) === 1 ? 0 : stepSize.indexOf(1) - 1; - const freeBalance = parseFloat(_.floor(baseAssetFreeBalance, lotPrecision)); - const orderQuantity = parseFloat( - _.floor(freeBalance - freeBalance * (0.1 / 100), lotPrecision) - ); - - if (orderQuantity * sellCurrentPrice < parseFloat(minNotional)) { - logger.info( - { sellCurrentProfit, sellCurrentPrice, sellTriggerPrice }, - 'Order quantity is less than minimum notional value. Ignore TradingView recommendation.' - ); - - return { shouldForceSell: false, forceSellMessage: '' }; - } - - logger.info({ freeBalance }, 'Free balance'); - - // Get force sell recommendation - const forceSellRecommendations = []; - if (tradingViewForceSellWhenNeutral) { - forceSellRecommendations.push('neutral'); - } - - if (tradingViewForceSellWhenSell) { - forceSellRecommendations.push('sell'); - } - - if (tradingViewForceSellWhenStrongSell) { - forceSellRecommendations.push('strong_sell'); - } - - // If summary recommendation is force sell recommendation, then execute force sell - if ( - forceSellRecommendations.length > 0 && - forceSellRecommendations.includes( - tradingViewSummaryRecommendation.toLowerCase() - ) === true - ) { - return { - shouldForceSell: true, - forceSellMessage: - `TradingView recommendation is ${tradingViewSummaryRecommendation}. ` + - `The current profit (${sellCurrentProfit}) is more than 0 and the current price (${sellCurrentPrice}) ` + - `is under trigger price (${sellTriggerPrice}). Sell at market price.` - }; - } - - // Otherwise, simply ignore - return { shouldForceSell: false, forceSellMessage: '' }; -}; - /** * Set sell action and message * @@ -538,8 +389,10 @@ const execute = async (logger, rawData) => { } // If tradingView recommendation is sell or strong sell - const { shouldForceSell, forceSellMessage } = - shouldForceSellByTradingViewRecommendation(logger, data); + const { shouldForceSell, forceSellMessage } = shouldForceSellByTradingView( + logger, + data + ); if (shouldForceSell) { // Prevent disable by stop-loss data.canDisable = false; diff --git a/app/cronjob/trailingTrade/step/ensure-grid-trade-order-executed.js b/app/cronjob/trailingTrade/step/ensure-grid-trade-order-executed.js index d427e883..8e181e9d 100644 --- a/app/cronjob/trailingTrade/step/ensure-grid-trade-order-executed.js +++ b/app/cronjob/trailingTrade/step/ensure-grid-trade-order-executed.js @@ -170,8 +170,8 @@ const saveGridTrade = async (logger, rawData, order) => { if (notifyDebug) { slack.sendMessage( `*${symbol}* ${side.toUpperCase()} Grid Trade Updated: *${type}*\n` + - `- New Gird Trade: \`\`\`${JSON.stringify( - newGridTrade, + `- Updated Gird Trade: \`\`\`${JSON.stringify( + currentGridTrade, undefined, 2 )}\`\`\``, diff --git a/app/cronjob/trailingTrade/step/get-indicators.js b/app/cronjob/trailingTrade/step/get-indicators.js index b5f73082..08b2fa7f 100644 --- a/app/cronjob/trailingTrade/step/get-indicators.js +++ b/app/cronjob/trailingTrade/step/get-indicators.js @@ -189,7 +189,8 @@ const execute = async (logger, rawData) => { enabled: sellConservativeModeEnabled, factor: conservativeFactor } - } + }, + botOptions: { tradingViews: tradingViewsConfig } }, baseAssetBalance: { total: baseAssetTotalBalance }, openOrders @@ -296,12 +297,20 @@ const execute = async (logger, rawData) => { }; } - const cachedTradingView = - JSON.parse(await cache.hget('trailing-trade-tradingview', `${symbol}`)) || - {}; - // Set trading view - data.tradingView = cachedTradingView; + data.tradingViews = _.map( + await cache.hgetall( + `trailing-trade-tradingview:${symbol}:`, + `trailing-trade-tradingview:${symbol}:*` + ), + tradingView => JSON.parse(tradingView) + ); + + const tradingViewIntervals = (tradingViewsConfig || []).map(c => c.interval); + // Filter out only configured interval + data.tradingViews = data.tradingViews.filter(tv => + tradingViewIntervals.includes(tv.request.interval) + ); // Set last candle data.lastCandle = cachedLatestCandle; diff --git a/app/cronjob/trailingTrade/step/get-override-action.js b/app/cronjob/trailingTrade/step/get-override-action.js index 8e3d6990..5b87723f 100644 --- a/app/cronjob/trailingTrade/step/get-override-action.js +++ b/app/cronjob/trailingTrade/step/get-override-action.js @@ -24,19 +24,11 @@ const shouldRescheduleBuyAction = async (logger, data) => { }, botOptions: { autoTriggerBuy: { - conditions: { - whenLessThanATHRestriction, - afterDisabledPeriod, - tradingView: { - whenStrongBuy: tradingViewWhenStrongBuy, - whenBuy: tradingViewWhenBuy - } - } + conditions: { whenLessThanATHRestriction, afterDisabledPeriod } } } }, - buy: { currentPrice, athRestrictionPrice }, - tradingView + buy: { currentPrice, athRestrictionPrice } } = data; // If the current price is higher than the restriction price, reschedule it @@ -78,42 +70,6 @@ const shouldRescheduleBuyAction = async (logger, data) => { return { shouldReschedule: true, rescheduleReason }; } - // If the tradingview recommendation is not allowed, then re-schedule - const allowedRecommendations = []; - if (tradingViewWhenStrongBuy) { - allowedRecommendations.push('strong_buy'); - } - if (tradingViewWhenBuy) { - allowedRecommendations.push('buy'); - } - - const tradingViewSummaryRecommendation = _.get( - tradingView, - 'result.summary.RECOMMENDATION', - '' - ); - - if ( - allowedRecommendations.length > 0 && - tradingViewSummaryRecommendation !== '' && - allowedRecommendations.includes( - tradingViewSummaryRecommendation.toLowerCase() - ) === false - ) { - const rescheduleReason = - `The auto-trigger buy action needs to be re-scheduled ` + - `because the TradingView recommendation is ${tradingViewSummaryRecommendation}.`; - logger.info( - { - afterDisabledPeriod, - checkDisable - }, - rescheduleReason - ); - - return { shouldReschedule: true, rescheduleReason }; - } - return { shouldReschedule: false, rescheduleReason: null }; }; diff --git a/app/cronjob/trailingTrade/step/place-buy-order.js b/app/cronjob/trailingTrade/step/place-buy-order.js index 05dad190..1540dd8f 100644 --- a/app/cronjob/trailingTrade/step/place-buy-order.js +++ b/app/cronjob/trailingTrade/step/place-buy-order.js @@ -6,129 +6,12 @@ const { getAPILimit, saveOrderStats, saveOverrideAction, - getAndCacheOpenOrdersForSymbol, - getAccountInfoFromAPI + refreshOpenOrdersAndAccountInfo } = require('../../trailingTradeHelper/common'); const { saveGridTradeOrder } = require('../../trailingTradeHelper/order'); - -/** - * Check whether recommendation is allowed or not. - * - * @param {*} logger - * @param {*} data - * @returns - */ -const isAllowedTradingViewRecommendation = (logger, data) => { - const { - symbolConfiguration: { - buy: { - tradingView: { - whenStrongBuy: tradingViewWhenStrongBuy, - whenBuy: tradingViewWhenBuy - } - }, - botOptions: { - tradingView: { - useOnlyWithin: tradingViewUseOnlyWithin, - ifExpires: tradingViewIfExpires - } - } - }, - tradingView, - overrideData - } = data; - - const overrideCheckTradingView = _.get( - overrideData, - 'checkTradingView', - false - ); - - // If this is override action, then process buy regardless recommendation. - if (overrideCheckTradingView === false && _.isEmpty(overrideData) === false) { - logger.info( - { overrideData }, - 'Override data is not empty. Ignore TradingView recommendation.' - ); - return { isTradingViewAllowed: true, tradingViewRejectedReason: '' }; - } - - // If there is no tradingView result time or recommendation, then ignore TradingView recommendation. - const tradingViewTime = _.get(tradingView, 'result.time', ''); - - const tradingViewSummaryRecommendation = _.get( - tradingView, - 'result.summary.RECOMMENDATION', - '' - ); - if (tradingViewTime === '' || tradingViewSummaryRecommendation === '') { - logger.info( - { tradingViewTime, tradingViewSummaryRecommendation }, - 'TradingView time or recommendation is empty. Ignore TradingView recommendation.' - ); - return { isTradingViewAllowed: true, tradingViewRejectedReason: '' }; - } - - // If tradingViewTime is more than configured time, then ignore TradingView recommendation. - const tradingViewUpdatedAt = moment - .utc(tradingViewTime, 'YYYY-MM-DDTHH:mm:ss.SSSSSS') - .add(tradingViewUseOnlyWithin, 'minutes'); - const currentTime = moment.utc(); - if (tradingViewUpdatedAt.isBefore(currentTime)) { - if (tradingViewIfExpires === 'do-not-buy') { - logger.info( - { - tradingViewUpdatedAt: tradingViewUpdatedAt.toISOString(), - currentTime: currentTime.toISOString() - }, - `TradingView data is older than ${tradingViewUseOnlyWithin} minutes. Do not buy.` - ); - return { - isTradingViewAllowed: false, - tradingViewRejectedReason: - `Do not place an order because ` + - `TradingView data is older than ${tradingViewUseOnlyWithin} minutes.` - }; - } - - logger.info( - { - tradingViewUpdatedAt: tradingViewUpdatedAt.toISOString(), - currentTime: currentTime.toISOString() - }, - `TradingView data is older than ${tradingViewUseOnlyWithin} minutes. Ignore TradingView recommendation.` - ); - return { isTradingViewAllowed: true, tradingViewRejectedReason: '' }; - } - - // Get allowed recommendation - const allowedRecommendations = []; - if (tradingViewWhenStrongBuy) { - allowedRecommendations.push('strong_buy'); - } - - if (tradingViewWhenBuy) { - allowedRecommendations.push('buy'); - } - - // If summary recommendation is not allowed recommendation, then prevent buy - if ( - allowedRecommendations.length > 0 && - allowedRecommendations.includes( - tradingViewSummaryRecommendation.toLowerCase() - ) === false - ) { - return { - isTradingViewAllowed: false, - tradingViewRejectedReason: - `Do not place an order because ` + - `TradingView recommendation is ${tradingViewSummaryRecommendation}.` - }; - } - - // Otherwise, simply allow - return { isTradingViewAllowed: true, tradingViewRejectedReason: '' }; -}; +const { + isBuyAllowedByTradingView +} = require('../../trailingTradeHelper/tradingview'); /** * Set message and return data @@ -173,10 +56,9 @@ const execute = async (logger, rawData) => { action, quoteAssetBalance: { free: quoteAssetFreeBalance }, buy: { currentPrice, triggerPrice, openOrders }, - tradingView, + tradingViews, overrideData } = data; - const humanisedGridTradeIndex = currentGridTradeIndex + 1; if (action !== 'buy') { @@ -202,7 +84,8 @@ const execute = async (logger, rawData) => { } const { isTradingViewAllowed, tradingViewRejectedReason } = - isAllowedTradingViewRecommendation(logger, data); + isBuyAllowedByTradingView(logger, data); + if (isTradingViewAllowed === false) { await saveOverrideAction( logger, @@ -381,13 +264,13 @@ const execute = async (logger, rawData) => { stopPercentage, limitPrice, triggerPrice, - tradingView: { + tradingViews: _.map(tradingViews, tradingView => ({ request: _.get(tradingView, 'request', {}), result: { time: _.get(tradingView, 'result.time', ''), summary: _.get(tradingView, 'result.summary', {}) } - }, + })), overrideData }; @@ -429,15 +312,15 @@ const execute = async (logger, rawData) => { // Save number of open orders await saveOrderStats(logger, symbols); - // FIXME: If you change this comment, please refactor to use common.js:refreshOpenOrdersAndAccountInfo - // Get open orders and update cache - data.openOrders = await getAndCacheOpenOrdersForSymbol(logger, symbol); - data.buy.openOrders = data.openOrders.filter( - o => o.side.toLowerCase() === 'buy' - ); - - // Refresh account info - data.accountInfo = await getAccountInfoFromAPI(logger); + const { + accountInfo, + openOrders: updatedOpenOrders, + buyOpenOrders + } = await refreshOpenOrdersAndAccountInfo(logger, symbol); + + data.accountInfo = accountInfo; + data.openOrders = updatedOpenOrders; + data.buy.openOrders = buyOpenOrders; if (notifyDebug || notifyOrderConfirm) slack.sendMessage( diff --git a/app/cronjob/trailingTrade/step/save-data-to-cache.js b/app/cronjob/trailingTrade/step/save-data-to-cache.js index c7428399..6c6320fd 100644 --- a/app/cronjob/trailingTrade/step/save-data-to-cache.js +++ b/app/cronjob/trailingTrade/step/save-data-to-cache.js @@ -26,7 +26,7 @@ const execute = async (logger, rawData) => { 'closedTrades', 'accountInfo', 'symbolConfiguration.symbols', - 'tradingView' + 'tradingViews' ]); await mongo.upsertOne(logger, 'trailing-trade-cache', filter, document); diff --git a/app/cronjob/trailingTradeHelper/__tests__/configuration.test.js b/app/cronjob/trailingTradeHelper/__tests__/configuration.test.js index a4089855..5955fd0e 100644 --- a/app/cronjob/trailingTradeHelper/__tests__/configuration.test.js +++ b/app/cronjob/trailingTradeHelper/__tests__/configuration.test.js @@ -9,6 +9,7 @@ describe('configuration.js', () => { beforeEach(async () => { jest.clearAllMocks().resetModules(); + cache.hdelall = jest.fn().mockResolvedValue(true); }); describe('saveGlobalConfiguration', () => { @@ -3138,7 +3139,7 @@ describe('configuration.js', () => { cache.del = jest.fn().mockResolvedValue(true); cache.hdelall = jest.fn().mockResolvedValue(true); - cache.hget = jest.fn().mockImplementation((hash, _key) => { + cache.hgetWithoutLock = jest.fn().mockImplementation((hash, _key) => { if (hash === 'trailing-trade-symbols') { return Promise.resolve( JSON.stringify({ @@ -3167,6 +3168,26 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, buy: { @@ -3229,7 +3250,7 @@ describe('configuration.js', () => { describe('without symbol', () => { describe('when cache is available', () => { beforeEach(async () => { - cache.hget = jest.fn().mockImplementation((hash, key) => { + cache.hgetWithoutLock = jest.fn().mockImplementation((hash, key) => { if (hash === 'trailing-trade-configurations' && key === 'global') { return Promise.resolve( JSON.stringify({ @@ -3344,6 +3365,26 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, system: { @@ -3411,6 +3452,42 @@ describe('configuration.js', () => { disableBuyMinutes: 65, orderType: 'market' } + }, + botOptions: { + tradingViews: [ + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: false + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '30m', + buy: { + whenStrongBuy: true, + whenBuy: false + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' + } } }; } @@ -3492,6 +3569,26 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, system: { @@ -3562,6 +3659,26 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, system: { @@ -3579,7 +3696,7 @@ describe('configuration.js', () => { describe('with symbol', () => { describe('when cache is available', () => { beforeEach(async () => { - cache.hget = jest.fn().mockImplementation((hash, key) => { + cache.hgetWithoutLock = jest.fn().mockImplementation((hash, key) => { if (hash === 'trailing-trade-configurations' && key === 'BTCUSDT') { return Promise.resolve( JSON.stringify({ @@ -3689,6 +3806,26 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, system: { @@ -3769,6 +3906,26 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, system: { @@ -3887,6 +4044,26 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, system: { @@ -3996,6 +4173,26 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, system: { @@ -4126,6 +4323,26 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, system: { @@ -4252,6 +4469,26 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, system: { @@ -4369,6 +4606,26 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, system: { @@ -4384,7 +4641,7 @@ describe('configuration.js', () => { describe('when cached symbol info is not valid', () => { beforeEach(async () => { - cache.hget = jest.fn().mockResolvedValue(null); + cache.hgetWithoutLock = jest.fn().mockResolvedValue(null); mongo.findOne = jest.fn((_logger, collection, filter) => { if ( @@ -4486,6 +4743,26 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, system: { @@ -4647,6 +4924,42 @@ describe('configuration.js', () => { orderType: 'market' } }, + botOptions: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' + } + }, system: { temporaryDisableActionAfterConfirmingOrder: 10, checkManualBuyOrderPeriod: 10, @@ -4706,6 +5019,42 @@ describe('configuration.js', () => { disableBuyMinutes: 65, orderType: 'market' } + }, + botOptions: { + tradingViews: [ + { + interval: '30m', + buy: { + whenStrongBuy: true, + whenBuy: false + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' + } } }; } @@ -4795,6 +5144,40 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '30m', + buy: { + whenStrongBuy: true, + whenBuy: false + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, system: { @@ -4881,6 +5264,28 @@ describe('configuration.js', () => { orderType: 'market' } }, + botOptions: { + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' + } + }, system: { temporaryDisableActionAfterConfirmingOrder: 10, checkManualBuyOrderPeriod: 10, @@ -4963,6 +5368,28 @@ describe('configuration.js', () => { disableBuyMinutes: 65, orderType: 'market' } + }, + botOptions: { + tradingViews: [ + { + interval: '30m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' + } } }; } @@ -5086,6 +5513,26 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '30m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, system: { @@ -5190,6 +5637,28 @@ describe('configuration.js', () => { orderType: 'market' } }, + botOptions: { + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: false + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' + } + }, system: { temporaryDisableActionAfterConfirmingOrder: 10, checkManualBuyOrderPeriod: 10, @@ -5256,6 +5725,28 @@ describe('configuration.js', () => { disableBuyMinutes: 65, orderType: 'market' } + }, + botOptions: { + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' + } } }; } @@ -5351,6 +5842,26 @@ describe('configuration.js', () => { botOptions: { logs: { deleteAfter: 30 + }, + tradingViews: [ + { + interval: '1h', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' } }, system: { diff --git a/app/cronjob/trailingTradeHelper/__tests__/tradingview.test.js b/app/cronjob/trailingTradeHelper/__tests__/tradingview.test.js new file mode 100644 index 00000000..3d30d956 --- /dev/null +++ b/app/cronjob/trailingTradeHelper/__tests__/tradingview.test.js @@ -0,0 +1,1647 @@ +const moment = require('moment'); +const { + isBuyAllowedByTradingView, + shouldForceSellByTradingView +} = require('../tradingview'); + +describe('tradingview.js', () => { + let result; + + let loggerMock; + let loggerMockInfo; + + const validTime = moment().utc().format('YYYY-MM-DDTHH:mm:ss.SSSSSS'); + + beforeEach(() => { + jest.clearAllMocks().resetModules(); + + loggerMockInfo = jest.fn(); + + loggerMock = { + info: loggerMockInfo + }; + }); + + describe('isBuyAllowedByTradingView', () => { + describe('when there is override data and set false to check TraidingView', () => { + beforeEach(() => { + result = isBuyAllowedByTradingView(loggerMock, { + symbolConfiguration: { + botOptions: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + } + ] + } + }, + overrideData: { + checkTradingView: false + } + }); + }); + + it('logs expected message', () => { + expect(loggerMockInfo).toHaveBeenCalledWith( + { + overrideData: { + checkTradingView: false + } + }, + 'Override data is not empty. Ignore TradingView recommendation.' + ); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + isTradingViewAllowed: true, + tradingViewRejectedReason: '' + }); + }); + }); + + describe('validateTradingViewsForBuy', () => { + describe('when there is TradingViews undefined', () => { + beforeEach(() => { + result = isBuyAllowedByTradingView(loggerMock, { + symbolConfiguration: { + botOptions: { + tradingViews: undefined, + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } + } + }, + overrideData: {}, + tradingViews: [ + { + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + }, + { + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: validTime + } + } + ] + }); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + isTradingViewAllowed: false, + tradingViewRejectedReason: + 'Do not place an order because there are missing TradingView configuration.' + }); + }); + }); + + describe('when there is no buy configuration configured', () => { + beforeEach(() => { + result = isBuyAllowedByTradingView(loggerMock, { + symbolConfiguration: { + botOptions: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } + } + }, + overrideData: {}, + tradingViews: [ + { + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + }, + { + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: validTime + } + } + ] + }); + }); + + it('logs expected message', () => { + expect(loggerMockInfo).toHaveBeenCalledWith( + 'There is no buy condition configured. Ignore TradingView recommendation.' + ); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + isTradingViewAllowed: true, + tradingViewRejectedReason: '' + }); + }); + }); + + describe('when there is expired TradingView', () => { + beforeEach(() => { + result = isBuyAllowedByTradingView(loggerMock, { + symbolConfiguration: { + botOptions: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: false + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'do-not-buy' + } + } + }, + overrideData: {}, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: '2021-10-30T08:53:53.973250' + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: '2021-10-30T08:53:53.973250' + } + } + ] + }); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + isTradingViewAllowed: false, + tradingViewRejectedReason: + 'Do not place an order because TradingView data is older than 5 minutes.' + }); + }); + }); + + describe('when there is no valid TradingView', () => { + beforeEach(() => { + result = isBuyAllowedByTradingView(loggerMock, { + symbolConfiguration: { + botOptions: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: false + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' + } + } + }, + overrideData: {}, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: '' + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: '2021-10-30T08:53:53.973250' + } + }, + { + request: { + interval: '30m' + }, + result: { + summary: { RECOMMENDATION: '' }, + time: validTime + } + } + ] + }); + }); + + it('logs expected message', () => { + expect(loggerMockInfo).toHaveBeenCalledWith( + 'TradingView time or recommendation is empty. Ignore TradingView recommendation.' + ); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + isTradingViewAllowed: true, + tradingViewRejectedReason: '' + }); + }); + }); + + describe('when there is missing TradingView', () => { + beforeEach(() => { + result = isBuyAllowedByTradingView(loggerMock, { + symbolConfiguration: { + botOptions: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: false + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' + } + } + }, + overrideData: {}, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + } + ] + }); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + isTradingViewAllowed: false, + tradingViewRejectedReason: + 'Do not place an order because there are missing TradingView data.' + }); + }); + }); + }); + + describe('when could not satisfy all recommendations', () => { + [ + { + desc: 'when none of buy conditions is matching', + tradingViewsConfig: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: false + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + } + } + ], + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + } + ], + expectedReaons: + 'Do not place an order because TradingView recommendation for 5m is NEUTRAL.' + }, + { + desc: 'when one interval buy conditions is matching', + tradingViewsConfig: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: false + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + } + } + ], + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_BUY' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + } + ], + expectedReaons: + 'Do not place an order because TradingView recommendation for 15m is NEUTRAL.' + }, + { + desc: 'when one interval buy conditions is matching', + tradingViewsConfig: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + } + } + ], + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_BUY' }, + time: validTime + } + } + ], + expectedReaons: + 'Do not place an order because TradingView recommendation for 5m is NEUTRAL.' + }, + { + desc: 'when one interval is not active', + tradingViewsConfig: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + } + } + ], + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_BUY' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + } + ], + expectedReaons: + 'Do not place an order because TradingView recommendation for 15m is NEUTRAL.' + }, + { + desc: 'with single config', + tradingViewsConfig: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: true + } + } + ], + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_BUY' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + } + ], + expectedReaons: + 'Do not place an order because TradingView recommendation for 5m is STRONG_BUY.' + } + ].forEach(t => { + describe(`${t.desc}`, () => { + beforeEach(() => { + result = isBuyAllowedByTradingView(loggerMock, { + symbolConfiguration: { + botOptions: { + tradingViews: t.tradingViewsConfig, + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'do-not-buy' + } + } + }, + overrideData: {}, + tradingViews: t.tradingViews + }); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + isTradingViewAllowed: false, + tradingViewRejectedReason: t.expectedReaons + }); + }); + }); + }); + }); + + describe('when satisfies all recommendations', () => { + [ + { + desc: 'two buy conditions are matching', + tradingViewsConfig: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: false + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + } + } + ], + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_BUY' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: validTime + } + } + ] + }, + { + desc: 'two buy conditions are matching', + tradingViewsConfig: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + } + } + ], + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_BUY' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: validTime + } + } + ] + }, + { + desc: "one buy condition is matching, but one interval's conditions are all false", + tradingViewsConfig: [ + { + interval: '5m', + buy: { + whenStrongBuy: false, + whenBuy: false + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + } + } + ], + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_BUY' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: validTime + } + } + ] + } + ].forEach(t => { + describe(`${t.desc}`, () => { + beforeEach(() => { + result = isBuyAllowedByTradingView(loggerMock, { + symbolConfiguration: { + botOptions: { + tradingViews: t.tradingViewsConfig, + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'do-not-buy' + } + } + }, + overrideData: {}, + tradingViews: t.tradingViews + }); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + isTradingViewAllowed: true, + tradingViewRejectedReason: '' + }); + }); + }); + }); + }); + }); + + describe('shouldForceSellByTradingView', () => { + const baseData = { + symbolInfo: { + filterLotSize: { stepSize: '0.00000100' }, + filterMinNotional: { minNotional: '10.00000000' } + }, + baseAssetBalance: { free: 0.5 }, + sell: { + currentProfit: 50, + currentPrice: 29000, + triggerPrice: 30900 + } + }; + + describe('validateTradingViewsForForceSell', () => { + describe('when there is TradingViews undefined', () => { + beforeEach(() => { + result = shouldForceSellByTradingView(loggerMock, { + ...baseData, + symbolConfiguration: { + botOptions: { + tradingViews: undefined, + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } + } + }, + tradingViews: [ + { + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + }, + { + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: validTime + } + } + ] + }); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + shouldForceSell: false, + forceSellMessage: + 'Do not place an order because there are missing TradingView configuration.' + }); + }); + }); + + describe('when there is no sell configuration configured', () => { + beforeEach(() => { + result = shouldForceSellByTradingView(loggerMock, { + ...baseData, + symbolConfiguration: { + botOptions: { + tradingViews: [ + { + interval: '5m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + }, + { + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } + } + }, + tradingViews: [ + { + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + }, + { + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: validTime + } + } + ] + }); + }); + + it('logs expected message', () => { + expect(loggerMockInfo).toHaveBeenCalledWith( + 'There is no sell condition configured. Ignore TradingView recommendation.' + ); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + shouldForceSell: false, + forceSellMessage: '' + }); + }); + }); + + describe('when there is expired TradingView', () => { + beforeEach(() => { + result = shouldForceSellByTradingView(loggerMock, { + ...baseData, + symbolConfiguration: { + botOptions: { + tradingViews: [ + { + interval: '5m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'do-not-buy' + } + } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: '2021-10-30T08:53:53.973250' + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: '2021-10-30T08:53:53.973250' + } + } + ] + }); + }); + + it('logs expected message', () => { + expect(loggerMockInfo).toHaveBeenCalledWith( + 'TradingView time or recommendation is empty. Ignore TradingView recommendation.' + ); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + shouldForceSell: false, + forceSellMessage: '' + }); + }); + }); + + describe('when there is no valid TradingView', () => { + beforeEach(() => { + result = shouldForceSellByTradingView(loggerMock, { + ...baseData, + symbolConfiguration: { + botOptions: { + tradingViews: [ + { + interval: '5m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'do-not-buy' + } + } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: '' + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: '' }, + time: validTime + } + } + ] + }); + }); + + it('logs expected message', () => { + expect(loggerMockInfo).toHaveBeenCalledWith( + 'TradingView time or recommendation is empty. Ignore TradingView recommendation.' + ); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + shouldForceSell: false, + forceSellMessage: '' + }); + }); + }); + + describe('when there is missingTradingView', () => { + beforeEach(() => { + result = shouldForceSellByTradingView(loggerMock, { + ...baseData, + symbolConfiguration: { + botOptions: { + tradingViews: [ + { + interval: '5m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'do-not-buy' + } + } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + } + ] + }); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + shouldForceSell: false, + forceSellMessage: + 'Do not force-sell because there are missing TradingView data.' + }); + }); + }); + }); + + describe('when current profit is less than 0', () => { + beforeEach(() => { + result = shouldForceSellByTradingView(loggerMock, { + ...baseData, + symbolConfiguration: { + botOptions: { + tradingViews: [ + { + interval: '5m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'do-not-buy' + } + } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + } + ], + sell: { + currentProfit: -50, + currentPrice: 29000, + triggerPrice: 30900 + } + }); + }); + + it('logs expected message', () => { + expect(loggerMockInfo).toHaveBeenCalledWith( + { + sellCurrentProfit: -50, + sellCurrentPrice: 29000, + sellTriggerPrice: 30900 + }, + `Current profit if equal or less than 0 or ` + + `current price is more than trigger price. Ignore TradingView recommendation.` + ); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + shouldForceSell: false, + forceSellMessage: '' + }); + }); + }); + + describe('when current price is higher than trigger price', () => { + beforeEach(() => { + result = shouldForceSellByTradingView(loggerMock, { + ...baseData, + symbolConfiguration: { + botOptions: { + tradingViews: [ + { + interval: '5m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'do-not-buy' + } + } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + } + ], + sell: { + currentProfit: 50, + currentPrice: 29000, + triggerPrice: 28950 + } + }); + }); + + it('logs expected message', () => { + expect(loggerMockInfo).toHaveBeenCalledWith( + { + sellCurrentProfit: 50, + sellCurrentPrice: 29000, + sellTriggerPrice: 28950 + }, + `Current profit if equal or less than 0 or ` + + `current price is more than trigger price. Ignore TradingView recommendation.` + ); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + shouldForceSell: false, + forceSellMessage: '' + }); + }); + }); + + describe('when free balance is less than minimum notional value', () => { + [ + { + desc: 'stepSize is not 1', + symbolInfo: { + filterLotSize: { stepSize: '0.00000100' }, + filterMinNotional: { minNotional: '10.00000000' } + } + }, + { + desc: 'stepSize is 1', + symbolInfo: { + filterLotSize: { stepSize: '1.00000000' }, + filterMinNotional: { minNotional: '10.00000000' } + } + } + ].forEach(t => { + describe(`${t.desc}`, () => { + beforeEach(() => { + result = shouldForceSellByTradingView(loggerMock, { + ...baseData, + symbolConfiguration: { + botOptions: { + tradingViews: [ + { + interval: '5m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'do-not-buy' + } + } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + } + ], + symbolInfo: t.symbolInfo, + baseAssetBalance: { free: 0.0003 }, + sell: { + currentProfit: 1, + currentPrice: 29000, + triggerPrice: 35000 + } + }); + }); + + it('logs expected message', () => { + expect(loggerMockInfo).toHaveBeenCalledWith( + { + sellCurrentProfit: 1, + sellCurrentPrice: 29000, + sellTriggerPrice: 35000 + }, + `Order quantity is less than minimum notional value. Ignore TradingView recommendation.` + ); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + shouldForceSell: false, + forceSellMessage: '' + }); + }); + }); + }); + }); + + describe('when could not satisfy any force sell recommendation', () => { + [ + { + desc: 'all sell conditions are set, but recommendation is not satisfied', + tradingViewsConfig: [ + { + interval: '5m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: validTime + } + } + ] + }, + { + desc: 'some sell conditions are set, but recommendation is not satisfied', + tradingViewsConfig: [ + { + interval: '5m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + }, + { + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + } + ], + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'SELL' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'SELL' }, + time: validTime + } + } + ] + }, + { + desc: 'one interval sell conditions are set, but recommendation is not satisfied', + tradingViewsConfig: [ + { + interval: '5m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + }, + { + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'SELL' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + } + ] + } + ].forEach(t => { + describe(`${t.desc}`, () => { + beforeEach(() => { + result = shouldForceSellByTradingView(loggerMock, { + ...baseData, + symbolConfiguration: { + botOptions: { + tradingViews: t.tradingViewsConfig, + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'do-not-buy' + } + } + }, + tradingViews: t.tradingViews + }); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + shouldForceSell: false, + forceSellMessage: '' + }); + }); + }); + }); + }); + + describe('when satisfies any recommendation', () => { + [ + { + desc: 'all sell conditions are set, and one recommendation is satisfied.', + tradingViewsConfig: [ + { + interval: '5m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: true, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'NEUTRAL' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'SELL' }, + time: validTime + } + } + ], + expectedForceSellReason: + `TradingView recommendation for 5m is neutral. The current profit (50) is more than 0 ` + + `and the current price (29000) is under trigger price (30900). Sell at market price.` + }, + { + desc: 'some sell conditions are set, and one recommendation is satisfied.', + tradingViewsConfig: [ + { + interval: '5m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + } + ], + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_SELL' }, + time: validTime + } + } + ], + expectedForceSellReason: + `TradingView recommendation for 15m is strong_sell. The current profit (50) is more than 0 ` + + `and the current price (29000) is under trigger price (30900). Sell at market price.` + }, + { + desc: 'only one sell conditions are set, and one recommendation is satisfied.', + tradingViewsConfig: [ + { + interval: '5m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: false + } + } + }, + { + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_SELL' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'SELL' }, + time: validTime + } + } + ], + expectedForceSellReason: + `TradingView recommendation for 15m is sell. The current profit (50) is more than 0 ` + + `and the current price (29000) is under trigger price (30900). Sell at market price.` + } + ].forEach(t => { + describe(`${t.desc}`, () => { + beforeEach(() => { + result = shouldForceSellByTradingView(loggerMock, { + ...baseData, + symbolConfiguration: { + botOptions: { + tradingViews: t.tradingViewsConfig, + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'do-not-buy' + } + } + }, + tradingViews: t.tradingViews + }); + }); + + it('returns expected result', () => { + expect(result).toStrictEqual({ + shouldForceSell: true, + forceSellMessage: t.expectedForceSellReason + }); + }); + }); + }); + }); + }); +}); diff --git a/app/cronjob/trailingTradeHelper/configuration.js b/app/cronjob/trailingTradeHelper/configuration.js index 3a90b302..c0f96404 100644 --- a/app/cronjob/trailingTradeHelper/configuration.js +++ b/app/cronjob/trailingTradeHelper/configuration.js @@ -859,6 +859,23 @@ const postProcessConfiguration = async ( return newConfiguration; }; +const getBotOptionTradingViews = ( + _logger, + _cachedSymbolInfo, + globalConfiguration, + symbolConfiguration +) => { + const { + botOptions: { tradingViews: globalTradingViews } + } = globalConfiguration; + + const { + botOptions: { tradingViews: symbolTradingViews } + } = symbolConfiguration; + + return symbolTradingViews ?? globalTradingViews; +}; + /** * Get global/symbol configuration * @@ -869,7 +886,10 @@ const getConfiguration = async (logger, symbol = null) => { // To reduce MongoDB query, try to get cached configuration first. const cachedConfiguration = JSON.parse( - await cache.hget('trailing-trade-configurations', `${symbol || 'global'}`) + await cache.hgetWithoutLock( + 'trailing-trade-configurations', + `${symbol || 'global'}` + ) ) || {}; if (_.isEmpty(cachedConfiguration) === false) { @@ -885,7 +905,12 @@ const getConfiguration = async (logger, symbol = null) => { let mergedConfigValue = _.defaultsDeep( symbolConfigValue, symbol !== null - ? _.omit(globalConfigValue, 'buy.gridTrade', 'sell.gridTrade') + ? _.omit( + globalConfigValue, + 'buy.gridTrade', + 'sell.gridTrade', + 'botOptions.tradingViews' + ) : globalConfigValue ); let cachedSymbolInfo; @@ -893,7 +918,10 @@ const getConfiguration = async (logger, symbol = null) => { if (symbol !== null) { cachedSymbolInfo = JSON.parse( - await cache.hget('trailing-trade-symbols', `${symbol}-symbol-info`) + await cache.hgetWithoutLock( + 'trailing-trade-symbols', + `${symbol}-symbol-info` + ) ) || {}; // Post process configuration value to prefill some default values @@ -909,6 +937,10 @@ const getConfiguration = async (logger, symbol = null) => { { key: 'sell.gridTrade', keyFunc: getGridTradeSell + }, + { + key: 'botOptions.tradingViews', + keyFunc: getBotOptionTradingViews } ].forEach(d => { const { key, keyFunc } = d; diff --git a/app/cronjob/trailingTradeHelper/tradingview.js b/app/cronjob/trailingTradeHelper/tradingview.js new file mode 100644 index 00000000..0d3c2e95 --- /dev/null +++ b/app/cronjob/trailingTradeHelper/tradingview.js @@ -0,0 +1,401 @@ +const _ = require('lodash'); +const moment = require('moment'); + +const filterValidTradingViews = ( + _logger, + side, + tradingViewsConfigs, + tradingViews, + { useOnlyWithin, ifExpires } +) => { + // Now check TradingView data time. + const currentTime = moment.utc(); + + let hasExpiredTradingView = false; + const validTradingViews = _.filter(tradingViews, tradingView => { + // If TradingView time or recommendation is not able to fetch, then ignore TradingView data. + const tradingViewTime = _.get(tradingView, 'result.time', ''); + if ( + tradingViewTime === '' || + _.get(tradingView, 'result.summary.RECOMMENDATION', '') === '' + ) { + return false; + } + + const tradingViewUpdatedAt = moment + .utc(tradingViewTime, 'YYYY-MM-DDTHH:mm:ss.SSSSSS') + .add(useOnlyWithin, 'minutes'); + + // If TradingView updated time is expired + if (tradingViewUpdatedAt.isBefore(currentTime)) { + // If set to not buy if TradingView is expired, then set the flag variable. + if (side === 'buy' && ifExpires === 'do-not-buy') { + hasExpiredTradingView = true; + return true; + } + + // If the update time is expired, then it's not valid TradingView. + return false; + } + + // Otherwise, consider as valid data. + return true; + }); + + // Check if has all TradingView data is cached. + const hasAllTradingViews = tradingViewsConfigs.every( + config => + tradingViews.find(tv => tv.request.interval === config.interval) !== + undefined + ); + + return { hasExpiredTradingView, hasAllTradingViews, validTradingViews }; +}; + +const validateTradingViewsForBuy = (logger, data) => { + const { + symbolConfiguration: { + botOptions: { + tradingViews: tradingViewsConfigs, + tradingViewOptions: { useOnlyWithin, ifExpires } + } + }, + tradingViews + } = data; + + if (!tradingViewsConfigs) { + return { + isTradingViewAllowed: false, + tradingViewRejectedReason: + 'Do not place an order because there are missing TradingView configuration.' + }; + } + // If all TradingViews buy configuration is not enabled, then no need to check. + if ( + tradingViewsConfigs.every(c => { + const p = c.buy; + return p.whenStrongBuy === false && p.whenBuy === false; + }) + ) { + logger.info( + 'There is no buy condition configured. Ignore TradingView recommendation.' + ); + return { + isTradingViewAllowed: true, + tradingViewRejectedReason: '' + }; + } + + const { hasExpiredTradingView, validTradingViews, hasAllTradingViews } = + filterValidTradingViews(logger, 'buy', tradingViewsConfigs, tradingViews, { + useOnlyWithin, + ifExpires + }); + + if (hasExpiredTradingView === true) { + return { + isTradingViewAllowed: false, + tradingViewRejectedReason: + `Do not place an order because ` + + `TradingView data is older than ${useOnlyWithin} minutes.` + }; + } + + if (_.isEmpty(validTradingViews) === true) { + logger.info( + 'TradingView time or recommendation is empty. Ignore TradingView recommendation.' + ); + return { isTradingViewAllowed: true, tradingViewRejectedReason: '' }; + } + + if (hasAllTradingViews === false) { + return { + isTradingViewAllowed: false, + tradingViewRejectedReason: + 'Do not place an order because there are missing TradingView data.' + }; + } + + logger.info('TradingView is valid.'); + + return { + isTradingViewAllowed: undefined, + tradingViewRejectedReason: '', + validTradingViews + }; +}; + +const isBuyAllowedByTradingView = (logger, data) => { + const { + symbolConfiguration: { + botOptions: { tradingViews: tradingViewsConfigs } + }, + overrideData + } = data; + + const overrideCheckTradingView = _.get( + overrideData, + 'checkTradingView', + false + ); + + // If this is override action, then process buy regardless recommendation. + if (overrideCheckTradingView === false && _.isEmpty(overrideData) === false) { + logger.info( + { overrideData }, + 'Override data is not empty. Ignore TradingView recommendation.' + ); + return { isTradingViewAllowed: true, tradingViewRejectedReason: '' }; + } + + // Validate TradingView data. + const { isTradingViewAllowed, tradingViewRejectedReason, validTradingViews } = + validateTradingViewsForBuy(logger, data); + + // If tradingViewAllowed is returned by validation, then return the result. + if (isTradingViewAllowed !== undefined) { + return { + isTradingViewAllowed, + tradingViewRejectedReason + }; + } + + // Can buy if all buy conditions are satisfied among all TradingViews. + let rejectedReason = ''; + const hasSatisfiedAllRecommendations = validTradingViews.every( + tradingView => { + // Retreive tradingView configurations. It must have at this point. + const config = tradingViewsConfigs.find( + c => c.interval === tradingView.request.interval + ); + + const allowedRecommendations = []; + + if (config.buy.whenStrongBuy) { + allowedRecommendations.push('strong_buy'); + } + if (config.buy.whenBuy) { + allowedRecommendations.push('buy'); + } + + // If recommendation is not selected, ignore this TradingView data and proceed to buy. + if (allowedRecommendations.length === 0) { + return true; + } + + // If tradingView recommendation is one of configured recommendation, then proceed to buy. + if ( + allowedRecommendations.includes( + tradingView.result.summary.RECOMMENDATION.toLowerCase() + ) === true + ) { + return true; + } + + // Otherwise, should prevent buying. + rejectedReason = + `Do not place an order because TradingView recommendation for ` + + `${tradingView.request.interval} is ${tradingView.result.summary.RECOMMENDATION}.`; + + return false; + } + ); + + // If summary recommendation is not allowed recommendation, then prevent buy + if (hasSatisfiedAllRecommendations === false) { + return { + isTradingViewAllowed: false, + tradingViewRejectedReason: rejectedReason + }; + } + + // Otherwise, simply allow + return { + isTradingViewAllowed: true, + tradingViewRejectedReason: '' + }; +}; + +const validateTradingViewsForForceSell = (logger, data) => { + const { + symbolConfiguration: { + botOptions: { + tradingViews: tradingViewsConfigs, + tradingViewOptions: { useOnlyWithin, ifExpires } + } + }, + tradingViews + } = data; + + if (!tradingViewsConfigs) { + return { + shouldForceSell: false, + forceSellMessage: + 'Do not place an order because there are missing TradingView configuration.' + }; + } + + // If all TradingViews force sell configuration is not enabled, then no need to check. + if ( + tradingViewsConfigs.every(c => { + const p = c.sell.forceSellOverZeroBelowTriggerPrice; + return ( + p.whenNeutral === false && + p.whenSell === false && + p.whenStrongSell === false + ); + }) + ) { + logger.info( + 'There is no sell condition configured. Ignore TradingView recommendation.' + ); + return { + shouldForceSell: false, + forceSellMessage: '' + }; + } + + const { validTradingViews, hasAllTradingViews } = filterValidTradingViews( + logger, + 'sell', + tradingViewsConfigs, + tradingViews, + { + useOnlyWithin, + ifExpires + } + ); + + if (_.isEmpty(validTradingViews) === true) { + logger.info( + 'TradingView time or recommendation is empty. Ignore TradingView recommendation.' + ); + return { shouldForceSell: false, forceSellMessage: '' }; + } + + if (hasAllTradingViews === false) { + return { + shouldForceSell: false, + forceSellMessage: + 'Do not force-sell because there are missing TradingView data.' + }; + } + + logger.info('TradingView is valid.'); + + return { + shouldForceSell: undefined, + forceSellMessage: '', + validTradingViews + }; +}; + +const shouldForceSellByTradingView = (logger, data) => { + const { + symbolInfo: { + filterLotSize: { stepSize }, + filterMinNotional: { minNotional } + }, + symbolConfiguration: { + botOptions: { tradingViews: tradingViewsConfigs } + }, + baseAssetBalance: { free: baseAssetFreeBalance }, + sell: { + currentProfit: sellCurrentProfit, + currentPrice: sellCurrentPrice, + triggerPrice: sellTriggerPrice + } + } = data; + + // Validate TradingView data. + const { shouldForceSell, forceSellMessage, validTradingViews } = + validateTradingViewsForForceSell(logger, data); + + // If shouldForceSell is returned by validation, then return the result. + if (shouldForceSell !== undefined) { + return { + shouldForceSell, + forceSellMessage + }; + } + + // If current profit is less than 0 or current price is more than trigger price + if (sellCurrentProfit <= 0 || sellCurrentPrice > sellTriggerPrice) { + logger.info( + { sellCurrentProfit, sellCurrentPrice, sellTriggerPrice }, + `Current profit if equal or less than 0 or ` + + `current price is more than trigger price. Ignore TradingView recommendation.` + ); + + return { shouldForceSell: false, forceSellMessage: '' }; + } + + // Only execute when the free balance is more than minimum notional value. + const lotPrecision = parseFloat(stepSize) === 1 ? 0 : stepSize.indexOf(1) - 1; + const freeBalance = parseFloat(_.floor(baseAssetFreeBalance, lotPrecision)); + const orderQuantity = parseFloat( + _.floor(freeBalance - freeBalance * (0.1 / 100), lotPrecision) + ); + + if (orderQuantity * sellCurrentPrice < parseFloat(minNotional)) { + logger.info( + { sellCurrentProfit, sellCurrentPrice, sellTriggerPrice }, + 'Order quantity is less than minimum notional value. Ignore TradingView recommendation.' + ); + + return { shouldForceSell: false, forceSellMessage: '' }; + } + + let forceSellReason = ''; + const hasAnyForceSellRecommendation = validTradingViews.some(tradingView => { + // Retreive tradingView configurations. It must have at this point. + const config = tradingViewsConfigs.find( + c => c.interval === tradingView.request.interval + ); + + const sellConfig = config.sell.forceSellOverZeroBelowTriggerPrice; + + // Get force sell recommendation + const forceSellRecommendations = []; + if (sellConfig.whenNeutral) { + forceSellRecommendations.push('neutral'); + } + + if (sellConfig.whenSell) { + forceSellRecommendations.push('sell'); + } + + if (sellConfig.whenStrongSell) { + forceSellRecommendations.push('strong_sell'); + } + + // If recommendation is not selected, ignore this TradingView data and do not sell. + if (forceSellRecommendations.length === 0) { + return false; + } + + // If tradingView recommendation is one of configured recommendation, then proceed to sell. + const recommendation = + tradingView.result.summary.RECOMMENDATION.toLowerCase(); + if (forceSellRecommendations.includes(recommendation) === true) { + forceSellReason = + `TradingView recommendation for ${tradingView.request.interval} is ${recommendation}. ` + + `The current profit (${sellCurrentProfit}) is more than 0 and the current price (${sellCurrentPrice}) ` + + `is under trigger price (${sellTriggerPrice}). Sell at market price.`; + return true; + } + + // Otherwise, do not sell + return false; + }); + + // If there is at least one satified force sell configuration, then return to force sell. + if (hasAnyForceSellRecommendation) { + return { shouldForceSell: true, forceSellMessage: forceSellReason }; + } + + // Otherwise ignore. + return { shouldForceSell: false, forceSellMessage: '' }; +}; + +module.exports = { isBuyAllowedByTradingView, shouldForceSellByTradingView }; diff --git a/app/cronjob/trailingTradeIndicator.js b/app/cronjob/trailingTradeIndicator.js index 8717314f..fa396cd3 100644 --- a/app/cronjob/trailingTradeIndicator.js +++ b/app/cronjob/trailingTradeIndicator.js @@ -12,7 +12,6 @@ const { executeDustTransfer, getClosedTrades, getOrderStats, - getTradingView, saveDataToCache } = require('./trailingTradeIndicator/steps'); const { errorHandlerWrapper } = require('../error-handler'); @@ -31,7 +30,6 @@ const execute = async logger => { symbolInfo: {}, overrideParams: {}, quoteAssetStats: {}, - tradingView: {}, apiLimit: { start: getAPILimit(logger), end: null } }; @@ -72,10 +70,6 @@ const execute = async logger => { stepName: 'get-order-stats', stepFunc: getOrderStats }, - { - stepName: 'get-tradingview', - stepFunc: getTradingView - }, { stepName: 'save-data-to-cache', stepFunc: saveDataToCache diff --git a/app/cronjob/trailingTradeIndicator/step/__tests__/get-trading-view.test.js b/app/cronjob/trailingTradeIndicator/step/__tests__/get-tradingview.test.js similarity index 55% rename from app/cronjob/trailingTradeIndicator/step/__tests__/get-trading-view.test.js rename to app/cronjob/trailingTradeIndicator/step/__tests__/get-tradingview.test.js index 04a3ba77..053e9ad5 100644 --- a/app/cronjob/trailingTradeIndicator/step/__tests__/get-trading-view.test.js +++ b/app/cronjob/trailingTradeIndicator/step/__tests__/get-tradingview.test.js @@ -1,6 +1,6 @@ /* eslint-disable global-require */ -describe('get-trading-view.js', () => { +describe('get-tradingview.js', () => { let rawData; let cacheMock; @@ -22,6 +22,7 @@ describe('get-trading-view.js', () => { loggerMock = logger; cacheMock.hset = jest.fn().mockResolvedValue(true); + cacheMock.hdel = jest.fn().mockResolvedValue(true); mockHandleError = jest.fn().mockResolvedValue(true); jest.mock('../../../../error-handler', () => ({ @@ -30,7 +31,7 @@ describe('get-trading-view.js', () => { }); describe('when there are symbols in the global configuration', () => { - describe('when symbols does not have custom interval for trading view', () => { + describe('when symbols have custom interval for trading view', () => { beforeEach(async () => { rawData = { globalConfiguration: { @@ -44,223 +45,126 @@ describe('get-trading-view.js', () => { BTCUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }), BNBUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } - } - } - } - }), - global: JSON.stringify({ - candles: { interval: '1h', limit: 100 }, - botOptions: { - tradingView: { - interval: '15m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + }, + { + interval: '30m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }) }) - .mockResolvedValueOnce({}); - - axiosMock.get = jest.fn().mockResolvedValue({ - data: { - request: { - symbols: ['BINANCE:BTCUSDT', 'BINANCE:BNBUSDT'], - screener: 'CRYPTO', - interval: '1h' - }, - result: { - 'BINANCE:BTCUSDT': { + .mockResolvedValueOnce({ + 'BTCUSDT:5m': JSON.stringify({ + request: { + symbol: 'BTCUSDT', + interval: '5m' + }, + result: { summary: { - RECOMMENDATION: 'BUY' + RECOMMENDATION: 'NEUTRAL' }, time: '2021-10-30T08:53:53.973250' + } + }), + 'BTCUSDT:1d': JSON.stringify({ + request: { + symbol: 'BTCUSDT', + interval: '1d' }, - 'BINANCE:BNBUSDT': { + result: { summary: { - RECOMMENDATION: 'SELL' + RECOMMENDATION: 'NEUTRAL' }, time: '2021-10-30T08:53:53.973250' } - } - } - }); - - const step = require('../get-trading-view'); - - await step.execute(loggerMock, rawData); - }); - - it('triggers cache.hgetall', () => { - expect(cacheMock.hgetall).toHaveBeenCalledWith( - 'trailing-trade-configurations:', - 'trailing-trade-configurations:*' - ); - }); - - it('triggers axios.get', () => { - expect(axiosMock.get).toHaveBeenCalledWith( - 'http://tradingview:8080', - { - params: { - symbols: ['BINANCE:BTCUSDT', 'BINANCE:BNBUSDT'], - screener: 'CRYPTO', - interval: '1h' - }, - paramsSerializer: expect.any(Function), - timeout: 20000 - } - ); - }); - - it('triggers cache.hset twice', () => { - expect(cacheMock.hset).toHaveBeenCalledTimes(2); - }); - - it('triggers cache.hset for symbols', () => { - expect(cacheMock.hset).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BTCUSDT', - JSON.stringify({ - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '1h' - }, - result: { - summary: { - RECOMMENDATION: 'BUY' - }, - time: '2021-10-30T08:53:53.973250' - } - }) - ); - - expect(cacheMock.hset).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BNBUSDT', - JSON.stringify({ - request: { - symbol: 'BNBUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '1h' - }, - result: { - summary: { - RECOMMENDATION: 'SELL' - }, - time: '2021-10-30T08:53:53.973250' - } - }) - ); - }); - - it("saves logger.info for symbols because there isn't existing recommendation", () => { - expect(loggerMock.info).toHaveBeenCalledWith( - { - symbol: 'BTCUSDT', - data: { - summary: { - RECOMMENDATION: 'BUY' - }, - time: '2021-10-30T08:53:53.973250' - }, - saveLog: true - }, - `The TradingView technical analysis recommendation for BTCUSDT is "BUY".` - ); - - expect(loggerMock.info).toHaveBeenCalledWith( - { - symbol: 'BNBUSDT', - data: { - summary: { - RECOMMENDATION: 'SELL' - }, - time: '2021-10-30T08:53:53.973250' - }, - saveLog: true - }, - `The TradingView technical analysis recommendation for BNBUSDT is "SELL".` - ); - }); - }); - - describe('when symbols have custom interval for trading view', () => { - beforeEach(async () => { - rawData = { - globalConfiguration: { - symbols: ['BTCUSDT', 'BNBUSDT'] - } - }; + }) + }); - cacheMock.hgetall = jest + axiosMock.get = jest .fn() .mockResolvedValueOnce({ - BTCUSDT: JSON.stringify({ - candles: { interval: '1h', limit: 100 }, - botOptions: { - tradingView: { - interval: '15m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' - } - } - } - } - }), - BNBUSDT: JSON.stringify({ - candles: { interval: '1h', limit: 100 }, - botOptions: { - tradingView: { - interval: '3m' + data: { + request: { + symbols: ['BINANCE:BTCUSDT', 'BINANCE:BNBUSDT'], + screener: 'CRYPTO', + interval: '5m' + }, + result: { + 'BINANCE:BTCUSDT': { + summary: { + RECOMMENDATION: 'NEUTRAL' + }, + time: '2021-10-30T08:53:53.973250' }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' - } - } + 'BINANCE:BNBUSDT': { + summary: { + RECOMMENDATION: 'BUY' + }, + time: '2021-10-30T08:53:53.973250' } } - }) + } }) - .mockResolvedValueOnce({}); - - axiosMock.get = jest - .fn() .mockResolvedValueOnce({ data: { request: { @@ -271,7 +175,7 @@ describe('get-trading-view.js', () => { result: { 'BINANCE:BTCUSDT': { summary: { - RECOMMENDATION: 'NEUTRAL' + RECOMMENDATION: 'BUY' }, time: '2021-10-30T08:53:53.973250' } @@ -283,12 +187,12 @@ describe('get-trading-view.js', () => { request: { symbols: ['BINANCE:BNBUSDT'], screener: 'CRYPTO', - interval: '5m' + interval: '30m' }, result: { 'BINANCE:BNBUSDT': { summary: { - RECOMMENDATION: 'BUY' + RECOMMENDATION: 'STRONG_BUY' }, time: '2021-10-30T08:53:53.973250' } @@ -296,16 +200,29 @@ describe('get-trading-view.js', () => { } }); - const step = require('../get-trading-view'); + const step = require('../get-tradingview'); await step.execute(loggerMock, rawData); }); - it('triggers axios.get twice', () => { - expect(axiosMock.get).toHaveBeenCalledTimes(2); + it('triggers axios.get three times', () => { + expect(axiosMock.get).toHaveBeenCalledTimes(3); }); it('triggers axios.get', () => { + expect(axiosMock.get).toHaveBeenCalledWith( + 'http://tradingview:8080', + { + params: { + symbols: ['BINANCE:BTCUSDT', 'BINANCE:BNBUSDT'], + screener: 'CRYPTO', + interval: '5m' + }, + paramsSerializer: expect.any(Function), + timeout: 20000 + } + ); + expect(axiosMock.get).toHaveBeenCalledWith( 'http://tradingview:8080', { @@ -325,7 +242,7 @@ describe('get-trading-view.js', () => { params: { symbols: ['BINANCE:BNBUSDT'], screener: 'CRYPTO', - interval: '5m' + interval: '30m' }, paramsSerializer: expect.any(Function), timeout: 20000 @@ -333,25 +250,23 @@ describe('get-trading-view.js', () => { ); }); - it('triggers cache.hset twice', () => { - expect(cacheMock.hset).toHaveBeenCalledTimes(2); + it('triggers cache.hset four times', () => { + expect(cacheMock.hset).toHaveBeenCalledTimes(4); }); it('triggers cache.hset for symbols', () => { expect(cacheMock.hset).toHaveBeenCalledWith( 'trailing-trade-tradingview', - 'BTCUSDT', + 'BTCUSDT:5m', JSON.stringify({ request: { symbol: 'BTCUSDT', screener: 'CRYPTO', exchange: 'BINANCE', - interval: '15m' + interval: '5m' }, result: { - summary: { - RECOMMENDATION: 'NEUTRAL' - }, + summary: { RECOMMENDATION: 'NEUTRAL' }, time: '2021-10-30T08:53:53.973250' } }) @@ -359,7 +274,7 @@ describe('get-trading-view.js', () => { expect(cacheMock.hset).toHaveBeenCalledWith( 'trailing-trade-tradingview', - 'BNBUSDT', + 'BNBUSDT:5m', JSON.stringify({ request: { symbol: 'BNBUSDT', @@ -368,308 +283,300 @@ describe('get-trading-view.js', () => { interval: '5m' }, result: { - summary: { - RECOMMENDATION: 'BUY' - }, + summary: { RECOMMENDATION: 'BUY' }, + time: '2021-10-30T08:53:53.973250' + } + }) + ); + + expect(cacheMock.hset).toHaveBeenCalledWith( + 'trailing-trade-tradingview', + 'BTCUSDT:15m', + JSON.stringify({ + request: { + symbol: 'BTCUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'BUY' }, + time: '2021-10-30T08:53:53.973250' + } + }) + ); + + expect(cacheMock.hset).toHaveBeenCalledWith( + 'trailing-trade-tradingview', + 'BNBUSDT:30m', + JSON.stringify({ + request: { + symbol: 'BNBUSDT', + screener: 'CRYPTO', + exchange: 'BINANCE', + interval: '30m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_BUY' }, time: '2021-10-30T08:53:53.973250' } }) ); }); + it('triggers cache.hdel to remove unmonitored interval', () => { + expect(cacheMock.hdel).toHaveBeenCalledWith( + 'trailing-trade-tradingview', + 'BTCUSDT:1d' + ); + }); + it("saves logger.info for symbols because there isn't existing recommendation", () => { expect(loggerMock.info).toHaveBeenCalledWith( { symbol: 'BTCUSDT', + interval: '5m', data: { - summary: { - RECOMMENDATION: 'NEUTRAL' - }, + summary: { RECOMMENDATION: 'NEUTRAL' }, time: '2021-10-30T08:53:53.973250' }, saveLog: true }, - `The TradingView technical analysis recommendation for BTCUSDT is "NEUTRAL".` + `The TradingView technical analysis recommendation for BTCUSDT:5m is "NEUTRAL".` ); expect(loggerMock.info).toHaveBeenCalledWith( { symbol: 'BNBUSDT', + interval: '5m', data: { - summary: { - RECOMMENDATION: 'BUY' - }, + summary: { RECOMMENDATION: 'BUY' }, time: '2021-10-30T08:53:53.973250' }, saveLog: true }, - `The TradingView technical analysis recommendation for BNBUSDT is "BUY".` + `The TradingView technical analysis recommendation for BNBUSDT:5m is "BUY".` ); - }); - }); - - describe('when symbols have override data for trading view', () => { - describe('when symbol have override interval for trading view', () => { - beforeEach(async () => { - rawData = { - globalConfiguration: { - symbols: ['BTCUSDT', 'BNBUSDT'] - } - }; - cacheMock.hgetall = jest - .fn() + expect(loggerMock.info).toHaveBeenCalledWith( + { + symbol: 'BTCUSDT', + interval: '15m', + data: { + summary: { RECOMMENDATION: 'BUY' }, + time: '2021-10-30T08:53:53.973250' + }, + saveLog: true + }, + `The TradingView technical analysis recommendation for BTCUSDT:15m is "BUY".` + ); + + expect(loggerMock.info).toHaveBeenCalledWith( + { + symbol: 'BNBUSDT', + interval: '30m', + data: { + summary: { RECOMMENDATION: 'STRONG_BUY' }, + time: '2021-10-30T08:53:53.973250' + }, + saveLog: true + }, + `The TradingView technical analysis recommendation for BNBUSDT:30m is "STRONG_BUY".` + ); + }); + }); + + describe('when there are existing recommendations', () => { + describe('recommendation is same', () => { + beforeEach(async () => { + rawData = { + globalConfiguration: { + symbols: ['BTCUSDT', 'BNBUSDT'] + } + }; + + cacheMock.hgetall = jest + .fn() .mockResolvedValueOnce({ BTCUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '15m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '30m' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }), BNBUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '3m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '1m' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '30m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }) }) .mockResolvedValueOnce({ - BNBUSDT: JSON.stringify({ - action: 'buy', - actionAt: '2022-06-07T11:50:22+00:00', - triggeredBy: 'auto-trigger', - notify: true, - checkTradingView: true - }) - }); - - axiosMock.get = jest - .fn() - .mockResolvedValueOnce({ - data: { + 'BTCUSDT:5m': JSON.stringify({ request: { - symbols: ['BINANCE:BTCUSDT'], - screener: 'CRYPTO', - interval: '15m' + symbol: 'BTCUSDT', + interval: '5m' }, result: { - 'BINANCE:BTCUSDT': { - summary: { - RECOMMENDATION: 'NEUTRAL' - }, - time: '2021-10-30T08:53:53.973250' - } + summary: { + RECOMMENDATION: 'NEUTRAL' + }, + time: '2021-10-30T08:53:53.973250' } - } - }) - .mockResolvedValueOnce({ - data: { + }), + 'BTCUSDT:1d': JSON.stringify({ request: { - symbols: ['BINANCE:BNBUSDT'], - screener: 'CRYPTO', - interval: '1m' + symbol: 'BTCUSDT', + interval: '1d' }, result: { - 'BINANCE:BNBUSDT': { - summary: { - RECOMMENDATION: 'BUY' - }, - time: '2021-10-30T08:53:53.973250' - } + summary: { + RECOMMENDATION: 'NEUTRAL' + }, + time: '2021-10-30T08:53:53.973250' } - } - }); - - const step = require('../get-trading-view'); - - await step.execute(loggerMock, rawData); - }); - - it('triggers axios.get twice', () => { - expect(axiosMock.get).toHaveBeenCalledTimes(2); - }); - - it('triggers axios.get', () => { - expect(axiosMock.get).toHaveBeenCalledWith( - 'http://tradingview:8080', - { - params: { - symbols: ['BINANCE:BTCUSDT'], - screener: 'CRYPTO', - interval: '15m' - }, - paramsSerializer: expect.any(Function), - timeout: 20000 - } - ); - - expect(axiosMock.get).toHaveBeenCalledWith( - 'http://tradingview:8080', - { - params: { - symbols: ['BINANCE:BNBUSDT'], - screener: 'CRYPTO', - interval: '1m' - }, - paramsSerializer: expect.any(Function), - timeout: 20000 - } - ); - }); - - it('triggers cache.hset twice', () => { - expect(cacheMock.hset).toHaveBeenCalledTimes(2); - }); - - it('triggers cache.hset for symbols', () => { - expect(cacheMock.hset).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BTCUSDT', - JSON.stringify({ - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'NEUTRAL' - }, - time: '2021-10-30T08:53:53.973250' - } - }) - ); - - expect(cacheMock.hset).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BNBUSDT', - JSON.stringify({ - request: { - symbol: 'BNBUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '1m' - }, - result: { - summary: { - RECOMMENDATION: 'BUY' - }, - time: '2021-10-30T08:53:53.973250' - } + }) }) - ); - }); - - it("saves logger.info for symbols because there isn't existing recommendation", () => { - expect(loggerMock.info).toHaveBeenCalledWith( - { - symbol: 'BTCUSDT', - data: { - summary: { - RECOMMENDATION: 'NEUTRAL' - }, - time: '2021-10-30T08:53:53.973250' - }, - saveLog: true - }, - `The TradingView technical analysis recommendation for BTCUSDT is "NEUTRAL".` - ); - - expect(loggerMock.info).toHaveBeenCalledWith( - { - symbol: 'BNBUSDT', - data: { - summary: { - RECOMMENDATION: 'BUY' - }, - time: '2021-10-30T08:53:53.973250' - }, - saveLog: true - }, - `The TradingView technical analysis recommendation for BNBUSDT is "BUY".` - ); - }); - }); - - describe('when symbol does not have override interval for trading view', () => { - beforeEach(async () => { - rawData = { - globalConfiguration: { - symbols: ['BTCUSDT', 'BNBUSDT'] - } - }; - - cacheMock.hgetall = jest - .fn() .mockResolvedValueOnce({ BTCUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '15m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '30m' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }), BNBUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '3m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '30m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }) }) - .mockResolvedValueOnce({ - BNBUSDT: JSON.stringify({ - action: 'buy', - actionAt: '2022-06-07T11:50:22+00:00', - triggeredBy: 'auto-trigger', - notify: true, - checkTradingView: true - }) - }); + .mockResolvedValueOnce({}); axiosMock.get = jest .fn() .mockResolvedValueOnce({ data: { request: { - symbols: ['BINANCE:BTCUSDT'], + symbols: ['BINANCE:BTCUSDT', 'BINANCE:BNBUSDT'], screener: 'CRYPTO', - interval: '15m' + interval: '5m' }, result: { 'BINANCE:BTCUSDT': { @@ -677,6 +584,12 @@ describe('get-trading-view.js', () => { RECOMMENDATION: 'NEUTRAL' }, time: '2021-10-30T08:53:53.973250' + }, + 'BINANCE:BNBUSDT': { + summary: { + RECOMMENDATION: 'BUY' + }, + time: '2021-10-30T08:53:53.973250' } } } @@ -684,12 +597,12 @@ describe('get-trading-view.js', () => { .mockResolvedValueOnce({ data: { request: { - symbols: ['BINANCE:BNBUSDT'], + symbols: ['BINANCE:BTCUSDT'], screener: 'CRYPTO', - interval: '1m' + interval: '15m' }, result: { - 'BINANCE:BNBUSDT': { + 'BINANCE:BTCUSDT': { summary: { RECOMMENDATION: 'BUY' }, @@ -697,212 +610,18 @@ describe('get-trading-view.js', () => { } } } - }); - - const step = require('../get-trading-view'); - - await step.execute(loggerMock, rawData); - }); - - it('triggers axios.get twice', () => { - expect(axiosMock.get).toHaveBeenCalledTimes(2); - }); - - it('triggers axios.get', () => { - expect(axiosMock.get).toHaveBeenCalledWith( - 'http://tradingview:8080', - { - params: { - symbols: ['BINANCE:BTCUSDT'], - screener: 'CRYPTO', - interval: '15m' - }, - paramsSerializer: expect.any(Function), - timeout: 20000 - } - ); - - expect(axiosMock.get).toHaveBeenCalledWith( - 'http://tradingview:8080', - { - params: { - symbols: ['BINANCE:BNBUSDT'], - screener: 'CRYPTO', - interval: '5m' - }, - paramsSerializer: expect.any(Function), - timeout: 20000 - } - ); - }); - - it('triggers cache.hset twice', () => { - expect(cacheMock.hset).toHaveBeenCalledTimes(2); - }); - - it('triggers cache.hset for symbols', () => { - expect(cacheMock.hset).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BTCUSDT', - JSON.stringify({ - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'NEUTRAL' - }, - time: '2021-10-30T08:53:53.973250' - } }) - ); - - expect(cacheMock.hset).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BNBUSDT', - JSON.stringify({ - request: { - symbol: 'BNBUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '5m' - }, - result: { - summary: { - RECOMMENDATION: 'BUY' - }, - time: '2021-10-30T08:53:53.973250' - } - }) - ); - }); - - it("saves logger.info for symbols because there isn't existing recommendation", () => { - expect(loggerMock.info).toHaveBeenCalledWith( - { - symbol: 'BTCUSDT', - data: { - summary: { - RECOMMENDATION: 'NEUTRAL' - }, - time: '2021-10-30T08:53:53.973250' - }, - saveLog: true - }, - `The TradingView technical analysis recommendation for BTCUSDT is "NEUTRAL".` - ); - - expect(loggerMock.info).toHaveBeenCalledWith( - { - symbol: 'BNBUSDT', - data: { - summary: { - RECOMMENDATION: 'BUY' - }, - time: '2021-10-30T08:53:53.973250' - }, - saveLog: true - }, - `The TradingView technical analysis recommendation for BNBUSDT is "BUY".` - ); - }); - }); - }); - - describe('when there are existing recommendations', () => { - describe('recommendation is same', () => { - beforeEach(async () => { - rawData = { - globalConfiguration: { - symbols: ['BTCUSDT', 'BNBUSDT'] - } - }; - - cacheMock.hgetall = jest - .fn() - .mockResolvedValueOnce({ - BTCUSDT: JSON.stringify({ - candles: { interval: '1h', limit: 100 }, - botOptions: { - tradingView: { - interval: '15m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' - } - } - } - } - }), - BNBUSDT: JSON.stringify({ - candles: { interval: '1h', limit: 100 }, - botOptions: { - tradingView: { - interval: '3m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' - } - } - } - } - }) - }) - .mockResolvedValueOnce({}) - .mockResolvedValueOnce({ - BTCUSDT: JSON.stringify({ - candles: { interval: '1h', limit: 100 }, - botOptions: { - tradingView: { - interval: '15m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' - } - } - } - } - }), - BNBUSDT: JSON.stringify({ - candles: { interval: '1h', limit: 100 }, - botOptions: { - tradingView: { - interval: '3m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' - } - } - } - } - }) - }) - .mockResolvedValueOnce({}); - - axiosMock.get = jest - .fn() .mockResolvedValueOnce({ data: { request: { - symbols: ['BINANCE:BTCUSDT'], + symbols: ['BINANCE:BNBUSDT'], screener: 'CRYPTO', - interval: '15m' + interval: '30m' }, result: { - 'BINANCE:BTCUSDT': { + 'BINANCE:BNBUSDT': { summary: { - RECOMMENDATION: 'NEUTRAL' + RECOMMENDATION: 'STRONG_BUY' }, time: '2021-10-30T08:53:53.973250' } @@ -912,16 +631,22 @@ describe('get-trading-view.js', () => { .mockResolvedValueOnce({ data: { request: { - symbols: ['BINANCE:BNBUSDT'], + symbols: ['BINANCE:BTCUSDT', 'BINANCE:BNBUSDT'], screener: 'CRYPTO', interval: '5m' }, result: { + 'BINANCE:BTCUSDT': { + summary: { + RECOMMENDATION: 'NEUTRAL' + }, + time: '2021-10-30T08:54:53.973250' + }, 'BINANCE:BNBUSDT': { summary: { RECOMMENDATION: 'BUY' }, - time: '2021-10-30T08:53:53.973250' + time: '2021-10-30T08:54:53.973250' } } } @@ -936,7 +661,7 @@ describe('get-trading-view.js', () => { result: { 'BINANCE:BTCUSDT': { summary: { - RECOMMENDATION: 'NEUTRAL' + RECOMMENDATION: 'BUY' }, time: '2021-10-30T08:54:53.973250' } @@ -948,12 +673,12 @@ describe('get-trading-view.js', () => { request: { symbols: ['BINANCE:BNBUSDT'], screener: 'CRYPTO', - interval: '5m' + interval: '30m' }, result: { 'BINANCE:BNBUSDT': { summary: { - RECOMMENDATION: 'BUY' + RECOMMENDATION: 'STRONG_BUY' }, time: '2021-10-30T08:54:53.973250' } @@ -961,154 +686,71 @@ describe('get-trading-view.js', () => { } }); - const step = require('../get-trading-view'); + const step = require('../get-tradingview'); - // Execute twice await step.execute(loggerMock, rawData); await step.execute(loggerMock, rawData); }); - it('triggers axios.get 4 times', () => { - expect(axiosMock.get).toHaveBeenCalledTimes(4); - }); - - it('triggers axios.get', () => { - expect(axiosMock.get).toHaveBeenCalledWith( - 'http://tradingview:8080', - { - params: { - symbols: ['BINANCE:BTCUSDT'], - screener: 'CRYPTO', - interval: '15m' - }, - paramsSerializer: expect.any(Function), - timeout: 20000 - } - ); - - expect(axiosMock.get).toHaveBeenCalledWith( - 'http://tradingview:8080', - { - params: { - symbols: ['BINANCE:BNBUSDT'], - screener: 'CRYPTO', - interval: '5m' - }, - paramsSerializer: expect.any(Function), - timeout: 20000 - } - ); + it('triggers axios.get six times', () => { + expect(axiosMock.get).toHaveBeenCalledTimes(6); }); - it('triggers cache.hset 4', () => { - expect(cacheMock.hset).toHaveBeenCalledTimes(4); + it('triggers cache.hset eight times', () => { + expect(cacheMock.hset).toHaveBeenCalledTimes(8); }); - it('triggers cache.hset for symbols', () => { - expect(cacheMock.hset).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BTCUSDT', - JSON.stringify({ - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'NEUTRAL' - }, - time: '2021-10-30T08:53:53.973250' - } - }) - ); - - expect(cacheMock.hset).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BNBUSDT', - JSON.stringify({ - request: { - symbol: 'BNBUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '5m' - }, - result: { - summary: { - RECOMMENDATION: 'BUY' - }, + it("saves logger.info for symbols because there isn't existing recommendation for first time", () => { + expect(loggerMock.info).toHaveBeenCalledWith( + { + symbol: 'BTCUSDT', + interval: '5m', + data: { + summary: { RECOMMENDATION: 'NEUTRAL' }, time: '2021-10-30T08:53:53.973250' - } - }) - ); - - expect(cacheMock.hset).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BTCUSDT', - JSON.stringify({ - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' }, - result: { - summary: { - RECOMMENDATION: 'NEUTRAL' - }, - time: '2021-10-30T08:54:53.973250' - } - }) + saveLog: true + }, + `The TradingView technical analysis recommendation for BTCUSDT:5m is "NEUTRAL".` ); - expect(cacheMock.hset).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BNBUSDT', - JSON.stringify({ - request: { - symbol: 'BNBUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '5m' + expect(loggerMock.info).toHaveBeenCalledWith( + { + symbol: 'BNBUSDT', + interval: '5m', + data: { + summary: { RECOMMENDATION: 'BUY' }, + time: '2021-10-30T08:53:53.973250' }, - result: { - summary: { - RECOMMENDATION: 'BUY' - }, - time: '2021-10-30T08:54:53.973250' - } - }) + saveLog: true + }, + `The TradingView technical analysis recommendation for BNBUSDT:5m is "BUY".` ); - }); - it("saves logger.info for symbols because there isn't existing recommendation for first time", () => { expect(loggerMock.info).toHaveBeenCalledWith( { symbol: 'BTCUSDT', + interval: '15m', data: { - summary: { - RECOMMENDATION: 'NEUTRAL' - }, + summary: { RECOMMENDATION: 'BUY' }, time: '2021-10-30T08:53:53.973250' }, saveLog: true }, - `The TradingView technical analysis recommendation for BTCUSDT is "NEUTRAL".` + `The TradingView technical analysis recommendation for BTCUSDT:15m is "BUY".` ); expect(loggerMock.info).toHaveBeenCalledWith( { symbol: 'BNBUSDT', + interval: '30m', data: { - summary: { - RECOMMENDATION: 'BUY' - }, + summary: { RECOMMENDATION: 'STRONG_BUY' }, time: '2021-10-30T08:53:53.973250' }, saveLog: true }, - `The TradingView technical analysis recommendation for BNBUSDT is "BUY".` + `The TradingView technical analysis recommendation for BNBUSDT:30m is "STRONG_BUY".` ); }); @@ -1116,38 +758,69 @@ describe('get-trading-view.js', () => { `does not save logger.info for symbols ` + `because there isn't existing recommendation for second time`, () => { - expect(loggerMock.info).toHaveBeenCalledWith( + expect(loggerMock.info).not.toHaveBeenCalledWith( { symbol: 'BTCUSDT', + interval: '5m', data: { - summary: { - RECOMMENDATION: 'NEUTRAL' - }, + summary: { RECOMMENDATION: 'NEUTRAL' }, time: '2021-10-30T08:54:53.973250' }, - saveLog: false + saveLog: true }, - `The TradingView technical analysis recommendation for BTCUSDT is "NEUTRAL".` + `The TradingView technical analysis recommendation for BTCUSDT:5m is "NEUTRAL".` ); - expect(loggerMock.info).toHaveBeenCalledWith( + expect(loggerMock.info).not.toHaveBeenCalledWith( { symbol: 'BNBUSDT', + interval: '5m', data: { - summary: { - RECOMMENDATION: 'BUY' - }, + summary: { RECOMMENDATION: 'BUY' }, + time: '2021-10-30T08:54:53.973250' + }, + saveLog: true + }, + `The TradingView technical analysis recommendation for BNBUSDT:5m is "BUY".` + ); + + expect(loggerMock.info).not.toHaveBeenCalledWith( + { + symbol: 'BTCUSDT', + interval: '15m', + data: { + summary: { RECOMMENDATION: 'BUY' }, + time: '2021-10-30T08:54:53.973250' + }, + saveLog: true + }, + `The TradingView technical analysis recommendation for BTCUSDT:15m is "BUY".` + ); + + expect(loggerMock.info).not.toHaveBeenCalledWith( + { + symbol: 'BNBUSDT', + interval: '30m', + data: { + summary: { RECOMMENDATION: 'STRONG_BUY' }, time: '2021-10-30T08:54:53.973250' }, - saveLog: false + saveLog: true }, - `The TradingView technical analysis recommendation for BNBUSDT is "BUY".` + `The TradingView technical analysis recommendation for BNBUSDT:30m is "STRONG_BUY".` ); } ); + + it('triggers cache.hdel to remove unmonitored interval', () => { + expect(cacheMock.hdel).toHaveBeenCalledWith( + 'trailing-trade-tradingview', + 'BTCUSDT:1d' + ); + }); }); - describe('recommendation is different', () => { + describe('reecomendation is different', () => { beforeEach(async () => { rawData = { globalConfiguration: { @@ -1161,64 +834,169 @@ describe('get-trading-view.js', () => { BTCUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '15m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }), BNBUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '3m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '30m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] + } + }) + }) + .mockResolvedValueOnce({ + 'BTCUSDT:5m': JSON.stringify({ + request: { + symbol: 'BTCUSDT', + interval: '5m' + }, + result: { + summary: { + RECOMMENDATION: 'NEUTRAL' + }, + time: '2021-10-30T08:53:53.973250' + } + }), + 'BTCUSDT:1d': JSON.stringify({ + request: { + symbol: 'BTCUSDT', + interval: '1d' + }, + result: { + summary: { + RECOMMENDATION: 'NEUTRAL' + }, + time: '2021-10-30T08:53:53.973250' } }) }) - .mockResolvedValueOnce({}) .mockResolvedValueOnce({ BTCUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '15m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }), BNBUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '3m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '30m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }) }) @@ -1226,6 +1004,29 @@ describe('get-trading-view.js', () => { axiosMock.get = jest .fn() + .mockResolvedValueOnce({ + data: { + request: { + symbols: ['BINANCE:BTCUSDT', 'BINANCE:BNBUSDT'], + screener: 'CRYPTO', + interval: '5m' + }, + result: { + 'BINANCE:BTCUSDT': { + summary: { + RECOMMENDATION: 'NEUTRAL' + }, + time: '2021-10-30T08:53:53.973250' + }, + 'BINANCE:BNBUSDT': { + summary: { + RECOMMENDATION: 'BUY' + }, + time: '2021-10-30T08:53:53.973250' + } + } + } + }) .mockResolvedValueOnce({ data: { request: { @@ -1236,7 +1037,7 @@ describe('get-trading-view.js', () => { result: { 'BINANCE:BTCUSDT': { summary: { - RECOMMENDATION: 'NEUTRAL' + RECOMMENDATION: 'BUY' }, time: '2021-10-30T08:53:53.973250' } @@ -1248,18 +1049,41 @@ describe('get-trading-view.js', () => { request: { symbols: ['BINANCE:BNBUSDT'], screener: 'CRYPTO', - interval: '5m' + interval: '30m' }, result: { 'BINANCE:BNBUSDT': { summary: { - RECOMMENDATION: 'BUY' + RECOMMENDATION: 'STRONG_BUY' }, time: '2021-10-30T08:53:53.973250' } } } }) + .mockResolvedValueOnce({ + data: { + request: { + symbols: ['BINANCE:BTCUSDT', 'BINANCE:BNBUSDT'], + screener: 'CRYPTO', + interval: '5m' + }, + result: { + 'BINANCE:BTCUSDT': { + summary: { + RECOMMENDATION: 'BUY' + }, + time: '2021-10-30T08:54:53.973250' + }, + 'BINANCE:BNBUSDT': { + summary: { + RECOMMENDATION: 'STRONG_BUY' + }, + time: '2021-10-30T08:54:53.973250' + } + } + } + }) .mockResolvedValueOnce({ data: { request: { @@ -1270,7 +1094,7 @@ describe('get-trading-view.js', () => { result: { 'BINANCE:BTCUSDT': { summary: { - RECOMMENDATION: 'BUY' + RECOMMENDATION: 'SELL' }, time: '2021-10-30T08:54:53.973250' } @@ -1282,12 +1106,12 @@ describe('get-trading-view.js', () => { request: { symbols: ['BINANCE:BNBUSDT'], screener: 'CRYPTO', - interval: '5m' + interval: '30m' }, result: { 'BINANCE:BNBUSDT': { summary: { - RECOMMENDATION: 'SELL' + RECOMMENDATION: 'STRONG_BUY' }, time: '2021-10-30T08:54:53.973250' } @@ -1295,193 +1119,205 @@ describe('get-trading-view.js', () => { } }); - const step = require('../get-trading-view'); + const step = require('../get-tradingview'); - // Execute twice await step.execute(loggerMock, rawData); await step.execute(loggerMock, rawData); }); - it('triggers axios.get 4 times', () => { - expect(axiosMock.get).toHaveBeenCalledTimes(4); - }); - - it('triggers axios.get', () => { - expect(axiosMock.get).toHaveBeenCalledWith( - 'http://tradingview:8080', - { - params: { - symbols: ['BINANCE:BTCUSDT'], - screener: 'CRYPTO', - interval: '15m' - }, - paramsSerializer: expect.any(Function), - timeout: 20000 - } - ); - - expect(axiosMock.get).toHaveBeenCalledWith( - 'http://tradingview:8080', - { - params: { - symbols: ['BINANCE:BNBUSDT'], - screener: 'CRYPTO', - interval: '5m' - }, - paramsSerializer: expect.any(Function), - timeout: 20000 - } - ); - }); - - it('triggers cache.hset 4', () => { - expect(cacheMock.hset).toHaveBeenCalledTimes(4); - }); - - it('triggers cache.hset for symbols', () => { - expect(cacheMock.hset).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BTCUSDT', - JSON.stringify({ - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' - }, - result: { - summary: { - RECOMMENDATION: 'NEUTRAL' - }, + it('triggers axios.get six times', () => { + expect(axiosMock.get).toHaveBeenCalledTimes(6); + }); + + it('triggers cache.hset eight times', () => { + expect(cacheMock.hset).toHaveBeenCalledTimes(8); + }); + + it("saves logger.info for symbols because there isn't existing recommendation for first time", () => { + expect(loggerMock.info).toHaveBeenCalledWith( + { + symbol: 'BTCUSDT', + interval: '5m', + data: { + summary: { RECOMMENDATION: 'NEUTRAL' }, time: '2021-10-30T08:53:53.973250' - } - }) + }, + saveLog: true + }, + `The TradingView technical analysis recommendation for BTCUSDT:5m is "NEUTRAL".` ); - expect(cacheMock.hset).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BNBUSDT', - JSON.stringify({ - request: { - symbol: 'BNBUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '5m' - }, - result: { - summary: { - RECOMMENDATION: 'BUY' - }, + expect(loggerMock.info).toHaveBeenCalledWith( + { + symbol: 'BNBUSDT', + interval: '5m', + data: { + summary: { RECOMMENDATION: 'BUY' }, time: '2021-10-30T08:53:53.973250' - } - }) + }, + saveLog: true + }, + `The TradingView technical analysis recommendation for BNBUSDT:5m is "BUY".` ); - expect(cacheMock.hset).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BTCUSDT', - JSON.stringify({ - request: { - symbol: 'BTCUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '15m' + expect(loggerMock.info).toHaveBeenCalledWith( + { + symbol: 'BTCUSDT', + interval: '15m', + data: { + summary: { RECOMMENDATION: 'BUY' }, + time: '2021-10-30T08:53:53.973250' }, - result: { - summary: { - RECOMMENDATION: 'BUY' - }, - time: '2021-10-30T08:54:53.973250' - } - }) + saveLog: true + }, + `The TradingView technical analysis recommendation for BTCUSDT:15m is "BUY".` ); - expect(cacheMock.hset).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BNBUSDT', - JSON.stringify({ - request: { - symbol: 'BNBUSDT', - screener: 'CRYPTO', - exchange: 'BINANCE', - interval: '5m' + expect(loggerMock.info).toHaveBeenCalledWith( + { + symbol: 'BNBUSDT', + interval: '30m', + data: { + summary: { RECOMMENDATION: 'STRONG_BUY' }, + time: '2021-10-30T08:53:53.973250' }, - result: { - summary: { - RECOMMENDATION: 'SELL' - }, - time: '2021-10-30T08:54:53.973250' - } - }) + saveLog: true + }, + `The TradingView technical analysis recommendation for BNBUSDT:30m is "STRONG_BUY".` ); }); - it("saves logger.info for symbols because there isn't existing recommendation for first time", () => { + it("saves logger.info for symbols because there isn't existing recommendation for second time", () => { expect(loggerMock.info).toHaveBeenCalledWith( { symbol: 'BTCUSDT', + interval: '5m', data: { - summary: { - RECOMMENDATION: 'NEUTRAL' - }, - time: '2021-10-30T08:53:53.973250' + summary: { RECOMMENDATION: 'BUY' }, + time: '2021-10-30T08:54:53.973250' }, saveLog: true }, - `The TradingView technical analysis recommendation for BTCUSDT is "NEUTRAL".` + `The TradingView technical analysis recommendation for BTCUSDT:5m is "BUY".` ); expect(loggerMock.info).toHaveBeenCalledWith( { symbol: 'BNBUSDT', + interval: '5m', data: { - summary: { - RECOMMENDATION: 'BUY' - }, - time: '2021-10-30T08:53:53.973250' + summary: { RECOMMENDATION: 'STRONG_BUY' }, + time: '2021-10-30T08:54:53.973250' + }, + saveLog: true + }, + `The TradingView technical analysis recommendation for BNBUSDT:5m is "STRONG_BUY".` + ); + + expect(loggerMock.info).toHaveBeenCalledWith( + { + symbol: 'BTCUSDT', + interval: '15m', + data: { + summary: { RECOMMENDATION: 'SELL' }, + time: '2021-10-30T08:54:53.973250' }, saveLog: true }, - `The TradingView technical analysis recommendation for BNBUSDT is "BUY".` + `The TradingView technical analysis recommendation for BTCUSDT:15m is "SELL".` + ); + }); + + it('triggers cache.hdel to remove unmonitored interval', () => { + expect(cacheMock.hdel).toHaveBeenCalledWith( + 'trailing-trade-tradingview', + 'BTCUSDT:1d' ); }); it( - `saves logger.info for symbols ` + - `because recommendation is different`, + `does not save logger.info for symbols` + + ` because there isn't existing recommendation for second time`, () => { - expect(loggerMock.info).toHaveBeenCalledWith( + expect(loggerMock.info).not.toHaveBeenCalledWith( { - symbol: 'BTCUSDT', + symbol: 'BNBUSDT', + interval: '30m', data: { - summary: { - RECOMMENDATION: 'BUY' - }, + summary: { RECOMMENDATION: 'STRONG_BUY' }, time: '2021-10-30T08:54:53.973250' }, saveLog: true }, - `The TradingView technical analysis recommendation for BTCUSDT is "BUY".` + `The TradingView technical analysis recommendation for BNBUSDT:30m is "STRONG_BUY".` ); + } + ); + }); + }); - expect(loggerMock.info).toHaveBeenCalledWith( - { - symbol: 'BNBUSDT', - data: { - summary: { - RECOMMENDATION: 'SELL' - }, - time: '2021-10-30T08:54:53.973250' + describe('when symbol tradingViews configuration is undefined for some reason', () => { + beforeEach(async () => { + rawData = { + globalConfiguration: { + symbols: ['BTCUSDT'] + } + }; + + cacheMock.hgetall = jest + .fn() + .mockResolvedValueOnce({ + BTCUSDT: JSON.stringify({ + candles: { interval: '1h', limit: 100 } + }) + }) + .mockResolvedValueOnce({ + 'BTCUSDT:5m': JSON.stringify({ + request: { + symbol: 'BTCUSDT', + interval: '5m' + }, + result: { + summary: { + RECOMMENDATION: 'NEUTRAL' }, - saveLog: true + time: '2021-10-30T08:53:53.973250' + } + }), + 'BTCUSDT:1d': JSON.stringify({ + request: { + symbol: 'BTCUSDT', + interval: '1d' }, - `The TradingView technical analysis recommendation for BNBUSDT is "SELL".` - ); - } + result: { + summary: { + RECOMMENDATION: 'NEUTRAL' + }, + time: '2021-10-30T08:53:53.973250' + } + }) + }); + + axiosMock.get = jest.fn(); + + const step = require('../get-tradingview'); + + await step.execute(loggerMock, rawData); + }); + + it('does not triggers axios.get', () => { + expect(axiosMock.get).not.toBeCalled(); + }); + + it('triggers cache.hdel to remove unmonitored interval', () => { + expect(cacheMock.hdel).toHaveBeenCalledWith( + 'trailing-trade-tradingview', + 'BTCUSDT:1d' ); }); }); }); + describe('when cache.hset throws an error', () => { beforeEach(async () => { rawData = { @@ -1496,46 +1332,43 @@ describe('get-trading-view.js', () => { BTCUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }), BNBUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' - } - } - } - } - }), - global: JSON.stringify({ - candles: { interval: '1h', limit: 100 }, - botOptions: { - tradingView: { - interval: '15m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }) }) @@ -1546,7 +1379,7 @@ describe('get-trading-view.js', () => { request: { symbols: ['BINANCE:BTCUSDT', 'BINANCE:BNBUSDT'], screener: 'CRYPTO', - interval: '1h' + interval: '5m' }, result: { 'BINANCE:BTCUSDT': { @@ -1569,7 +1402,7 @@ describe('get-trading-view.js', () => { .fn() .mockRejectedValue(new Error('something went wrong')); - const step = require('../get-trading-view'); + const step = require('../get-tradingview'); await step.execute(loggerMock, rawData); }); @@ -1586,7 +1419,7 @@ describe('get-trading-view.js', () => { params: { symbols: ['BINANCE:BTCUSDT', 'BINANCE:BNBUSDT'], screener: 'CRYPTO', - interval: '1h' + interval: '5m' }, paramsSerializer: expect.any(Function), timeout: 20000 @@ -1596,13 +1429,13 @@ describe('get-trading-view.js', () => { it('triggers cache.hset for symbols', () => { expect(cacheMock.hset).toHaveBeenCalledWith( 'trailing-trade-tradingview', - 'BTCUSDT', + 'BTCUSDT:5m', JSON.stringify({ request: { symbol: 'BTCUSDT', screener: 'CRYPTO', exchange: 'BINANCE', - interval: '1h' + interval: '5m' }, result: { summary: { @@ -1615,13 +1448,13 @@ describe('get-trading-view.js', () => { expect(cacheMock.hset).toHaveBeenCalledWith( 'trailing-trade-tradingview', - 'BNBUSDT', + 'BNBUSDT:5m', JSON.stringify({ request: { symbol: 'BNBUSDT', screener: 'CRYPTO', exchange: 'BINANCE', - interval: '1h' + interval: '5m' }, result: { summary: { @@ -1652,46 +1485,78 @@ describe('get-trading-view.js', () => { BTCUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + }, + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }), BNBUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '30m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }), global: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '15m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }) }) @@ -1699,7 +1564,7 @@ describe('get-trading-view.js', () => { axiosMock.get = jest.fn().mockResolvedValue({}); - const step = require('../get-trading-view'); + const step = require('../get-tradingview'); await step.execute(loggerMock, rawData); }); @@ -1714,9 +1579,29 @@ describe('get-trading-view.js', () => { it('triggers axios.get', () => { expect(axiosMock.get).toHaveBeenCalledWith('http://tradingview:8080', { params: { - symbols: ['BINANCE:BTCUSDT', 'BINANCE:BNBUSDT'], + symbols: ['BINANCE:BTCUSDT'], + screener: 'CRYPTO', + interval: '5m' + }, + paramsSerializer: expect.any(Function), + timeout: 20000 + }); + + expect(axiosMock.get).toHaveBeenCalledWith('http://tradingview:8080', { + params: { + symbols: ['BINANCE:BTCUSDT'], + screener: 'CRYPTO', + interval: '15m' + }, + paramsSerializer: expect.any(Function), + timeout: 20000 + }); + + expect(axiosMock.get).toHaveBeenCalledWith('http://tradingview:8080', { + params: { + symbols: ['BINANCE:BNBUSDT'], screener: 'CRYPTO', - interval: '1h' + interval: '30m' }, paramsSerializer: expect.any(Function), timeout: 20000 @@ -1742,46 +1627,64 @@ describe('get-trading-view.js', () => { BTCUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }), BNBUSDT: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }), global: JSON.stringify({ candles: { interval: '1h', limit: 100 }, botOptions: { - tradingView: { - interval: '15m' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + tradingViews: [ + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } + ] } }) }) @@ -1792,7 +1695,7 @@ describe('get-trading-view.js', () => { request: { symbols: ['BINANCE:BTCUSDT', 'BINANCE:BNBUSDT'], screener: 'CRYPTO', - interval: '1h' + interval: '5m' }, result: { 'BINANCE:BTCUSDT': { @@ -1807,7 +1710,7 @@ describe('get-trading-view.js', () => { } }); - const step = require('../get-trading-view'); + const step = require('../get-tradingview'); await step.execute(loggerMock, rawData); }); @@ -1824,7 +1727,7 @@ describe('get-trading-view.js', () => { params: { symbols: ['BINANCE:BTCUSDT', 'BINANCE:BNBUSDT'], screener: 'CRYPTO', - interval: '1h' + interval: '5m' }, paramsSerializer: expect.any(Function), timeout: 20000 @@ -1844,60 +1747,75 @@ describe('get-trading-view.js', () => { } }; - cacheMock.hgetall = jest - .fn() - .mockResolvedValueOnce({ - BTCUSDT: JSON.stringify({ - candles: { interval: '1h', limit: 100 }, - botOptions: { - tradingView: { - interval: '' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + cacheMock.hgetall = jest.fn().mockResolvedValueOnce({ + BTCUSDT: JSON.stringify({ + candles: { interval: '1h', limit: 100 }, + botOptions: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true } } } - } - }), - BNBUSDT: JSON.stringify({ - candles: { interval: '1h', limit: 100 }, - botOptions: { - tradingView: { - interval: '' - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + ] + } + }), + BNBUSDT: JSON.stringify({ + candles: { interval: '1h', limit: 100 }, + botOptions: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true } } } - } - }), - global: JSON.stringify({ - candles: { interval: '1h', limit: 100 }, - botOptions: { - tradingView: { - interval: '15m' - } - }, - autoTriggerBuy: { - conditions: { - tradingView: { - overrideInterval: '' + ] + } + }), + global: JSON.stringify({ + candles: { interval: '1h', limit: 100 }, + botOptions: { + tradingViews: [ + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } } } - } - }) + ] + } }) - .mockResolvedValueOnce({}); + }); axiosMock.get = jest.fn().mockRejectedValue(new Error('timeout')); - const step = require('../get-trading-view'); + const step = require('../get-tradingview'); await step.execute(loggerMock, rawData); }); @@ -1914,7 +1832,7 @@ describe('get-trading-view.js', () => { params: { symbols: ['BINANCE:BTCUSDT', 'BINANCE:BNBUSDT'], screener: 'CRYPTO', - interval: '1h' + interval: '5m' }, paramsSerializer: expect.any(Function), timeout: 20000 @@ -1934,14 +1852,11 @@ describe('get-trading-view.js', () => { } }; - cacheMock.hgetall = jest - .fn() - .mockResolvedValueOnce({}) - .mockResolvedValueOnce({}); + cacheMock.hgetall = jest.fn().mockResolvedValueOnce({}); axiosMock.get = jest.fn().mockRejectedValue(new Error('timeout')); - const step = require('../get-trading-view'); + const step = require('../get-tradingview'); await step.execute(loggerMock, rawData); }); diff --git a/app/cronjob/trailingTradeIndicator/step/execute-dust-transfer.js b/app/cronjob/trailingTradeIndicator/step/execute-dust-transfer.js index 126b946c..04ee6508 100644 --- a/app/cronjob/trailingTradeIndicator/step/execute-dust-transfer.js +++ b/app/cronjob/trailingTradeIndicator/step/execute-dust-transfer.js @@ -30,7 +30,7 @@ const execute = async (logger, rawData) => { slack.sendMessage( `Dust Transfer Action:\n` + `- Assets: \`\`\`${JSON.stringify(assets, undefined, 2)}\`\`\``, - { apiLimit: getAPILimit(logger) } + { symbol: 'global', apiLimit: getAPILimit(logger) } ); try { @@ -50,7 +50,7 @@ const execute = async (logger, rawData) => { undefined, 2 )}\`\`\``, - { apiLimit: getAPILimit(logger) } + { symbol: 'global', apiLimit: getAPILimit(logger) } ); } catch (e) { logger.error(e, 'Execution failed.'); @@ -60,6 +60,7 @@ const execute = async (logger, rawData) => { }); slack.sendMessage(`Dust Transfer Error:\n- Message: ${e.message}`, { + symbol: 'global', apiLimit: getAPILimit(logger) }); } diff --git a/app/cronjob/trailingTradeIndicator/step/get-trading-view.js b/app/cronjob/trailingTradeIndicator/step/get-tradingview.js similarity index 57% rename from app/cronjob/trailingTradeIndicator/step/get-trading-view.js rename to app/cronjob/trailingTradeIndicator/step/get-tradingview.js index 537847a6..59d4d32f 100644 --- a/app/cronjob/trailingTradeIndicator/step/get-trading-view.js +++ b/app/cronjob/trailingTradeIndicator/step/get-tradingview.js @@ -5,53 +5,11 @@ const config = require('config'); const { cache } = require('../../../helpers'); const { handleError } = require('../../../error-handler'); -const isTriggeredByAutoTrigger = symbolOverrideData => - _.get(symbolOverrideData, 'action', '') === 'buy' && - _.get(symbolOverrideData, 'triggeredBy', '') === 'auto-trigger'; - -const getInterval = (logger, symbolConfiguration, symbolOverrideData) => { - const { - candles: { interval: candleInterval }, - botOptions: { - tradingView: { interval: tradingViewInterval }, - autoTriggerBuy: { - conditions: { - tradingView: { overrideInterval: tradingViewOverrideInterval } - } - } - } - } = symbolConfiguration; - - // By default, use candle interval - let interval = candleInterval; - // If overriden data is triggered by auto-trigger and TradingView override interval is configured, then use it. - if ( - isTriggeredByAutoTrigger(symbolOverrideData) && - tradingViewOverrideInterval !== '' - ) { - interval = tradingViewOverrideInterval; - logger.info( - { tradingViewOverrideInterval }, - 'Use override interval because of auto-buy trigger' - ); - } else if (tradingViewInterval !== '') { - // If TradingView interval is not empty, then use it. - interval = tradingViewInterval; - } - - switch (interval) { - case '3m': - return '5m'; - default: - return interval; - } -}; - // This variable will store last TradingView response per symbol to avoid saving the duplicated log. const lastTradingView = {}; /** - * Retreive trading view for symbols per interval + * Retrieve trading view for symbols per interval * * @param {*} logger * @param {*} symbols @@ -98,8 +56,11 @@ const retrieveTradingView = async (logger, symbols, interval) => { // If new recommendation is different than previous recommendation, if ( - _.get(lastTradingView, `${symbol}.summary.RECOMMENDATION`, '') !== - newRecommendation + _.get( + lastTradingView, + `${symbol}:${interval}.summary.RECOMMENDATION`, + '' + ) !== newRecommendation ) { // Then saveLog as true saveLog = true; @@ -108,18 +69,19 @@ const retrieveTradingView = async (logger, symbols, interval) => { // If recommendation is retrieved, if (newRecommendation !== '') { logger.info( - { symbol, data: result, saveLog }, - `The TradingView technical analysis recommendation for ${symbol} is "${_.get( + { symbol, interval, data: result, saveLog }, + `The TradingView technical analysis recommendation for ${symbol}:${interval} is "${_.get( result, 'summary.RECOMMENDATION' )}".` ); - lastTradingView[symbol] = result; + lastTradingView[`${symbol}:${interval}`] = result; + cache .hset( 'trailing-trade-tradingview', - symbol, + `${symbol}:${interval}`, JSON.stringify({ request: { symbol, @@ -163,10 +125,10 @@ const execute = async (funcLogger, rawData) => { const logger = funcLogger.child({ symbols }); - logger.info('get-trading-view started'); + logger.info('get-tradingview started'); if (_.isEmpty(symbols)) { - logger.info('No symbols configured. Do not process get-trading-view'); + logger.info('No symbols configured. Do not process get-tradingview'); return data; } @@ -176,58 +138,71 @@ const execute = async (funcLogger, rawData) => { 'trailing-trade-configurations:*' ); - const cachedOverrideData = await cache.hgetall( - 'trailing-trade-override:', - 'trailing-trade-override:*' + const cacheTradingViews = _.map( + await cache.hgetall( + 'trailing-trade-tradingview:', + 'trailing-trade-tradingview:*' + ), + tradingView => JSON.parse(tradingView) ); const tradingViewRequests = {}; // eslint-disable-next-line no-restricted-syntax for (const symbol of Object.keys(cachedSymbolConfigurations)) { + // Do not handle if it's a global configuration if (symbol === 'global') { // eslint-disable-next-line no-continue continue; } - const value = cachedSymbolConfigurations[symbol]; - const symbolConfiguration = JSON.parse(value); + const symbolConfiguration = JSON.parse(cachedSymbolConfigurations[symbol]); - let symbolOverrideData = {}; - try { - symbolOverrideData = JSON.parse(cachedOverrideData[symbol]); - // eslint-disable-next-line no-empty - } catch (e) {} - - logger.info({ symbol, symbolOverrideData }, 'Symbol override data'); - - const finalInterval = getInterval( - logger, + const tradingViews = _.get( symbolConfiguration, - symbolOverrideData - ); - logger.info( - { symbol, symbolOverrideData, finalInterval }, - 'Determined final interval' + 'botOptions.tradingViews', + [] ); - if (tradingViewRequests[finalInterval] === undefined) { - tradingViewRequests[finalInterval] = { - symbols: [], - interval: finalInterval - }; - } - tradingViewRequests[finalInterval].symbols.push(symbol); - } + const tradingViewIntervals = []; + tradingViews.forEach(tradingView => { + const { interval } = tradingView; - logger.info({ tradingViewRequests }, 'TradingView requests'); + tradingViewIntervals.push(interval); + if (tradingViewRequests[interval] === undefined) { + tradingViewRequests[interval] = { + symbols: [], + interval + }; + } + tradingViewRequests[interval].symbols.push(symbol); + }); - const promises = _.map(tradingViewRequests, request => - retrieveTradingView(logger, request.symbols, request.interval) - ); + // Delete if tradingView interval is removed from symbol configuration + cacheTradingViews + .filter(tv => tv.request.symbol === symbol) + .filter( + tv => tradingViewIntervals.includes(tv.request.interval) === false + ) + .forEach(tv => + cache.hdel( + 'trailing-trade-tradingview', + `${tv.request.symbol}:${tv.request.interval}` + ) + ); + } - Promise.all(promises); + logger.info({ tradingViewRequests }, 'Requesting TradingView intervals'); + const promises = []; + + _.forIn(tradingViewRequests, async (request, _requestInterval) => { + promises.push( + retrieveTradingView(logger, _.uniq(request.symbols), request.interval) + ); + }); + await Promise.all(promises); + logger.info('get-tradingview completed'); return data; }; diff --git a/app/cronjob/trailingTradeIndicator/steps.js b/app/cronjob/trailingTradeIndicator/steps.js index 2700d51a..f67374f9 100644 --- a/app/cronjob/trailingTradeIndicator/steps.js +++ b/app/cronjob/trailingTradeIndicator/steps.js @@ -15,7 +15,7 @@ const { } = require('./step/execute-dust-transfer'); const { execute: getClosedTrades } = require('./step/get-closed-trades'); const { execute: getOrderStats } = require('./step/get-order-stats'); -const { execute: getTradingView } = require('./step/get-trading-view'); +const { execute: getTradingView } = require('./step/get-tradingview'); const { execute: saveDataToCache } = require('./step/save-data-to-cache'); module.exports = { diff --git a/app/error-handler.js b/app/error-handler.js index 40d20782..bb0505f3 100644 --- a/app/error-handler.js +++ b/app/error-handler.js @@ -13,6 +13,12 @@ const runErrorHandler = logger => { // and report them to slack // https://nodejs.org/api/process.html#process_event_uncaughtexception process.on('uncaughtException', async err => { + // Ignore the error with redlock + if (err.message.includes('redlock')) { + // Simply ignore + return; + } + logger.error({ err }); const githubIssuesLink = 'https://github.com/chrisleekr/binance-trading-bot/issues/new' + @@ -27,13 +33,13 @@ const runErrorHandler = logger => { ? `Stack:\`\`\`${err.stack}\`\`\`\n` : '' }`, - { apiLimit: getAPILimit(logger) } + { symbol: 'global', apiLimit: getAPILimit(logger) } ); }); }; const handleError = (logger, job, err) => { - // For the redlock fail + // Ignore the error with redlock if (err.message.includes('redlock')) { // Simply ignore return; @@ -61,7 +67,7 @@ const handleError = (logger, job, err) => { ? `Stack:\`\`\`${err.stack}\`\`\`\n` : '' }`, - { apiLimit: getAPILimit(logger) } + { symbol: 'global', apiLimit: getAPILimit(logger) } ); } }; diff --git a/app/frontend/local-tunnel/configure.js b/app/frontend/local-tunnel/configure.js index 038fb5e8..2ee90279 100644 --- a/app/frontend/local-tunnel/configure.js +++ b/app/frontend/local-tunnel/configure.js @@ -21,7 +21,7 @@ const reconnect = (logger, message, ms) => { isReconnecting = true; if (config.get('featureToggle.notifyDebug')) { - slack.sendMessage(`Local Tunnel:\n${message}`); + slack.sendMessage(`Local Tunnel:\n${message}`, { symbol: 'global' }); } logger.warn(message); @@ -76,7 +76,7 @@ const connect = async logger => { // Save config with local tunnel url await cache.hset('trailing-trade-common', 'local-tunnel-url', tunnel.url); - slack.sendMessage(`*Public URL:* ${tunnel.url}`); + slack.sendMessage(`*Public URL:* ${tunnel.url}`, { symbol: 'global' }); logger.info( { localTunnelURL: tunnel.url }, 'New URL detected, sent to Slack.' diff --git a/app/frontend/webserver/handlers/__tests__/symbol-delete.test.js b/app/frontend/webserver/handlers/__tests__/symbol-delete.test.js index ba92aa1b..b683d1b5 100644 --- a/app/frontend/webserver/handlers/__tests__/symbol-delete.test.js +++ b/app/frontend/webserver/handlers/__tests__/symbol-delete.test.js @@ -94,12 +94,18 @@ describe('webserver/handlers/symbol-delete', () => { mongoMock.deleteOne = jest.fn().mockResolvedValue(true); - cacheMock.hgetall = jest.fn().mockResolvedValueOnce({ - 'BTCUSDT-latest-candle': 'value1', - 'BTCUSDT-symbol-info': 'value2' - }); - - cacheMock.hdel = jest.fn().mockResolvedValue(true); + cacheMock.hgetall = jest + .fn() + .mockResolvedValueOnce({ + 'BTCUSDT-latest-candle': 'value1', + 'BTCUSDT-symbol-info': 'value2' + }) + .mockResolvedValueOnce({ + 'BTCUSDT:5m': { request: { interval: '5m' } }, + 'BTCUSDT:15m': { request: { interval: '15m' } } + }); + + cacheMock.hdel = jest.fn().mockReturnValue(true); PubSubMock.publish = jest.fn().mockResolvedValue(true); @@ -131,7 +137,7 @@ describe('webserver/handlers/symbol-delete', () => { }); ['BTCUSDT-latest-candle', 'BTCUSDT-symbol-info'].forEach(key => { - it('triggers cache.hdel for trailing-trade-symbols', () => { + it(`triggers cache.hdel for trailing-trade-symbols:${key}`, () => { expect(cacheMock.hdel).toHaveBeenCalledWith( 'trailing-trade-symbols', key @@ -139,11 +145,13 @@ describe('webserver/handlers/symbol-delete', () => { }); }); - it('triggers cache.del for trailing-trade-tradingview', () => { - expect(cacheMock.hdel).toHaveBeenCalledWith( - 'trailing-trade-tradingview', - 'BTCUSDT' - ); + ['BTCUSDT:5m', 'BTCUSDT:15m'].forEach(key => { + it(`triggers cache.hdel for trailing-trade-tradingview:${key}`, () => { + expect(cacheMock.hdel).toHaveBeenCalledWith( + 'trailing-trade-tradingview', + key + ); + }); }); it('triggers mongo.deleteOne for last buy price', () => { diff --git a/app/frontend/webserver/handlers/auth.js b/app/frontend/webserver/handlers/auth.js index 87892fb2..9012fcfa 100644 --- a/app/frontend/webserver/handlers/auth.js +++ b/app/frontend/webserver/handlers/auth.js @@ -80,7 +80,8 @@ const handleAuth = async (funcLogger, app, { loginLimiter }) => { 'appName' )} Webserver:\n❌ The bot failed to authenticate.\n` + `- Entered password: ${requestedPassword}\n` + - `- IP: ${clientIp}` + `- IP: ${clientIp}`, + { symbol: 'global' } ); return res.send({ @@ -105,7 +106,8 @@ const handleAuth = async (funcLogger, app, { loginLimiter }) => { slack.sendMessage( `${config.get( 'appName' - )} Webserver:\n✅ The bot succeeded to authenticate.\n- IP: ${clientIp}` + )} Webserver:\n✅ The bot succeeded to authenticate.\n- IP: ${clientIp}`, + { symbol: 'global' } ); return res.send({ diff --git a/app/frontend/webserver/handlers/backup-get.js b/app/frontend/webserver/handlers/backup-get.js index 4b84526c..3d1473c5 100644 --- a/app/frontend/webserver/handlers/backup-get.js +++ b/app/frontend/webserver/handlers/backup-get.js @@ -49,7 +49,7 @@ const handleBackupGet = async (funcLogger, app) => { if (result.code !== 0) { slack.sendMessage( `The backup has failed.\n\`\`\`${JSON.stringify(result)}\`\`\``, - {} + { symbol: 'global' } ); return res.send({ diff --git a/app/frontend/webserver/handlers/restore-post.js b/app/frontend/webserver/handlers/restore-post.js index 3916d125..9598387f 100644 --- a/app/frontend/webserver/handlers/restore-post.js +++ b/app/frontend/webserver/handlers/restore-post.js @@ -53,7 +53,7 @@ const handleRestorePost = async (funcLogger, app) => { }); if (result.code !== 0) { - slack.sendMessage(`The restore has failed.`, {}); + slack.sendMessage(`The restore has failed.`, { symbol: 'global' }); return res.send({ success: false, diff --git a/app/frontend/webserver/handlers/symbol-delete.js b/app/frontend/webserver/handlers/symbol-delete.js index e1099d28..75743cfd 100644 --- a/app/frontend/webserver/handlers/symbol-delete.js +++ b/app/frontend/webserver/handlers/symbol-delete.js @@ -41,7 +41,14 @@ const handleSymbolDelete = async (funcLogger, app) => { ); // Delete tradingview - await cache.hdel('trailing-trade-tradingview', symbol); + await Promise.all( + Object.keys( + await cache.hgetall( + 'trailing-trade-tradingview:', + `trailing-trade-tradingview:${symbol}*` + ) + ).map(key => cache.hdel('trailing-trade-tradingview', key)) + ); // Delete last buy price [`${symbol}-last-buy-price`].forEach(async key => { diff --git a/app/frontend/websocket/configure.js b/app/frontend/websocket/configure.js index a018606c..7b805a23 100644 --- a/app/frontend/websocket/configure.js +++ b/app/frontend/websocket/configure.js @@ -48,8 +48,6 @@ const configureWebSocket = async (server, funcLogger, { loginLimiter }) => { wss.on('connection', ws => { ws.on('message', async message => { - logger.info({ message }, 'received'); - // eslint-disable-next-line no-underscore-dangle const clientIp = ws._socket.remoteAddress; const rateLimiterLogin = await loginLimiter.get(clientIp); diff --git a/app/frontend/websocket/handlers/__tests__/fixtures/latest-stats-authenticated.json b/app/frontend/websocket/handlers/__tests__/fixtures/latest-stats-authenticated.json index 640dabaf..f874f0fd 100644 --- a/app/frontend/websocket/handlers/__tests__/fixtures/latest-stats-authenticated.json +++ b/app/frontend/websocket/handlers/__tests__/fixtures/latest-stats-authenticated.json @@ -23,13 +23,7 @@ "candles": { "interval": "15m" }, - "symbols": [ - "BTCUSDT", - "BNBUSDT", - "ETHBUSD", - "BTCBUSD", - "LTCBUSD" - ], + "symbols": ["BTCUSDT", "BNBUSDT", "ETHBUSD", "BTCBUSD", "LTCBUSD"], "botOptions": { "authentication": { "lockList": true, @@ -48,7 +42,7 @@ "sell": {} }, "common": { - "version": "0.0.85", + "version": "0.0.93", "gitHash": "some-hash", "accountInfo": { "makerCommission": 0, @@ -126,9 +120,7 @@ "tickSize": null } ], - "permissions": [ - "SPOT" - ] + "permissions": ["SPOT"] }, "apiInfo": { "spot": { @@ -662,25 +654,6 @@ }, "order": {}, "saveToCache": true, - "tradingView": { - "request": { - "symbol": "BTCUSDT", - "screener": "CRYPTO", - "exchange": "BINANCE", - "interval": "1h" - }, - "result": { - "indicators": { - "summary": { - "BUY": 14, - "NEUTRAL": 10, - "RECOMMENDATION": "BUY", - "SELL": 2 - }, - "time": "2022-07-18T11:45:39.308508" - } - } - }, "isActionDisabled": { "isDisabled": false, "ttl": -2 @@ -1400,6 +1373,27 @@ "ttl": -2 } } + ], + "tradingViews": [ + { + "request": { + "symbol": "BTCUSDT", + "screener": "CRYPTO", + "exchange": "BINANCE", + "interval": "1h" + }, + "result": { + "indicators": { + "summary": { + "BUY": 14, + "NEUTRAL": 10, + "RECOMMENDATION": "BUY", + "SELL": 2 + }, + "time": "2022-07-18T11:45:39.308508" + } + } + } ] } } diff --git a/app/frontend/websocket/handlers/__tests__/fixtures/latest-stats-invalid-cache.json b/app/frontend/websocket/handlers/__tests__/fixtures/latest-stats-invalid-cache.json index 5cb4bc16..ebcb699f 100644 --- a/app/frontend/websocket/handlers/__tests__/fixtures/latest-stats-invalid-cache.json +++ b/app/frontend/websocket/handlers/__tests__/fixtures/latest-stats-invalid-cache.json @@ -2,27 +2,14 @@ "result": true, "type": "latest", "isAuthenticated": true, - "configuration": { - "enabled": true, - "symbols": ["BTCUSDT", "BNBUSDT"] - }, + "configuration": { "enabled": true, "symbols": ["BTCUSDT", "BNBUSDT"] }, "common": { - "version": "0.0.94", - "gitHash": "unspecified", - "accountInfo": { - "balances": [] - }, - "apiInfo": { - "spot": { - "usedWeight1m": "60" - }, - "futures": {} - }, + "version": "0.0.96", + "gitHash": "some-hash", + "accountInfo": { "balances": [] }, + "apiInfo": { "spot": { "usedWeight1m": "60" }, "futures": {} }, "closedTradesSetting": {}, - "orderStats": { - "numberOfOpenTrades": null, - "numberOfBuyOpenOrders": null - }, + "orderStats": { "numberOfOpenTrades": null, "numberOfBuyOpenOrders": null }, "closedTrades": [], "totalProfitAndLoss": [], "streamsCount": 6, @@ -51,28 +38,16 @@ "enabled": true, "cronTime": "* * * * * *", "botOptions": { - "authentication": { - "lockList": false, - "lockAfter": 120 - }, - "autoTriggerBuy": { - "enabled": true, - "triggerAfter": 1 - } - }, - "candles": { - "interval": "1m", - "limit": 10 + "authentication": { "lockList": false, "lockAfter": 120 }, + "autoTriggerBuy": { "enabled": true, "triggerAfter": 1 } }, + "candles": { "interval": "1m", "limit": 10 }, "buy": { "enabled": true, "lastBuyPriceRemoveThreshold": 10, "athRestriction": { "enabled": false, - "candles": { - "interval": "1d", - "limit": 30 - }, + "candles": { "interval": "1d", "limit": 30 }, "restrictionPercentage": 0.9 }, "maxPurchaseAmount": -1, @@ -301,19 +276,13 @@ "symbolConfiguration": { "_id": "611e5f2dd3630e44f849b05b", "key": "BTCUSDT-configuration", - "candles": { - "interval": "1m", - "limit": 10 - }, + "candles": { "interval": "1m", "limit": 10 }, "buy": { "enabled": true, "lastBuyPriceRemoveThreshold": 10, "athRestriction": { "enabled": false, - "candles": { - "interval": "1d", - "limit": 30 - }, + "candles": { "interval": "1d", "limit": 30 }, "restrictionPercentage": 0.9 }, "maxPurchaseAmount": -1, @@ -366,14 +335,8 @@ } }, "botOptions": { - "authentication": { - "lockList": false, - "lockAfter": 120 - }, - "autoTriggerBuy": { - "enabled": true, - "triggerAfter": 1 - } + "authentication": { "lockList": false, "lockAfter": 120 }, + "autoTriggerBuy": { "enabled": true, "triggerAfter": 1 } }, "enabled": true, "cronTime": "* * * * * *", @@ -512,10 +475,7 @@ }, "order": {}, "saveToCache": true, - "isActionDisabled": { - "isDisabled": false, - "ttl": -2 - } + "isActionDisabled": { "isDisabled": false, "ttl": -2 } }, { "symbol": "ETHBUSD", @@ -536,28 +496,16 @@ "enabled": true, "cronTime": "* * * * * *", "botOptions": { - "authentication": { - "lockList": false, - "lockAfter": 120 - }, - "autoTriggerBuy": { - "enabled": true, - "triggerAfter": 1 - } - }, - "candles": { - "interval": "1m", - "limit": 10 + "authentication": { "lockList": false, "lockAfter": 120 }, + "autoTriggerBuy": { "enabled": true, "triggerAfter": 1 } }, + "candles": { "interval": "1m", "limit": 10 }, "buy": { "enabled": true, "lastBuyPriceRemoveThreshold": 10, "athRestriction": { "enabled": false, - "candles": { - "interval": "1d", - "limit": 30 - }, + "candles": { "interval": "1d", "limit": 30 }, "restrictionPercentage": 0.9 }, "maxPurchaseAmount": -1, @@ -762,10 +710,7 @@ }, "order": {}, "saveToCache": true, - "isActionDisabled": { - "isDisabled": false, - "ttl": -2 - } + "isActionDisabled": { "isDisabled": false, "ttl": -2 } }, { "symbol": "BTCBUSD", @@ -786,28 +731,16 @@ "enabled": true, "cronTime": "* * * * * *", "botOptions": { - "authentication": { - "lockList": false, - "lockAfter": 120 - }, - "autoTriggerBuy": { - "enabled": true, - "triggerAfter": 1 - } - }, - "candles": { - "interval": "1m", - "limit": 10 + "authentication": { "lockList": false, "lockAfter": 120 }, + "autoTriggerBuy": { "enabled": true, "triggerAfter": 1 } }, + "candles": { "interval": "1m", "limit": 10 }, "buy": { "enabled": true, "lastBuyPriceRemoveThreshold": 10, "athRestriction": { "enabled": false, - "candles": { - "interval": "1d", - "limit": 30 - }, + "candles": { "interval": "1d", "limit": 30 }, "restrictionPercentage": 0.9 }, "maxPurchaseAmount": -1, @@ -994,10 +927,7 @@ }, "order": {}, "saveToCache": true, - "isActionDisabled": { - "isDisabled": false, - "ttl": -2 - } + "isActionDisabled": { "isDisabled": false, "ttl": -2 } }, { "symbol": "LTCBUSD", @@ -1018,28 +948,16 @@ "enabled": true, "cronTime": "* * * * * *", "botOptions": { - "authentication": { - "lockList": false, - "lockAfter": 120 - }, - "autoTriggerBuy": { - "enabled": true, - "triggerAfter": 1 - } - }, - "candles": { - "interval": "1m", - "limit": 10 + "authentication": { "lockList": false, "lockAfter": 120 }, + "autoTriggerBuy": { "enabled": true, "triggerAfter": 1 } }, + "candles": { "interval": "1m", "limit": 10 }, "buy": { "enabled": true, "lastBuyPriceRemoveThreshold": 10, "athRestriction": { "enabled": false, - "candles": { - "interval": "1d", - "limit": 30 - }, + "candles": { "interval": "1d", "limit": 30 }, "restrictionPercentage": 0.9 }, "maxPurchaseAmount": -1, @@ -1226,11 +1144,9 @@ }, "order": {}, "saveToCache": true, - "isActionDisabled": { - "isDisabled": false, - "ttl": -2 - } + "isActionDisabled": { "isDisabled": false, "ttl": -2 } } - ] + ], + "tradingViews": [] } } diff --git a/app/frontend/websocket/handlers/__tests__/fixtures/latest-stats-not-authenticated-unlock-list.json b/app/frontend/websocket/handlers/__tests__/fixtures/latest-stats-not-authenticated-unlock-list.json index bca2ffdf..9683a2c3 100644 --- a/app/frontend/websocket/handlers/__tests__/fixtures/latest-stats-not-authenticated-unlock-list.json +++ b/app/frontend/websocket/handlers/__tests__/fixtures/latest-stats-not-authenticated-unlock-list.json @@ -20,13 +20,7 @@ "configuration": { "enabled": true, "type": "i-am-global", - "symbols": [ - "BTCUSDT", - "BNBUSDT", - "ETHBUSD", - "BTCBUSD", - "LTCBUSD" - ], + "symbols": ["BTCUSDT", "BNBUSDT", "ETHBUSD", "BTCBUSD", "LTCBUSD"], "candles": { "interval": "15m" }, @@ -48,7 +42,7 @@ "sell": {} }, "common": { - "version": "0.0.85", + "version": "0.0.93", "gitHash": "some-hash", "accountInfo": { "makerCommission": 0, @@ -126,9 +120,7 @@ "tickSize": null } ], - "permissions": [ - "SPOT" - ] + "permissions": ["SPOT"] }, "apiInfo": { "spot": { @@ -662,25 +654,6 @@ }, "order": {}, "saveToCache": true, - "tradingView": { - "request": { - "symbol": "BTCUSDT", - "screener": "CRYPTO", - "exchange": "BINANCE", - "interval": "1h" - }, - "result": { - "indicators": { - "summary": { - "BUY": 14, - "NEUTRAL": 10, - "RECOMMENDATION": "BUY", - "SELL": 2 - }, - "time": "2022-07-18T11:45:39.308508" - } - } - }, "isActionDisabled": { "isDisabled": false, "ttl": -2 @@ -1400,6 +1373,27 @@ "ttl": -2 } } + ], + "tradingViews": [ + { + "request": { + "symbol": "BTCUSDT", + "screener": "CRYPTO", + "exchange": "BINANCE", + "interval": "1h" + }, + "result": { + "indicators": { + "summary": { + "BUY": 14, + "NEUTRAL": 10, + "RECOMMENDATION": "BUY", + "SELL": 2 + }, + "time": "2022-07-18T11:45:39.308508" + } + } + } ] } } diff --git a/app/frontend/websocket/handlers/__tests__/setting-update.test.js b/app/frontend/websocket/handlers/__tests__/setting-update.test.js index afb3ca1e..9ef0c96e 100644 --- a/app/frontend/websocket/handlers/__tests__/setting-update.test.js +++ b/app/frontend/websocket/handlers/__tests__/setting-update.test.js @@ -1,4 +1,5 @@ /* eslint-disable global-require */ +const moment = require('moment'); describe('setting-update.test.js', () => { let mockWebSocketServer; @@ -14,6 +15,8 @@ describe('setting-update.test.js', () => { let config; + const validTime = moment().utc().format('YYYY-MM-DDTHH:mm:ss.SSSSSS'); + beforeEach(() => { jest.clearAllMocks().resetModules(); @@ -114,10 +117,50 @@ describe('setting-update.test.js', () => { cacheMock = cache; cacheMock.hdel = jest.fn().mockResolvedValue(true); - cacheMock.hgetall = jest.fn().mockResolvedValue({ - 'BTCUSDT-symbol-info': JSON.stringify({ some: 'value' }), - 'ETHUSDT-symbol-info': JSON.stringify({ some: 'value' }) - }); + cacheMock.hgetall = jest + .fn() + .mockResolvedValueOnce({ + 'BTCUSDT-symbol-info': JSON.stringify({ some: 'value' }), + 'ETHUSDT-symbol-info': JSON.stringify({ some: 'value' }) + }) + .mockResolvedValueOnce({ + 'BTCUSDT:5m': JSON.stringify({ + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_SELL' }, + time: validTime + } + }), + 'BTCUSDT:15m': JSON.stringify({ + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_SELL' }, + time: validTime + } + }), + 'BTCUSDT:30m': JSON.stringify({ + request: { + interval: '30m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_SELL' }, + time: validTime + } + }), + 'ETHUSDT:5m': JSON.stringify({ + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_SELL' }, + time: validTime + } + }) + }); mockGetGlobalConfiguration = jest.fn().mockResolvedValue({ enabled: true, @@ -137,7 +180,49 @@ describe('setting-update.test.js', () => { lastbuyPercentage: 1.06, stopPercentage: 0.99, limitPercentage: 0.98 - } + }, + botOptions: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' + } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_SELL' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'SELL' }, + time: validTime + } + } + ] }); mockSaveGlobalConfiguration = jest.fn().mockResolvedValue(true); @@ -164,6 +249,42 @@ describe('setting-update.test.js', () => { lastbuyPercentage: 1.07, stopPercentage: 0.98, limitPercentage: 0.97 + }, + botOptions: { + tradingViews: [ + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + }, + { + interval: '30m', + buy: { + whenStrongBuy: false, + whenBuy: false + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' + } } } }); @@ -192,7 +313,67 @@ describe('setting-update.test.js', () => { lastbuyPercentage: 1.07, stopPercentage: 0.98, limitPercentage: 0.97 - } + }, + botOptions: { + tradingViews: [ + { + buy: { + whenBuy: true, + whenStrongBuy: true + }, + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + }, + { + buy: { + whenBuy: false, + whenStrongBuy: false + }, + interval: '30m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + ifExpires: 'ignore', + useOnlyWithin: 5 + } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { + RECOMMENDATION: 'STRONG_SELL' + }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL' + }, + time: validTime + } + } + ] }); }); @@ -247,7 +428,50 @@ describe('setting-update.test.js', () => { lastbuyPercentage: 1.07, stopPercentage: 0.98, limitPercentage: 0.97 - } + }, + botOptions: { + tradingViews: [ + { + interval: '15m', + buy: { whenStrongBuy: true, whenBuy: true }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + }, + { + interval: '30m', + buy: { whenStrongBuy: false, whenBuy: false }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } + }, + tradingViews: [ + { + request: { interval: '5m' }, + result: { + summary: { RECOMMENDATION: 'STRONG_SELL' }, + time: validTime + } + }, + { + request: { interval: '15m' }, + result: { + summary: { RECOMMENDATION: 'SELL' }, + time: validTime + } + } + ] } }) ); @@ -261,10 +485,50 @@ describe('setting-update.test.js', () => { cacheMock = cache; cacheMock.hdel = jest.fn().mockResolvedValue(true); - cacheMock.hgetall = jest.fn().mockResolvedValue({ - 'BTCUSDT-symbol-info': JSON.stringify({ some: 'value' }), - 'ETHUSDT-symbol-info': JSON.stringify({ some: 'value' }) - }); + cacheMock.hgetall = jest + .fn() + .mockResolvedValueOnce({ + 'BTCUSDT-symbol-info': JSON.stringify({ some: 'value' }), + 'ETHUSDT-symbol-info': JSON.stringify({ some: 'value' }) + }) + .mockResolvedValueOnce({ + 'BTCUSDT:5m': JSON.stringify({ + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_SELL' }, + time: validTime + } + }), + 'BTCUSDT:15m': JSON.stringify({ + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_SELL' }, + time: validTime + } + }), + 'BTCUSDT:30m': JSON.stringify({ + request: { + interval: '30m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_SELL' }, + time: validTime + } + }), + 'ETHUSDT:5m': JSON.stringify({ + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_SELL' }, + time: validTime + } + }) + }); mockGetGlobalConfiguration = jest.fn().mockResolvedValue({ enabled: true, @@ -284,7 +548,49 @@ describe('setting-update.test.js', () => { lastbuyPercentage: 1.06, stopPercentage: 0.99, limitPercentage: 0.98 - } + }, + botOptions: { + tradingViews: [ + { + interval: '5m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: true, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' + } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { RECOMMENDATION: 'STRONG_SELL' }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { RECOMMENDATION: 'SELL' }, + time: validTime + } + } + ] }); mockSaveGlobalConfiguration = jest.fn().mockResolvedValue(true); @@ -311,6 +617,42 @@ describe('setting-update.test.js', () => { lastbuyPercentage: 1.07, stopPercentage: 0.98, limitPercentage: 0.97 + }, + botOptions: { + tradingViews: [ + { + interval: '15m', + buy: { + whenStrongBuy: true, + whenBuy: true + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + }, + { + interval: '30m', + buy: { + whenStrongBuy: false, + whenBuy: false + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + useOnlyWithin: 5, + ifExpires: 'ignore' + } } } }); @@ -339,7 +681,67 @@ describe('setting-update.test.js', () => { lastbuyPercentage: 1.07, stopPercentage: 0.98, limitPercentage: 0.97 - } + }, + botOptions: { + tradingViews: [ + { + buy: { + whenBuy: true, + whenStrongBuy: true + }, + interval: '15m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + }, + { + buy: { + whenBuy: false, + whenStrongBuy: false + }, + interval: '30m', + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { + ifExpires: 'ignore', + useOnlyWithin: 5 + } + }, + tradingViews: [ + { + request: { + interval: '5m' + }, + result: { + summary: { + RECOMMENDATION: 'STRONG_SELL' + }, + time: validTime + } + }, + { + request: { + interval: '15m' + }, + result: { + summary: { + RECOMMENDATION: 'SELL' + }, + time: validTime + } + } + ] }); }); @@ -394,7 +796,50 @@ describe('setting-update.test.js', () => { lastbuyPercentage: 1.07, stopPercentage: 0.98, limitPercentage: 0.97 - } + }, + botOptions: { + tradingViews: [ + { + interval: '15m', + buy: { whenStrongBuy: true, whenBuy: true }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + }, + { + interval: '30m', + buy: { whenStrongBuy: false, whenBuy: false }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: false, + whenSell: false, + whenStrongSell: true + } + } + } + ], + tradingViewOptions: { useOnlyWithin: 5, ifExpires: 'ignore' } + }, + tradingViews: [ + { + request: { interval: '5m' }, + result: { + summary: { RECOMMENDATION: 'STRONG_SELL' }, + time: validTime + } + }, + { + request: { interval: '15m' }, + result: { + summary: { RECOMMENDATION: 'SELL' }, + time: validTime + } + } + ] } }) ); diff --git a/app/frontend/websocket/handlers/__tests__/symbol-delete.test.js b/app/frontend/websocket/handlers/__tests__/symbol-delete.test.js deleted file mode 100644 index aa175690..00000000 --- a/app/frontend/websocket/handlers/__tests__/symbol-delete.test.js +++ /dev/null @@ -1,89 +0,0 @@ -/* eslint-disable global-require */ - -describe('symbol-delete.test.js', () => { - let mockWebSocketServer; - let mockWebSocketServerWebSocketSend; - - let cacheMock; - let mongoMock; - let loggerMock; - - beforeEach(() => { - jest.clearAllMocks().resetModules(); - - mockWebSocketServerWebSocketSend = jest.fn().mockResolvedValue(true); - - mockWebSocketServer = { - send: mockWebSocketServerWebSocketSend - }; - }); - - describe('delete symbol', () => { - beforeEach(async () => { - const { cache, logger, mongo } = require('../../../../helpers'); - cacheMock = cache; - mongoMock = mongo; - loggerMock = logger; - - cacheMock.hdel = jest.fn().mockResolvedValue(true); - mongoMock.deleteOne = jest.fn().mockResolvedValue(true); - cacheMock.hgetall = jest.fn().mockImplementation((_key, pattern) => { - if (pattern === 'trailing-trade-symbols:BTCUSDT*') { - return Promise.resolve({ - 'BTCUSDT-key-1': 'value1', - 'BTCUSDT-last-buy-price': 123 - }); - } - return Promise.resolve(null); - }); - - const { handleSymbolDelete } = require('../symbol-delete'); - await handleSymbolDelete(logger, mockWebSocketServer, { - data: { - symbolInfo: { - symbol: 'BTCUSDT' - } - } - }); - }); - - it('triggers cache.hdel', () => { - expect(cacheMock.hdel).toHaveBeenCalledWith( - 'trailing-trade-symbols', - 'BTCUSDT-key-1' - ); - expect(cacheMock.hdel).toHaveBeenCalledWith( - 'trailing-trade-symbols', - 'BTCUSDT-last-buy-price' - ); - }); - - it('triggers mongo.mock', () => { - expect(mongoMock.deleteOne).toHaveBeenCalledWith( - loggerMock, - 'trailing-trade-symbols', - { key: 'BTCUSDT-last-buy-price' } - ); - }); - - it('does not trigger cache.hdel with other symbols', () => { - expect(cacheMock.hdel).not.toHaveBeenCalledWith( - 'trailing-trade-symbols', - 'LTCUSDT-key-1' - ); - expect(cacheMock.hdel).not.toHaveBeenCalledWith( - 'trailing-trade-symbols', - 'LTCUSDT-key-2' - ); - }); - - it('triggers ws.send', () => { - expect(mockWebSocketServerWebSocketSend).toHaveBeenCalledWith( - JSON.stringify({ - result: true, - type: 'symbol-delete-result' - }) - ); - }); - }); -}); diff --git a/app/frontend/websocket/handlers/latest.js b/app/frontend/websocket/handlers/latest.js index b776d30e..bea819db 100644 --- a/app/frontend/websocket/handlers/latest.js +++ b/app/frontend/websocket/handlers/latest.js @@ -45,9 +45,13 @@ const handleLatest = async (logger, ws, payload) => { 'trailing-trade-common:', 'trailing-trade-common:*' ); - const cacheTradingView = await cache.hgetall( - 'trailing-trade-tradingview:', - 'trailing-trade-tradingview:*' + + const cacheTradingViews = _.map( + await cache.hgetall( + 'trailing-trade-tradingview:', + 'trailing-trade-tradingview:*' + ), + tradingView => JSON.parse(tradingView) ); const symbolsPerPage = 12; @@ -89,20 +93,15 @@ const handleLatest = async (logger, ws, payload) => { const stats = { symbols: await Promise.all( _.map(cacheTrailingTradeSymbols, async symbol => { - const newSymbol = { ...symbol }; - try { - newSymbol.tradingView = JSON.parse( - cacheTradingView[newSymbol.symbol] - ); - } catch (e) { - _.unset(newSymbol, 'tradingView'); - } - - // Retrieve action disabled - newSymbol.isActionDisabled = await isActionDisabled(newSymbol.symbol); + const newSymbol = { + ...symbol, + isActionDisabled: await isActionDisabled(symbol.symbol) + }; + return newSymbol; }) - ) + ), + tradingViews: cacheTradingViews }; const cacheTrailingTradeQuoteEstimates = diff --git a/app/frontend/websocket/handlers/symbol-delete.js b/app/frontend/websocket/handlers/symbol-delete.js deleted file mode 100644 index 7967ccf0..00000000 --- a/app/frontend/websocket/handlers/symbol-delete.js +++ /dev/null @@ -1,30 +0,0 @@ -const { cache, mongo } = require('../../../helpers'); - -const handleSymbolDelete = async (logger, ws, payload) => { - logger.info({ payload }, 'Start symbol delete'); - - const { data } = payload; - const { symbolInfo } = data; - const { symbol } = symbolInfo; - - const cacheValues = await cache.hgetall( - 'trailing-trade-symbols:', - `trailing-trade-symbols:${symbol}*` - ); - - await Promise.all( - Object.keys(cacheValues).map(key => - cache.hdel('trailing-trade-symbols', key) - ) - ); - - [`${symbol}-last-buy-price`].forEach(async key => { - await mongo.deleteOne(logger, 'trailing-trade-symbols', { key }); - }); - - await mongo.deleteOne(logger, 'trailing-trade-cache', { symbol }); - - ws.send(JSON.stringify({ result: true, type: 'symbol-delete-result' })); -}; - -module.exports = { handleSymbolDelete }; diff --git a/app/helpers/__tests__/slack.test.js b/app/helpers/__tests__/slack.test.js index 9f0452c7..046dc44e 100644 --- a/app/helpers/__tests__/slack.test.js +++ b/app/helpers/__tests__/slack.test.js @@ -75,6 +75,25 @@ describe('slack', () => { expect(axios.post).not.toHaveBeenCalled(); }); }); + + describe('when post throws an error', () => { + beforeEach(async () => { + axios.post.mockReset(); + + axios.post = jest + .fn() + .mockRejectedValue(new Error('something happened')); + + result = await slack.sendMessage('my message - something happened', { + symbol: 'BTCUSDT', + apiLimit: 10 + }); + }); + + it('returns expected value', () => { + expect(result).toStrictEqual({}); + }); + }); }); }); }); diff --git a/app/helpers/logger.js b/app/helpers/logger.js index dc5fe414..83d29297 100644 --- a/app/helpers/logger.js +++ b/app/helpers/logger.js @@ -8,7 +8,8 @@ const mongo = require('./mongo'); /* istanbul ignore next */ const fakeLogger = { child: _childData => ({ - info: (..._infoData) => {} + info: (..._infoData) => {}, + debug: (..._infoData) => {} }) }; diff --git a/app/helpers/mongo.js b/app/helpers/mongo.js index 45160d47..f5db2859 100644 --- a/app/helpers/mongo.js +++ b/app/helpers/mongo.js @@ -49,11 +49,11 @@ const count = async (funcLogger, collectionName, query) => { const collection = database.collection(collectionName); - logger.info({ query }, 'Finding document from MongoDB'); + logger.debug({ query }, 'Finding document from MongoDB'); const result = await collection.count(query); - logger.info({ result }, 'Found documents from MongoDB'); + logger.debug({ result }, 'Found documents from MongoDB'); return result; }; @@ -73,11 +73,11 @@ const findAll = async (funcLogger, collectionName, query, params = {}) => { const collection = database.collection(collectionName); - logger.info({ query, params }, 'Finding document from MongoDB'); + logger.debug({ query, params }, 'Finding document from MongoDB'); const result = await collection.find(query, params).toArray(); - logger.info({ result }, 'Found documents from MongoDB'); + logger.debug({ result }, 'Found documents from MongoDB'); return result; }; @@ -95,11 +95,11 @@ const aggregate = async (funcLogger, collectionName, query) => { const collection = database.collection(collectionName); - logger.info({ query }, 'Finding document from MongoDB'); + logger.debug({ query }, 'Finding document from MongoDB'); const result = await collection.aggregate(query).toArray(); - logger.info({ result }, 'Found documents from MongoDB'); + logger.debug({ result }, 'Found documents from MongoDB'); return result; }; @@ -118,9 +118,9 @@ const findOne = async (funcLogger, collectionName, query) => { const collection = database.collection(collectionName); - logger.info({ collectionName, query }, 'Finding document from MongoDB'); + logger.debug({ collectionName, query }, 'Finding document from MongoDB'); const result = await collection.findOne(query); - logger.info({ result }, 'Found document from MongoDB'); + logger.debug({ result }, 'Found document from MongoDB'); return result; }; @@ -139,7 +139,7 @@ const insertOne = async (funcLogger, collectionName, document) => { const collection = database.collection(collectionName); - logger.info({ collectionName, document }, 'Inserting document to MongoDB'); + logger.debug({ collectionName, document }, 'Inserting document to MongoDB'); const result = await collection.insertOne(document, { // https://docs.mongodb.com/v3.2/reference/write-concern/ writeConcern: { @@ -147,7 +147,7 @@ const insertOne = async (funcLogger, collectionName, document) => { j: false } }); - logger.info({ result }, 'Inserted document to MongoDB'); + logger.debug({ result }, 'Inserted document to MongoDB'); return result; }; @@ -167,7 +167,7 @@ const upsertOne = async (funcLogger, collectionName, filter, document) => { const collection = database.collection(collectionName); - logger.info( + logger.debug( { collectionName, filter, document }, 'Upserting document to MongoDB' ); @@ -183,7 +183,7 @@ const upsertOne = async (funcLogger, collectionName, filter, document) => { } } ); - logger.info({ result }, 'Upserted document to MongoDB'); + logger.debug({ result }, 'Upserted document to MongoDB'); return result; }; @@ -200,7 +200,7 @@ const upsertOne = async (funcLogger, collectionName, filter, document) => { const deleteAll = async (funcLogger, collectionName, filter) => { const logger = funcLogger.child({ helper: 'mongo', funcName: 'deleteAll' }); - logger.info({ collectionName, filter }, 'Deleting documents from MongoDB'); + logger.debug({ collectionName, filter }, 'Deleting documents from MongoDB'); const collection = database.collection(collectionName); const result = collection.deleteMany(filter, { // https://docs.mongodb.com/v3.2/reference/write-concern/ @@ -209,7 +209,7 @@ const deleteAll = async (funcLogger, collectionName, filter) => { j: false } }); - logger.info({ result }, 'Deleted documents from MongoDB'); + logger.debug({ result }, 'Deleted documents from MongoDB'); return result; }; @@ -226,7 +226,7 @@ const deleteAll = async (funcLogger, collectionName, filter) => { const deleteOne = async (funcLogger, collectionName, filter) => { const logger = funcLogger.child({ helper: 'mongo', funcName: 'deleteOne' }); - logger.info({ collectionName, filter }, 'Deleting document from MongoDB'); + logger.debug({ collectionName, filter }, 'Deleting document from MongoDB'); const collection = database.collection(collectionName); const result = collection.deleteOne(filter, { // https://docs.mongodb.com/v3.2/reference/write-concern/ @@ -235,7 +235,7 @@ const deleteOne = async (funcLogger, collectionName, filter) => { j: false } }); - logger.info({ result }, 'Deleted document from MongoDB'); + logger.debug({ result }, 'Deleted document from MongoDB'); return result; }; @@ -252,10 +252,13 @@ const deleteOne = async (funcLogger, collectionName, filter) => { const createIndex = async (funcLogger, collectionName, keys, options) => { const logger = funcLogger.child({ helper: 'mongo', funcName: 'createIndex' }); - logger.info({ collectionName, keys, options }, 'Creating index from MongoDB'); + logger.debug( + { collectionName, keys, options }, + 'Creating index from MongoDB' + ); const collection = database.collection(collectionName); const result = collection.createIndex(keys, options); - logger.info({ result }, 'Created index from MongoDB'); + logger.debug({ result }, 'Created index from MongoDB'); return result; }; @@ -271,10 +274,10 @@ const createIndex = async (funcLogger, collectionName, keys, options) => { const dropIndex = async (funcLogger, collectionName, indexName) => { const logger = funcLogger.child({ helper: 'mongo', funcName: 'dropIndex' }); - logger.info({ collectionName, indexName }, 'Dropping index from MongoDB'); + logger.debug({ collectionName, indexName }, 'Dropping index from MongoDB'); const collection = database.collection(collectionName); const result = collection.dropIndex(indexName); - logger.info({ result }, 'Dropped index from MongoDB'); + logger.debug({ result }, 'Dropped index from MongoDB'); return result; }; @@ -293,7 +296,7 @@ const bulkWrite = async (funcLogger, collectionName, operations) => { const collection = database.collection(collectionName); - logger.info( + logger.debug( { collectionName, operations }, 'Bulk writing documents to MongoDB' ); @@ -304,7 +307,7 @@ const bulkWrite = async (funcLogger, collectionName, operations) => { }, ordered: false }); - logger.info({ result }, 'Finished bulk writing documents to MongoDB'); + logger.debug({ result }, 'Finished bulk writing documents to MongoDB'); return result; }; diff --git a/app/helpers/slack.js b/app/helpers/slack.js index 25348f05..c71d69ef 100644 --- a/app/helpers/slack.js +++ b/app/helpers/slack.js @@ -12,10 +12,10 @@ const lastMessages = {}; * * @param {*} text */ -const sendMessage = (text, params = {}) => { +const sendMessage = async (text, params = {}) => { if (_.get(params, 'symbol', '') !== '') { if (_.get(lastMessages, `${params.symbol}.message`, '') === text) { - return Promise.resolve({}); + return {}; } lastMessages[params.symbol] = { message: text }; @@ -32,15 +32,26 @@ const sendMessage = (text, params = {}) => { ); if (config.get('slack.enabled') !== true) { - return Promise.resolve({}); + return {}; } - return axios.post(config.get('slack.webhookUrl'), { - channel: config.get('slack.channel'), - username: `${config.get('slack.username')} - ${config.get('mode')}`, - type: 'mrkdwn', - text: formattedText - }); + try { + const response = await axios.post(config.get('slack.webhookUrl'), { + channel: config.get('slack.channel'), + username: `${config.get('slack.username')} - ${config.get('mode')}`, + type: 'mrkdwn', + text: formattedText + }); + + return response; + } catch (e) { + logger.error( + { tag: 'slack-send-message', e }, + 'Error occurred while sending slack message.' + ); + + return {}; + } }; module.exports = { sendMessage }; diff --git a/app/scripts/tradingview-test.js b/app/scripts/tradingview-test.js new file mode 100644 index 00000000..071b58db --- /dev/null +++ b/app/scripts/tradingview-test.js @@ -0,0 +1,35 @@ +/* istanbul ignore file */ +const qs = require('qs'); +const _ = require('lodash'); +const config = require('config'); +const axios = require('axios'); +const { logger } = require('../helpers'); + +(async () => { + const tradingviewUrl = `http://${config.get('tradingview.host')}:${config.get( + 'tradingview.port' + )}`; + + const symbols = ['BTCUSDT', 'BNBUSDT']; + + const params = { + symbols: symbols.reduce((acc, s) => { + acc.push(`BINANCE:${s}`); + return acc; + }, []), + screener: 'CRYPTO', + interval: '30m' + }; + + const response = await axios.get(tradingviewUrl, { + params, + paramsSerializer: + /* istanbul ignore next */ + p => qs.stringify(p, { arrayFormat: 'repeat' }), + timeout: 20000 // timeout 20 seconds + }); + const tradingViewResult = _.get(response.data, 'result', {}); + + logger.info({ tradingViewResult }, 'TradingView result'); + process.exit(0); +})(); diff --git a/app/server-cronjob.js b/app/server-cronjob.js index 5c3e019a..77ec98d9 100644 --- a/app/server-cronjob.js +++ b/app/server-cronjob.js @@ -3,7 +3,11 @@ const config = require('config'); const { CronJob } = require('cron'); const { maskConfig } = require('./cronjob/trailingTradeHelper/util'); -const { executeAlive, executeTrailingTradeIndicator } = require('./cronjob'); +const { + executeAlive, + executeTrailingTradeIndicator, + executeTradingView +} = require('./cronjob'); const fulfillWithTimeLimit = async (logger, timeLimit, task, failureValue) => { let timeout; @@ -40,10 +44,13 @@ const runCronjob = async serverLogger => { // Execute jobs [ { jobName: 'alive', executeJob: executeAlive }, - // { jobName: 'trailingTrade', executeJob: executeTrailingTrade }, { jobName: 'trailingTradeIndicator', executeJob: executeTrailingTradeIndicator + }, + { + jobName: 'tradingView', + executeJob: executeTradingView } ].forEach(job => { const { jobName, executeJob } = job; diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 5bb89a1e..e163d66b 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -152,23 +152,6 @@ "__name": "BINANCE_JOBS_TRAILING_TRADE_BOT_OPTIONS_AUTO_TRIGGER_BY_CONDITIONS_AFTER_DISABLED_PERIOD", "__description": "Set a boolean to reschedule the auto-buy trigger action if the action is currently disabled by the stop-loss or other actions.", "__format": "boolean" - }, - "tradingView": { - "overrideInterval": { - "__name": "BINANCE_JOBS_TRAILING_TRADE_BOT_OPTIONS_AUTO_TRIGGER_BY_CONDITIONS_TRADINGVIEW_OVERRIDE_INTERVAL", - "__description": "Override TradingView candle interval while attempting auto trigger buy. If empty, it will use TradingView -> interval. Available interval: 1m, 5m, 15m, 30m, 1h, 2h, 4h, 1d", - "__format": "boolean" - }, - "whenStrongBuy": { - "__name": "BINANCE_JOBS_TRAILING_TRADE_BOT_OPTIONS_AUTO_TRIGGER_BY_CONDITIONS_TRADINGVIEW_WHEN_STRONG_BUY", - "__description": "Set a boolean to trigger the auto-buy trigger action if the TradingView recommendation is `Strong buy`. If the recommendation is not `Strong buy`, then the bot will re-schedule the auto-buy trigger action.", - "__format": "boolean" - }, - "whenBuy": { - "__name": "BINANCE_JOBS_TRAILING_TRADE_BOT_OPTIONS_AUTO_TRIGGER_BY_CONDITIONS_TRADINGVIEW_WHEN_BUY", - "__description": "Set a boolean to trigger the auto-buy trigger action if the TradingView recommendation is `Buy`. If the recommendation is not `Buy`, then the bot will re-schedule the auto-buy trigger action.", - "__format": "boolean" - } } } }, @@ -189,12 +172,12 @@ "__format": "number" } }, - "tradingView": { - "interval": { - "__name": "BINANCE_JOBS_TRAILING_TRADE_BOT_OPTIONS_TRADING_VIEW_INTERVAL", - "__description": "Set the interval to retrieve TradingView technical analysis. If empty, it will use candles -> interval. Available interval: 1m, 5m, 15m, 30m, 1h, 2h, 4h, 1d", - "__format": "string" - }, + "tradingViews": { + "__name": "BINANCE_JOBS_TRAILING_TRADE_BOT_OPTIONS_TRADING_VIEWS", + "__description": "Set TradingView interval configurations.", + "__format": "json" + }, + "tradingViewOptions": { "useOnlyWithin": { "__name": "BINANCE_JOBS_TRAILING_TRADE_BOT_OPTIONS_TRADING_VIEW_USE_ONLY_WITHIN", "__description": "Set the minutes to allow to use TradingView technical analysis data. If the data is older than configured minutes, the bot will ignore the TradingView technical analysis data. This is due to avoid the issue with TradingView downtime.", @@ -254,18 +237,6 @@ "__name": "BINANCE_JOBS_TRAILING_TRADE_BUY_ATH_RESTRICTION_RESTRICTION_PERCENTAGE", "__format": "number" } - }, - "tradingView": { - "whenStrongBuy": { - "__name": "BINANCE_JOBS_TRAILING_TRADE_BUY_TRADINGVIEW_WHEN_STRONG_BUY", - "__description": "Set a boolean to use TradingView recommendation to trigger the buy. If the buy trigger price is reached, the bot will check TradingView recommendation and if it is not `Strong buy`, then the bot will not place a buy order.", - "__format": "boolean" - }, - "whenBuy": { - "__name": "BINANCE_JOBS_TRAILING_TRADE_BUY_TRADINGVIEW_WHEN_BUY", - "__description": "Set a boolean to use TradingView recommendation to trigger the buy. If the buy trigger price is reached, the bot will check TradingView recommendation and if it is not `Buy`, then the bot will not place a buy order.", - "__format": "boolean" - } } }, "sell": { @@ -300,25 +271,6 @@ }, "orderType": "BINANCE_JOBS_TRAILING_TRADE_SELL_STOP_LOSS_ORDER_TYPE" }, - "tradingView": { - "forceSellOverZeroBelowTriggerPrice": { - "whenNeutral": { - "__name": "BINANCE_JOBS_TRAILING_TRADE_SELL_TRADING_VIEW_FORCE_SELL_OVER_ZERO_BELOW_TRIGGER_PRICE_WHEN_NEUTRAL", - "__description": "Set a boolean to use TradingView recommendation to sell the coin at the market price if the profit is over 0 but under the trigger price. When the condition is met and the TradingView recommendation is `Neutral`, then the bot will place a market sell order immediately. If the auto-buy trigger is enabled, then it will place a buy order later. Note that this action can cause loss if the profit is less than commission.", - "__format": "boolean" - }, - "whenSell": { - "__name": "BINANCE_JOBS_TRAILING_TRADE_SELL_TRADING_VIEW_FORCE_SELL_OVER_ZERO_BELOW_TRIGGER_PRICE_WHEN_SELL", - "__description": "Set a boolean to use TradingView recommendation to sell the coin at the market price if the profit is over 0 but under the trigger price. When the condition is met and the TradingView recommendation is `Sell`, then the bot will place a market sell order immediately. If the auto-buy trigger is enabled, then it will place a buy order later. Note that this action can cause loss if the profit is less than commission.", - "__format": "boolean" - }, - "whenStrongSell": { - "__name": "BINANCE_JOBS_TRAILING_TRADE_SELL_TRADING_VIEW_FORCE_SELL_OVER_ZERO_BELOW_TRIGGER_PRICE_WHEN_STRONG_SELL", - "__description": "Set a boolean to use TradingView recommendation to sell the coin at the market price if the profit is over 0 but under the trigger price. When the condition is met and the TradingView recommendation is `Strong sell`, then the bot will place a market sell order immediately. If the auto-buy trigger is enabled, then it will place a buy order later. Note that this action can cause loss if the profit is less than commission.", - "__format": "boolean" - } - } - }, "conservativeMode": { "enabled": { "__name": "BINANCE_JOBS_TRAILING_TRADE_SELL_CONSERVATIVE_MODE_ENABLED", @@ -366,6 +318,13 @@ "__format": "boolean" }, "cronTime": "BINANCE_JOBS_TRAILING_TRADE_INDICATOR_CRON_TIME" + }, + "tradingView": { + "enabled": { + "__name": "BINANCE_JOBS_TRADING_VIEW_ENABLED", + "__format": "boolean" + }, + "cronTime": "BINANCE_JOBS_TRADING_VIEW_INDICATOR_CRON_TIME" } } } diff --git a/config/default.json b/config/default.json index 3099c102..49bbbe16 100644 --- a/config/default.json +++ b/config/default.json @@ -71,12 +71,7 @@ "triggerAfter": 20, "conditions": { "whenLessThanATHRestriction": true, - "afterDisabledPeriod": true, - "tradingView": { - "overrideInterval": "", - "whenStrongBuy": true, - "whenBuy": true - } + "afterDisabledPeriod": true } }, "orderLimit": { @@ -84,8 +79,23 @@ "maxBuyOpenOrders": 3, "maxOpenTrades": 5 }, - "tradingView": { - "interval": "", + "tradingViews": [ + { + "interval": "1h", + "buy": { + "whenStrongBuy": true, + "whenBuy": true + }, + "sell": { + "forceSellOverZeroBelowTriggerPrice": { + "whenNeutral": false, + "whenSell": false, + "whenStrongSell": false + } + } + } + ], + "tradingViewOptions": { "useOnlyWithin": 5, "ifExpires": "ignore" }, @@ -119,10 +129,6 @@ "limit": 30 }, "restrictionPercentage": 0.9 - }, - "tradingView": { - "whenStrongBuy": true, - "whenBuy": true } }, "sell": { @@ -142,13 +148,6 @@ "disableBuyMinutes": 360, "orderType": "market" }, - "tradingView": { - "forceSellOverZeroBelowTriggerPrice": { - "whenNeutral": false, - "whenSell": false, - "whenStrongSell": false - } - }, "conservativeMode": { "enabled": false, "factor": 0.5 @@ -165,6 +164,10 @@ "trailingTradeIndicator": { "enabled": true, "cronTime": "* * * * * *" + }, + "tradingView": { + "enabled": true, + "cronTime": "* * * * * *" } } } diff --git a/docker-compose.rpi.yml b/docker-compose.rpi.yml index 29a2b187..e1e3ed54 100644 --- a/docker-compose.rpi.yml +++ b/docker-compose.rpi.yml @@ -15,6 +15,7 @@ services: - BINANCE_REDIS_HOST=binance-redis - BINANCE_REDIS_PORT=6379 - BINANCE_REDIS_PASSWORD=secretp422 + - BINANCE_LOG_LEVEL=INFO ports: - 8080:80 logging: diff --git a/docker-compose.server.yml b/docker-compose.server.yml index 514b2a16..ec6e9b67 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -15,6 +15,7 @@ services: - BINANCE_REDIS_HOST=binance-redis - BINANCE_REDIS_PORT=6379 - BINANCE_REDIS_PASSWORD=secretp422 + - BINANCE_LOG_LEVEL=INFO ports: - 8080:80 logging: diff --git a/docker-compose.yml b/docker-compose.yml index 600370df..702a5355 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: - BINANCE_REDIS_HOST=binance-redis - BINANCE_REDIS_PORT=6379 - BINANCE_REDIS_PASSWORD=secretp422 + - BINANCE_LOG_LEVEL=DEBUG - BINANCE_TRADINGVIEW_HOST=tradingview - BINANCE_TRADINGVIEW_PORT=8082 ports: diff --git a/migrations/1626509477518-migrate-grid-trade.js b/migrations/1626509477518-migrate-grid-trade.js index 4c272162..d631f4d6 100644 --- a/migrations/1626509477518-migrate-grid-trade.js +++ b/migrations/1626509477518-migrate-grid-trade.js @@ -156,20 +156,21 @@ module.exports.up = async next => { key: 'configuration' } ); - - // Migrate global configuration - const newGlobalConfiguration = migrateGlobalConfiguration( - logger, - globalConfiguration - ); - - // Update global configuration - await mongo.upsertOne( - logger, - 'trailing-trade-common', - { key: 'configuration' }, - { key: 'configuration', ...newGlobalConfiguration } - ); + if (globalConfiguration) { + // Migrate global configuration + const newGlobalConfiguration = migrateGlobalConfiguration( + logger, + globalConfiguration + ); + + // Update global configuration + await mongo.upsertOne( + logger, + 'trailing-trade-common', + { key: 'configuration' }, + { key: 'configuration', ...newGlobalConfiguration } + ); + } logger.info('Finish migration'); diff --git a/migrations/1664194720480-add-multiple-tradingviews.js b/migrations/1664194720480-add-multiple-tradingviews.js new file mode 100644 index 00000000..39fd035f --- /dev/null +++ b/migrations/1664194720480-add-multiple-tradingviews.js @@ -0,0 +1,113 @@ +const _ = require('lodash'); +const path = require('path'); +const { logger: rootLogger, mongo, cache } = require('../app/helpers'); + +const migrateConfiguration = (_logger, configuration) => { + const tradingView = { + interval: _.get(configuration, 'botOptions.tradingView.interval') || '1h', + buy: { + whenStrongBuy: _.get(configuration, 'buy.tradingView.whenStrongBuy'), + whenBuy: _.get(configuration, 'buy.tradingView.whenBuy') + }, + sell: { + forceSellOverZeroBelowTriggerPrice: { + whenNeutral: _.get( + configuration, + 'sell.tradingView.forceSellOverZeroBelowTriggerPrice.whenNeutral' + ), + whenSell: _.get( + configuration, + 'sell.tradingView.forceSellOverZeroBelowTriggerPrice.whenSell' + ), + whenStrongSell: _.get( + configuration, + 'sell.tradingView.forceSellOverZeroBelowTriggerPrice.whenStrongSell' + ) + } + } + }; + + const tradingViewOptions = { + useOnlyWithin: + _.get(configuration, 'botOptions.tradingView.useOnlyWithin') || 5, + ifExpires: + _.get(configuration, 'botOptions.tradingView.ifExpires') || 'ignore' + }; + + return _.chain(configuration) + .omit('botOptions.autoTriggerBuy.conditions.tradingView') + .omit('sell.tradingView') + .omit('buy.tradingView') + .omit('botOptions.tradingView') + .set('botOptions.tradingViews', [tradingView]) + .set('botOptions.tradingViewOptions', tradingViewOptions) + .value(); +}; + +module.exports.up = async () => { + const logger = rootLogger.child({ + gitHash: process.env.GIT_HASH || 'unspecified', + migration: path.basename(__filename) + }); + + await mongo.connect(logger); + + logger.info('Start migration'); + + // Get global configuration + const globalConfiguration = await mongo.findOne( + logger, + 'trailing-trade-common', + { + key: 'configuration' + } + ); + + if (globalConfiguration) { + if (_.get(globalConfiguration, 'botOptions.tradingViews')) { + logger.warn('Configuration seems already migrated. Skip migration.'); + return; + } + + const migratedGlobalConfiguration = migrateConfiguration( + logger, + globalConfiguration + ); + + await mongo.upsertOne( + logger, + 'trailing-trade-common', + { key: 'configuration' }, + { key: 'configuration', ...migratedGlobalConfiguration } + ); + } + + const migratedSymbolConfigurations = ( + (await mongo.findAll(logger, 'trailing-trade-symbols', { + key: { $regex: /-configuration/ } + })) || [] + ).map(configuration => migrateConfiguration(logger, configuration)); + + await Promise.all( + migratedSymbolConfigurations.map(configuration => + mongo.upsertOne( + logger, + 'trailing-trade-symbols', + { key: configuration.key }, + { key: configuration.key, ...configuration } + ) + ) + ); + + // Delete all cache. + await mongo.deleteAll(logger, 'trailing-trade-cache', {}); + + await cache.hdelall('trailing-trade-configurations:*'); + await cache.hdelall('trailing-trade-tradingview:*'); + + logger.info('Finish migration'); +}; + +module.exports.down = next => { + next(); +}; diff --git a/package.json b/package.json index e92472cc..63579903 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "docker:build:tradingview:prod": "docker build ./tradingview -t chrisleekr/binance-trading-bot:tradingview", "docker:build:bot:dev": "docker build . --build-arg PACKAGE_VERSION=$(node -p \"require('./package.json').version\") --build-arg GIT_HASH=$(git rev-parse --short HEAD) --build-arg NODE_ENV=development --target dev-stage -t chrisleekr/binance-trading-bot:latest", "docker:build:tradingview:dev": "docker build ./tradingview -t chrisleekr/binance-trading-bot:tradingview", + "docker:buildx": "npm run docker:buildx:bot:prod && npm run docker:buildx:tradingview:prod", + "docker:buildx:bot:prod": "docker buildx build --platform=linux/amd64,linux/arm64 . --build-arg PACKAGE_VERSION=$(node -p \"require('./package.json').version\") --build-arg GIT_HASH=$(git rev-parse --short HEAD) --build-arg NODE_ENV=production --target production-stage -t chrisleekr/binance-trading-bot:latest", + "docker:buildx:tradingview:prod": "docker buildx build --platform=linux/amd64,linux/arm64 ./tradingview -t chrisleekr/binance-trading-bot:tradingview", "migrate:create": "./node_modules/.bin/migrate create", "migrate:up": "./node_modules/.bin/migrate up --store=/srv/mongo-state-storage.js", "migrate:down": "./node_modules/.bin/migrate down --store=/srv/mongo-state-storage.js" diff --git a/public/css/App.css b/public/css/App.css index e62c130b..048a9af0 100644 --- a/public/css/App.css +++ b/public/css/App.css @@ -1088,3 +1088,28 @@ input[type='number'] { /** End: Dropzone **/ + +/** + Start: TradingView +**/ +.coin-info-tradingview-name { + width: 40%; +} + +.coin-info-tradingview-recommend { + width: 10%; +} + +.btn-add-new-tradingview { + background-color: #02c076; + color: white; +} + +.btn-add-new-grid-trade-buy:hover { + box-shadow: none; + color: white; + background-color: #2ed191; +} +/** + End: TradingView +**/ diff --git a/public/index.html b/public/index.html index d1b545b8..ef93bcd8 100644 --- a/public/index.html +++ b/public/index.html @@ -127,6 +127,7 @@ + @@ -139,9 +140,22 @@ + + + + + @@ -162,6 +176,20 @@ + + + + + +