From 0866a662039a4423371302192d79b8d124823fbd Mon Sep 17 00:00:00 2001 From: Juan Calvo Date: Sun, 26 Dec 2021 15:13:54 +0100 Subject: [PATCH 1/2] add: refactor backtesting to a different fork This commit updates backtesting to be executed in a different fork, it allows to start up several different executions independently, either by different tabs or even different pairs at the same time. In the future, this process could be persisted in database with the intention of running long backtestings of years of information on several dozen pairs. The exclusive locking had to be disabled so that each fork also has access to transact with the database. It would be interesting to move it to a Postgres, including a potential docker file --- src/command/backtest.js | 23 ++++++ src/modules/backtest.js | 43 +++++++---- src/modules/http.js | 71 ++++++++++++++----- src/modules/services.js | 2 +- templates/backtest-pending-results.html.twig | 65 +++++++++++++++++ templates/backtest_submit.html.twig | 14 ++-- templates/components/backtest_table.html.twig | 10 +-- 7 files changed, 183 insertions(+), 45 deletions(-) create mode 100644 src/command/backtest.js create mode 100644 templates/backtest-pending-results.html.twig diff --git a/src/command/backtest.js b/src/command/backtest.js new file mode 100644 index 000000000..cb763e5b2 --- /dev/null +++ b/src/command/backtest.js @@ -0,0 +1,23 @@ +const services = require('../modules/services'); + +process.on('message', async msg => { + const p = msg.pair.split('.'); + + const results = await services + .getBacktest() + .getBacktestResult( + msg.tickIntervalInMinutes, + msg.hours, + msg.strategy, + msg.candlePeriod, + p[0], + p[1], + msg.options, + msg.initialCapital, + msg.projectDir + ); + + process.send({ + results: results + }); +}); diff --git a/src/modules/backtest.js b/src/modules/backtest.js index 974a4e351..c4805dc00 100644 --- a/src/modules/backtest.js +++ b/src/modules/backtest.js @@ -44,7 +44,20 @@ module.exports = class Backtest { }); } - getBacktestResult(tickIntervalInMinutes, hours, strategy, candlePeriod, exchange, pair, options, initial_capital) { + getBacktestResult( + tickIntervalInMinutes, + hours, + strategy, + candlePeriod, + exchange, + pair, + options, + initialCapital, + projectDir + ) { + if (projectDir) { + this.projectDir = projectDir; + } return new Promise(async resolve => { const start = moment() .startOf('hour') @@ -186,7 +199,7 @@ module.exports = class Backtest { }; }); - const backtestSummary = await this.getBacktestSummary(signals, initial_capital); + const backtestSummary = await this.getBacktestSummary(signals, initialCapital); resolve({ summary: backtestSummary, rows: rows.slice().reverse(), @@ -205,10 +218,10 @@ module.exports = class Backtest { }); } - getBacktestSummary(signals, initial_capital) { - return new Promise(async resolve => { - const initialCapital = Number(initial_capital); // 1000 $ Initial Capital - let workingCapital = initialCapital; // Capital that changes after every trade + getBacktestSummary(signals, initialCapital) { + return new Promise(resolve => { + const initialCapitalNumber = Number(initialCapital); // 1000 $ Initial Capital + let workingCapital = initialCapitalNumber; // Capital that changes after every trade let lastPosition; // Holds Info about last action @@ -227,17 +240,17 @@ module.exports = class Backtest { // Iterate over all the signals for (let s = 0; s < signals.length; s++) { const signalObject = signals[s]; - const signalType = signalObject.result._signal; // Can be long,short,close + const signalType = signalObject.result.getSignal(); // Can be long,short,close // When a trade is closed - if (signalType == 'close') { + if (signalType === 'close') { // Increment the total trades counter trades.total += 1; // Entry Position Details - const entrySignalType = lastPosition.result._signal; // Long or Short + const entrySignalType = lastPosition.result.getSignal(); // Long or Short const entryPrice = lastPosition.price; // Price during the trade entry - const tradedQuantity = Number((workingCapital / entryPrice)); // Quantity + const tradedQuantity = Number(workingCapital / entryPrice); // Quantity // Exit Details const exitPrice = signalObject.price; // Price during trade exit @@ -247,7 +260,7 @@ module.exports = class Backtest { let pnlValue = 0; // Profit or Loss Value // When the position is Long - if (entrySignalType == 'long') { + if (entrySignalType === 'long') { if (exitPrice > entryPrice) { // Long Trade is Profitable trades.profitableCount += 1; @@ -255,7 +268,7 @@ module.exports = class Backtest { // Set the PNL pnlValue = exitValue - workingCapital; - } else if (entrySignalType == 'short') { + } else if (entrySignalType === 'short') { if (exitPrice < entryPrice) { // Short Trade is Profitable trades.profitableCount += 1; @@ -276,7 +289,7 @@ module.exports = class Backtest { // Update Working Cap workingCapital += pnlValue; - } else if (signalType == 'long' || signalType == 'short') { + } else if (signalType === 'long' || signalType === 'short') { // Enter into a position lastPosition = signalObject; } @@ -309,7 +322,7 @@ module.exports = class Backtest { // -- End of Sharpe Ratio Calculation // Net Profit - const netProfit = Number((((workingCapital - initialCapital) / initialCapital) * 100).toFixed(2)); + const netProfit = Number((((workingCapital - initialCapitalNumber) / initialCapitalNumber) * 100).toFixed(2)); trades.profitabilityPercent = Number(((trades.profitableCount * 100) / trades.total).toFixed(2)); @@ -317,7 +330,7 @@ module.exports = class Backtest { sharpeRatio: sharpeRatio, averagePNLPercent: averagePNLPercent, netProfit: netProfit, - initialCapital: initialCapital, + initialCapital: initialCapitalNumber, finalCapital: Number(workingCapital.toFixed(2)), trades: trades }; diff --git a/src/modules/http.js b/src/modules/http.js index c176b1053..d73cd9806 100644 --- a/src/modules/http.js +++ b/src/modules/http.js @@ -5,8 +5,12 @@ const auth = require('basic-auth'); const cookieParser = require('cookie-parser'); const crypto = require('crypto'); const moment = require('moment'); +const { fork } = require('child_process'); const OrderUtil = require('../utils/order_util'); +const backtestPendingPairs = {}; +const backtestResults = {}; + module.exports = class Http { constructor( systemUtil, @@ -92,7 +96,13 @@ module.exports = class Http { strict_variables: false }); - app.use(express.urlencoded({ limit: '12mb', extended: true, parameterLimit: 50000 })); + app.use( + express.urlencoded({ + limit: '12mb', + extended: true, + parameterLimit: 50000 + }) + ); app.use(cookieParser()); app.use(compression()); app.use(express.static(`${this.projectDir}/web/static`, { maxAge: 3600000 * 24 })); @@ -136,29 +146,52 @@ module.exports = class Http { pairs = [pairs]; } - const asyncs = pairs.map(pair => { - return async () => { - const p = pair.split('.'); + const key = moment().unix(); + + backtestPendingPairs[key] = []; + backtestResults[key] = []; + + pairs.forEach(pair => { + backtestPendingPairs[key].push(pair); + + const forked = fork('src/command/backtest.js'); - return { + forked.send({ + pair, + tickIntervalInMinutes: parseInt(req.body.ticker_interval, 10), + hours: req.body.hours, + strategy: req.body.strategy, + candlePeriod: req.body.candle_period, + options: req.body.options ? JSON.parse(req.body.options) : {}, + initialCapital: req.body.initial_capital, + projectDir: this.projectDir + }); + + forked.on('message', msg => { + backtestPendingPairs[key].splice(backtestPendingPairs[key].indexOf(pair), 1); + backtestResults[key].push({ pair: pair, - result: await this.backtest.getBacktestResult( - parseInt(req.body.ticker_interval, 10), - req.body.hours, - req.body.strategy, - req.body.candle_period, - p[0], - p[1], - req.body.options ? JSON.parse(req.body.options) : {}, - req.body.initial_capital - ) - }; - }; + result: msg.results + }); + }); }); - const backtests = await Promise.all(asyncs.map(fn => fn())); + res.render('../templates/backtest-pending-results.html.twig', { + key: key + }); + }); + + app.get('/backtest/:backtestKey', async (req, res) => { + res.send({ + ready: + backtestPendingPairs[req.params.backtestKey] === undefined + ? false + : backtestPendingPairs[req.params.backtestKey].length === 0 + }); + }); - // single details view + app.get('/backtest/result/:backtestKey', (req, res) => { + const backtests = backtestResults[req.params.backtestKey]; if (backtests.length === 1) { res.render('../templates/backtest_submit.html.twig', backtests[0].result); return; diff --git a/src/modules/services.js b/src/modules/services.js index 95040b276..d90f749a8 100644 --- a/src/modules/services.js +++ b/src/modules/services.js @@ -155,7 +155,7 @@ module.exports = { myDb.pragma('journal_mode = WAL'); myDb.pragma('SYNCHRONOUS = 1;'); - myDb.pragma('LOCKING_MODE = EXCLUSIVE;'); + // myDb.pragma('LOCKING_MODE = EXCLUSIVE;'); return (db = myDb); }, diff --git a/templates/backtest-pending-results.html.twig b/templates/backtest-pending-results.html.twig new file mode 100644 index 000000000..cdcb326c0 --- /dev/null +++ b/templates/backtest-pending-results.html.twig @@ -0,0 +1,65 @@ +{% extends './layout.html.twig' %} + +{% block title %}Backtesting | Crypto Bot{% endblock %} + +{% block content %} + +
+ +
+
+
+
+

Backtesting

+
+
+ +
+
+
+
+ + + +
+
+

Waiting results for backtest id {{ key }}

+
+
+ +
+ + +{% endblock %} + +{% block javascript %} + + + + +{% endblock %} + +{% block stylesheet %} + +{% endblock %} diff --git a/templates/backtest_submit.html.twig b/templates/backtest_submit.html.twig index adcb33512..6a536385f 100644 --- a/templates/backtest_submit.html.twig +++ b/templates/backtest_submit.html.twig @@ -45,7 +45,7 @@ - +

Chart

@@ -83,10 +83,14 @@
- {% include 'components/backtest_table.html.twig' with { - 'rows': rows, - 'extra_fields': extra_fields, - } only %} + {% if rows|length > 1000 %} + {% include 'components/backtest_table.html.twig' with { + 'rows': rows, + 'extra_fields': extra_fields, + } only %} + {% else %} + Too many rows detected, rendering process skipped. + {% endif %}
diff --git a/templates/components/backtest_table.html.twig b/templates/components/backtest_table.html.twig index aa1dc6437..146160797 100644 --- a/templates/components/backtest_table.html.twig +++ b/templates/components/backtest_table.html.twig @@ -21,7 +21,7 @@ {{ row.price|default }} {% if row.profit is defined %} - {{ row.profit|round(2) }} % + {{ row.profit|round(2) }} % {% endif %} {% if row.lastPriceClosed is defined %} @@ -30,11 +30,11 @@ {% if row.result is defined %} - {% if row.result.signal == 'long' %} + {% if row.result._signal == 'long' %} - {% elseif row.result.signal == 'short' %} + {% elseif row.result._signal == 'short' %} - {% elseif row.result.signal == 'close' %} + {% elseif row.result._signal == 'close' %} {% endif %} {% endif %} @@ -76,4 +76,4 @@ {% endfor %} - \ No newline at end of file + From b4c897261248c10631889ffd3f72096487330c04 Mon Sep 17 00:00:00 2001 From: Juan Calvo Date: Tue, 28 Dec 2021 13:39:25 +0100 Subject: [PATCH 2/2] add: Calculate fees on backtest This change adds calculation of fees during backtesting increasing the accuracy of the test. --- README.md | 14 ++++++++++ instance.js.dist | 4 +++ src/command/backtest.js | 2 ++ src/modules/backtest.js | 27 ++++++++++++------- .../components/backtest_summary.html.twig | 8 ++++++ 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 07fd5b0aa..d44a8a723 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,7 @@ c.symbols = [ symbol: 'ETHUSDT', exchange: 'binance_futures', periods: ['1m', '15m', '1h'], + feesPerTrade: 0.04, trade: { currency_capital: 10, strategies: [ @@ -369,6 +370,7 @@ Per pair you can set used margin before orders are created; depending on exchang c.symbols.push({ 'symbol': 'BTCUSD', 'exchange': 'bitmex', + 'feesPerTrade': 0.05, 'extra': { 'bitmex_leverage': 5, }, @@ -377,12 +379,24 @@ Per pair you can set used margin before orders are created; depending on exchang c.symbols.push({ 'symbol': 'EOSUSD', 'exchange': 'bybit', + 'feesPerTrade': 0.075, 'extra': { 'bybit_leverage': 5, }, }) ``` +### Fees + +Fees can be configured on `instance.js` file including the field `feesPerTrade`, it is important to +mention that prices of fees are for Taker, not for Maker + +For example: + +Binance futures: has a Maker/Taker 0.0200%/0.0400%. + +'feesPerTrade': 0.04, + ## Tools ### Fill data diff --git a/instance.js.dist b/instance.js.dist index 621c0854f..1600a0630 100644 --- a/instance.js.dist +++ b/instance.js.dist @@ -94,6 +94,7 @@ c.init = () => { 'periods': ['1m', '15m', '1h'], 'exchange': 'binance', 'state': 'watch', + 'feesPerTrade': 0.04, 'watchdogs': [ { 'name': 'stoploss_watch', @@ -144,6 +145,7 @@ y.forEach((pair) => { 'symbol': pair, 'periods': ['1m', '15m', '1h'], 'exchange': 'bitmex', + 'feesPerTrade': 0.1, 'state': 'watch', 'extra': { 'bitmex_leverage': 5, @@ -193,6 +195,7 @@ l.forEach((pair) => { 'symbol': pair, 'periods': ['1m', '15m', '1h'], 'exchange': 'bitmex_testnet', + 'feesPerTrade': 0.1, 'state': 'watch', 'watchdogs': [ { @@ -235,6 +238,7 @@ z.forEach((pair) => { 'symbol': pair, 'periods': ['1m', '15m', '1h'], 'exchange': 'binance', + 'feesPerTrade': 0.1, 'state': 'watch', 'strategies': [ { diff --git a/src/command/backtest.js b/src/command/backtest.js index cb763e5b2..66ed827fd 100644 --- a/src/command/backtest.js +++ b/src/command/backtest.js @@ -3,6 +3,8 @@ const services = require('../modules/services'); process.on('message', async msg => { const p = msg.pair.split('.'); + services.boot(msg.projectDir); + const results = await services .getBacktest() .getBacktestResult( diff --git a/src/modules/backtest.js b/src/modules/backtest.js index c4805dc00..3d190e757 100644 --- a/src/modules/backtest.js +++ b/src/modules/backtest.js @@ -199,7 +199,10 @@ module.exports = class Backtest { }; }); - const backtestSummary = await this.getBacktestSummary(signals, initialCapital); + const instance = this.instances.symbols.filter(i => i.symbol === pair && i.exchange === exchange)[0]; + const fees = instance.feesPerTrade === undefined ? 0 : instance.feesPerTrade; + + const backtestSummary = await this.getBacktestSummary(signals, initialCapital, fees); resolve({ summary: backtestSummary, rows: rows.slice().reverse(), @@ -218,7 +221,7 @@ module.exports = class Backtest { }); } - getBacktestSummary(signals, initialCapital) { + getBacktestSummary(signals, initialCapital, feesPerTrade) { return new Promise(resolve => { const initialCapitalNumber = Number(initialCapital); // 1000 $ Initial Capital let workingCapital = initialCapitalNumber; // Capital that changes after every trade @@ -235,8 +238,8 @@ module.exports = class Backtest { }; let cumulativePNLPercent = 0; // Sum of all the PNL Percentages + let cumulativeNetFees = 0; const pnlRateArray = []; // Array of all PNL Percentages of all the trades - // Iterate over all the signals for (let s = 0; s < signals.length; s++) { const signalObject = signals[s]; @@ -248,34 +251,39 @@ module.exports = class Backtest { trades.total += 1; // Entry Position Details - const entrySignalType = lastPosition.result.getSignal(); // Long or Short const entryPrice = lastPosition.price; // Price during the trade entry const tradedQuantity = Number(workingCapital / entryPrice); // Quantity - + const entrySignalType = lastPosition.result.getSignal(); // Long or Short + const entryValue = Number((tradedQuantity * entryPrice).toFixed(2)); // Price * Quantity + const entryFee = (entryValue * feesPerTrade) / 100; // Exit Details const exitPrice = signalObject.price; // Price during trade exit const exitValue = Number((tradedQuantity * exitPrice).toFixed(2)); // Price * Quantity + const exitFee = (exitValue * feesPerTrade) / 100; + + const totalFee = entryFee + exitFee; + cumulativeNetFees += totalFee; // Trade Details let pnlValue = 0; // Profit or Loss Value // When the position is Long if (entrySignalType === 'long') { - if (exitPrice > entryPrice) { + if (exitValue - totalFee > entryValue) { // Long Trade is Profitable trades.profitableCount += 1; } // Set the PNL - pnlValue = exitValue - workingCapital; + pnlValue = exitValue - totalFee - workingCapital; } else if (entrySignalType === 'short') { - if (exitPrice < entryPrice) { + if (exitValue - totalFee < entryValue) { // Short Trade is Profitable trades.profitableCount += 1; } // Set the PNL - pnlValue = -(exitValue - workingCapital); + pnlValue = -(exitValue - totalFee - workingCapital); } // Percentage Return @@ -330,6 +338,7 @@ module.exports = class Backtest { sharpeRatio: sharpeRatio, averagePNLPercent: averagePNLPercent, netProfit: netProfit, + netFees: cumulativeNetFees, initialCapital: initialCapitalNumber, finalCapital: Number(workingCapital.toFixed(2)), trades: trades diff --git a/templates/components/backtest_summary.html.twig b/templates/components/backtest_summary.html.twig index 355a2aae8..2e4690ded 100644 --- a/templates/components/backtest_summary.html.twig +++ b/templates/components/backtest_summary.html.twig @@ -17,6 +17,14 @@ {{ summary.finalCapital }} $ + + + Net Fees + + + {{ summary.netFees|round(2) }} $ + + Net Return on Investment