diff --git a/.sequelizerc b/.sequelizerc new file mode 100644 index 000000000..6383033bf --- /dev/null +++ b/.sequelizerc @@ -0,0 +1,10 @@ +// .sequelizerc + +const path = require('path'); + +module.exports = { + 'config': path.resolve('conf.json'), + 'models-path': path.resolve('src', 'modules', 'repository'), + 'seeders-path': path.resolve('db', 'seeders'), + 'migrations-path': path.resolve('db', 'migrations') +}; \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 6d9ef085d..0ccbc640c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,9 @@ language: node_js node_js: - '10' +before_install: +- cp conf.json.dist conf.json + install: - npm install diff --git a/.vscode/launch.json b/.vscode/launch.json index 449c49844..471c8c542 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,9 +5,26 @@ "version": "0.2.0", "configurations": [ { + "name": "Server", "type": "node", "request": "launch", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/index.js", + "args": ["trade"] + }, + { + "name": "Client", + "type": "chrome", + "request": "launch", + "url": "http://localhost:8080/", + "webRoot": "${workspaceRoot}/web/static/js" + }, + { "name": "Mocha Tests", + "type": "node", + "request": "launch", "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", "args": [ //"-u", @@ -22,17 +39,12 @@ "skipFiles": [ "/**" ] - }, - + } + ], + "compounds": [ { - "type": "node", - "request": "launch", - "name": "Bot-DEV", - "skipFiles": [ - "/**" - ], - "program": "${workspaceFolder}\\index.js", - "args": ["trade"] + "name": "Server/Client", + "configurations": ["Server", "Client"] } - ] + ] } \ No newline at end of file diff --git a/README.md b/README.md index f14b2e92c..8ca61d891 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,14 @@ TODOS: * node.js * sqlite3 + * [sequelize ORM](https://sequelize.org/) * [technicalindicators](https://github.com/anandanand84/technicalindicators) * [tulipindicators - tulind](https://tulipindicators.org/list) * [TA-Lib](https://mrjbq7.github.io/ta-lib/) - * twig + * [twig](https://www.npmjs.com/package/twig) * express * Bootstrap v4 + * [datatables](https://datatables.net/) * Tradingview widgets ## How to use @@ -84,11 +86,6 @@ Provide a configuration with your exchange credentials cp conf.json.dist conf.json ``` -Create a new sqlite database use bot.sql scheme to create the tables -``` -sqlite3 bot.db < bot.sql -``` - Lets start it ``` diff --git a/bot.sql b/bot.sql deleted file mode 100644 index 7b4bd9bba..000000000 --- a/bot.sql +++ /dev/null @@ -1,84 +0,0 @@ -PRAGMA auto_vacuum = INCREMENTAL; - -CREATE TABLE IF NOT EXISTS candlesticks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - exchange VARCHAR(255) NULL, - symbol VARCHAR(255) NULL, - period VARCHAR(255) NULL, - time INTEGER NULL, - open REAL NULL, - high REAL NULL, - low REAL NULL, - close REAL NULL, - volume REAL NULL -); - -CREATE UNIQUE INDEX unique_candle - ON candlesticks (exchange, symbol, period, time); - -CREATE INDEX time_idx ON candlesticks (time); -CREATE INDEX exchange_symbol_idx ON candlesticks (exchange, symbol); - -CREATE TABLE IF NOT EXISTS candlesticks_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - income_at BIGINT NULL, - exchange VARCHAR(255) NULL, - symbol VARCHAR(255) NULL, - period VARCHAR(255) NULL, - time INTEGER NULL, - open REAL NULL, - high REAL NULL, - low REAL NULL, - close REAL NULL, - volume REAL NULL -); - -CREATE INDEX candle_idx ON candlesticks_log (exchange, symbol, period, time); - -CREATE TABLE IF NOT EXISTS ticker ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - exchange VARCHAR(255) NULL, - symbol VARCHAR(255) NULL, - ask REAL NULL, - bid REAL NULL, - updated_at INT NULL -); - -CREATE UNIQUE INDEX ticker_unique - ON ticker (exchange, symbol); - -CREATE TABLE IF NOT EXISTS ticker_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - exchange VARCHAR(255) NULL, - symbol VARCHAR(255) NULL, - ask REAL NULL, - bid REAL NULL, - income_at BIGINT NULL -); -CREATE INDEX ticker_log_idx ON ticker_log (exchange, symbol); -CREATE INDEX ticker_log_time_idx ON ticker_log (exchange, symbol, income_at); - -CREATE TABLE IF NOT EXISTS signals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - exchange VARCHAR(255) NULL, - symbol VARCHAR(255) NULL, - ask REAL NULL, - bid REAL NULL, - options TEXT NULL, - side VARCHAR(50) NULL, - strategy VARCHAR(50) NULL, - income_at BIGINT NULL, - state VARCHAR(50) NULL -); -CREATE INDEX symbol_idx ON signals (exchange, symbol); - -CREATE TABLE IF NOT EXISTS logs ( - uuid VARCHAR(64) PRIMARY KEY, - level VARCHAR(32) NOT NULL, - message TEXT NULL, - created_at INT NOT NULL -); - -CREATE INDEX created_at_idx ON logs (created_at); -CREATE INDEX level_created_at_idx ON logs (level, created_at); -CREATE INDEX level_idx ON logs (level); \ No newline at end of file diff --git a/conf.json.dist b/conf.json.dist index eaf06f99b..04767bca8 100644 --- a/conf.json.dist +++ b/conf.json.dist @@ -147,5 +147,21 @@ } ] } - ] + ], + + "development": { + "storage": "bot.db", + "dialect": "sqlite", + "operatorsAliases": false + }, + "test": { + "storage": ":memory:", + "dialect": "sqlite", + "operatorsAliases": false + }, + "production": { + "storage": "bot.db", + "dialect": "sqlite", + "operatorsAliases": false + } } \ No newline at end of file diff --git a/db/migrations/20200526142117-initial.js b/db/migrations/20200526142117-initial.js new file mode 100644 index 000000000..ec4e578e9 --- /dev/null +++ b/db/migrations/20200526142117-initial.js @@ -0,0 +1,5 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.dropAllTables(); + } +}; diff --git a/package-lock.json b/package-lock.json index 48d324e03..e57fc18e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -339,6 +339,11 @@ "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", "integrity": "sha1-qCJQ3bABXponyoLoLqYDu/pF768=" }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" + }, "anymatch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", @@ -1138,15 +1143,6 @@ "resolved": "https://registry.npmjs.org/better-queue-memory/-/better-queue-memory-1.0.4.tgz", "integrity": "sha512-SWg5wFIShYffEmJpI6LgbL8/3Dqhku7xI1oEiy6FroP9DbcZlG0ZDjxvPdP9t7hTGW40IpIcC6zVoGT1oxjOuA==" }, - "better-sqlite3": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-5.4.3.tgz", - "integrity": "sha512-fPp+8f363qQIhuhLyjI4bu657J/FfMtgiiHKfaTsj3RWDkHlWC1yT7c6kHZDnBxzQVoAINuzg553qKmZ4F1rEw==", - "requires": { - "integer": "^2.1.0", - "tar": "^4.4.10" - } - }, "bfx-api-node-models": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/bfx-api-node-models/-/bfx-api-node-models-1.1.2.tgz", @@ -1370,6 +1366,11 @@ } } }, + "bootstrap": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.5.0.tgz", + "integrity": "sha512-Z93QoXvodoVslA+PWNdk23Hze4RBYIkpb5h8I2HY2Tu2h7A0LpAgLcyrhrSUyo2/Oxm2l1fRZPs1e5hnxnliXA==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1624,6 +1625,19 @@ } } }, + "cli-color": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-1.4.0.tgz", + "integrity": "sha512-xu6RvQqqrWEo6MPR1eixqGPywhYBHRs653F9jfXB2Hx4jdM/3WxiNE1vppRmxtMIfl16SFYTpYlrnqH/HsK/2w==", + "requires": { + "ansi-regex": "^2.1.1", + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "memoizee": "^0.4.14", + "timers-ext": "^0.1.5" + } + }, "cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -1684,6 +1698,15 @@ "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-0.0.1.tgz", "integrity": "sha1-uI+UqCzzi4eR1YBG6kAprYjKmdE=" }, + "cls-bluebird": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cls-bluebird/-/cls-bluebird-2.1.0.tgz", + "integrity": "sha1-N+8eCAqP+1XC9BZPU28ZGeeWiu4=", + "requires": { + "is-bluebird": "^1.0.2", + "shimmer": "^1.1.0" + } + }, "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", @@ -1823,6 +1846,15 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, + "config-chain": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.12.tgz", + "integrity": "sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA==", + "requires": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "confusing-browser-globals": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.9.tgz", @@ -1940,6 +1972,15 @@ "which": "^1.2.9" } }, + "d": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", + "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", + "requires": { + "es5-ext": "^0.10.50", + "type": "^1.0.1" + } + }, "damerau-levenshtein": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.5.tgz", @@ -1954,6 +1995,47 @@ "assert-plus": "^1.0.0" } }, + "datatables.net": { + "version": "1.10.20", + "resolved": "https://registry.npmjs.org/datatables.net/-/datatables.net-1.10.20.tgz", + "integrity": "sha512-4E4S7tTU607N3h0fZPkGmAtr9mwy462u+VJ6gxYZ8MxcRIjZqHy3Dv1GNry7i3zQCktTdWbULVKBbkAJkuHEnQ==", + "requires": { + "jquery": ">=1.7" + } + }, + "datatables.net-bs4": { + "version": "1.10.20", + "resolved": "https://registry.npmjs.org/datatables.net-bs4/-/datatables.net-bs4-1.10.20.tgz", + "integrity": "sha512-kQmMUMsHMOlAW96ztdoFqjSbLnlGZQ63iIM82kHbmldsfYdzuyhbb4hTx6YNBi481WCO3iPSvI6YodNec46ZAw==", + "requires": { + "datatables.net": "1.10.20", + "jquery": ">=1.7" + } + }, + "datatables.net-plugins": { + "version": "1.10.20", + "resolved": "https://registry.npmjs.org/datatables.net-plugins/-/datatables.net-plugins-1.10.20.tgz", + "integrity": "sha512-rnhNmRHe9UEzvM7gtjBay1QodkWUmxLUhHNbmJMYhhUggjtm+BRSlE0PRilkeUkwckpNWzq+0fPd7/i0fpQgzA==" + }, + "datatables.net-responsive": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/datatables.net-responsive/-/datatables.net-responsive-2.2.5.tgz", + "integrity": "sha512-AuF28BJRQWfke0cwZwgJB5+WHgoDCDAnW59TJWX4JAXYes3iFrJA6mNHWw46Up3bqUJVI2ZxJoKTGpoEHm5hNA==", + "requires": { + "datatables.net": "^1.10.15", + "jquery": ">=1.7" + } + }, + "datatables.net-responsive-bs4": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/datatables.net-responsive-bs4/-/datatables.net-responsive-bs4-2.2.5.tgz", + "integrity": "sha512-ufUywwBDAyBbUsRJ/QAp6aSSVlZLnNy7BI9cJswqC0y+Si4hwLUTz22kbeoJuCERASPs0aLcx2MaYiKnQi/Euw==", + "requires": { + "datatables.net-bs4": "^1.10.15", + "datatables.net-responsive": "2.2.5", + "jquery": ">=1.7" + } + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -2056,6 +2138,11 @@ "esutils": "^2.0.2" } }, + "dottie": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.2.tgz", + "integrity": "sha512-fmrwR04lsniq/uSr8yikThDTrM7epXHBAAjH9TbeH3rEA8tdCO7mRzB9hdmdGyJCxF8KERo9CITcm3kGuoyMhg==" + }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -2065,6 +2152,24 @@ "safer-buffer": "^2.1.0" } }, + "editorconfig": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-0.15.3.tgz", + "integrity": "sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==", + "requires": { + "commander": "^2.19.0", + "lru-cache": "^4.1.5", + "semver": "^5.6.0", + "sigmund": "^1.0.1" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + } + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2151,11 +2256,51 @@ "is-symbol": "^1.0.2" } }, + "es5-ext": { + "version": "0.10.53", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz", + "integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==", + "requires": { + "es6-iterator": "~2.0.3", + "es6-symbol": "~3.1.3", + "next-tick": "~1.0.0" + } + }, + "es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", + "requires": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, "es6-promise": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" }, + "es6-symbol": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", + "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", + "requires": { + "d": "^1.0.1", + "ext": "^1.1.2" + } + }, + "es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "requires": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -3180,6 +3325,15 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, + "event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=", + "requires": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "eventemitter2": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-5.0.1.tgz", @@ -3266,6 +3420,21 @@ } } }, + "ext": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz", + "integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==", + "requires": { + "type": "^2.0.0" + }, + "dependencies": { + "type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/type/-/type-2.0.0.tgz", + "integrity": "sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow==" + } + } + }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3921,6 +4090,11 @@ "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true }, + "inflection": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.12.0.tgz", + "integrity": "sha1-ogCTVlbW9fa8TcdQLhrstwMihBY=" + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -4049,11 +4223,6 @@ } } }, - "integer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/integer/-/integer-2.1.0.tgz", - "integrity": "sha512-vBtiSgrEiNocWvvZX1RVfeOKa2mCHLZQ2p9nkQkQZ/BvEiY+6CcUz0eyjvIiewjJoeNidzg2I+tpPJvpyspL1w==" - }, "internal-slot": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.2.tgz", @@ -4118,6 +4287,11 @@ "binary-extensions": "^2.0.0" } }, + "is-bluebird": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-bluebird/-/is-bluebird-1.0.2.tgz", + "integrity": "sha1-CWQ5Bg9KpBGr7hkUOoTWpVNG1uI=" + }, "is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", @@ -4228,8 +4402,7 @@ "is-promise": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", - "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=", - "dev": true + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=" }, "is-regex": { "version": "1.0.4", @@ -4326,6 +4499,39 @@ "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, + "jquery": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz", + "integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg==" + }, + "js-beautify": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.11.0.tgz", + "integrity": "sha512-a26B+Cx7USQGSWnz9YxgJNMmML/QG2nqIaL7VVYPCXbqiKz8PN0waSNvroMtvAK6tY7g/wPdNWGEP+JTNIBr6A==", + "requires": { + "config-chain": "^1.1.12", + "editorconfig": "^0.15.3", + "glob": "^7.1.3", + "mkdirp": "~1.0.3", + "nopt": "^4.0.3" + }, + "dependencies": { + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + }, + "nopt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz", + "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==", + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + } + } + }, "js-tokens": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", @@ -4582,6 +4788,30 @@ "js-tokens": "^3.0.0 || ^4.0.0" } }, + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + }, + "dependencies": { + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" + } + } + }, + "lru-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", + "integrity": "sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM=", + "requires": { + "es5-ext": "~0.10.2" + } + }, "mailcomposer": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/mailcomposer/-/mailcomposer-3.12.0.tgz", @@ -4647,6 +4877,21 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" }, + "memoizee": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.14.tgz", + "integrity": "sha512-/SWFvWegAIYAO4NQMpcX+gcra0yEZu4OntmUdrBaWrJncxOqAziGFlHxc7yjKVK2uu3lpPW27P27wkR82wA8mg==", + "requires": { + "d": "1", + "es5-ext": "^0.10.45", + "es6-weak-map": "^2.0.2", + "event-emitter": "^0.3.5", + "is-promise": "^2.1", + "lru-queue": "0.1", + "next-tick": "1", + "timers-ext": "^0.1.5" + } + }, "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -4911,6 +5156,14 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.25.3.tgz", "integrity": "sha512-PuYv0PHxZvzc15Sp8ybUCoQ+xpyPWvjOuK72a5ovzp2LI32rJXOiIfyoFoYvG3s6EwwrdkMyWuRiEHSZRLJNdg==" }, + "moment-timezone": { + "version": "0.5.28", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.28.tgz", + "integrity": "sha512-TDJkZvAyKIVWg5EtVqRzU97w0Rb0YVbfpqyjgu6GwXCAohVRqwZjf4fOzDE6p1Ch98Sro/8hQQi65WDXW5STPw==", + "requires": { + "moment": ">= 2.9.0" + } + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -5050,6 +5303,11 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, + "next-tick": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", + "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=" + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -5538,8 +5796,7 @@ "path-parse": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", - "dev": true + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" }, "path-to-regexp": { "version": "0.1.7", @@ -5693,6 +5950,11 @@ "react-is": "^16.8.1" } }, + "proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=" + }, "proxy-addr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.5.tgz", @@ -5702,6 +5964,11 @@ "ipaddr.js": "1.9.0" } }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" + }, "psl": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.6.0.tgz", @@ -6036,7 +6303,6 @@ "version": "1.13.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.13.1.tgz", "integrity": "sha512-CxqObCX8K8YtAhOBRg+lrcdn+LK+WYOS8tSjqSFbjtrI5PnS63QPhZl4+yKfrU9tdsbMu9Anr/amegT87M9Z6w==", - "dev": true, "requires": { "path-parse": "^1.0.6" } @@ -6076,6 +6342,14 @@ "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==" }, + "retry-as-promised": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-3.2.0.tgz", + "integrity": "sha512-CybGs60B7oYU/qSQ6kuaFmRd9sTZ6oXSc0toqePvV74Ac6/IFZSI1ReFQmtCN+uvW1Mtqdwpvt/LGOiCBAY2Mg==", + "requires": { + "any-promise": "^1.3.0" + } + }, "rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -6176,6 +6450,78 @@ "mailcomposer": "3.12.0" } }, + "sequelize": { + "version": "5.21.7", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-5.21.7.tgz", + "integrity": "sha512-+JrS5Co7CN53cOFFFaUb+xqQP00wD1Ag9xGLBLoUko2KhRZxjm+UDkqAVPHTUp87McLwJaCPkKv61GPhBVloRQ==", + "requires": { + "bluebird": "^3.5.0", + "cls-bluebird": "^2.1.0", + "debug": "^4.1.1", + "dottie": "^2.0.0", + "inflection": "1.12.0", + "lodash": "^4.17.15", + "moment": "^2.24.0", + "moment-timezone": "^0.5.21", + "retry-as-promised": "^3.2.0", + "semver": "^6.3.0", + "sequelize-pool": "^2.3.0", + "toposort-class": "^1.0.1", + "uuid": "^3.3.3", + "validator": "^10.11.0", + "wkx": "^0.4.8" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "sequelize-cli": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/sequelize-cli/-/sequelize-cli-5.5.1.tgz", + "integrity": "sha512-ZM4kUZvY3y14y+Rq3cYxGH7YDJz11jWHcN2p2x7rhAIemouu4CEXr5ebw30lzTBtyXV4j2kTO+nUjZOqzG7k+Q==", + "requires": { + "bluebird": "^3.5.3", + "cli-color": "^1.4.0", + "fs-extra": "^7.0.1", + "js-beautify": "^1.8.8", + "lodash": "^4.17.5", + "resolve": "^1.5.0", + "umzug": "^2.1.0", + "yargs": "^13.1.0" + } + }, + "sequelize-datatables": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/sequelize-datatables/-/sequelize-datatables-3.0.0.tgz", + "integrity": "sha512-LI27fBBDVxiSZL/LO3eH7d1MeplGR9SiDmT8++Wc1UgQUkggC4xqORlfF1i5okAfOxMhNIXvbLOLimPg+6buHA==", + "requires": { + "bluebird": "^3.5.1", + "lodash": "^4.17.10", + "sequelize": "^5.21.7" + } + }, + "sequelize-pool": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-2.3.0.tgz", + "integrity": "sha512-Ibz08vnXvkZ8LJTiUOxRcj1Ckdn7qafNZ2t59jYHMX1VIebTAOYefWdRYFt6z6+hy52WGthAHAoLc9hvk3onqA==" + }, "serve-static": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", @@ -6229,6 +6575,11 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=" }, + "shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" + }, "side-channel": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.2.tgz", @@ -6239,6 +6590,11 @@ "object-inspect": "^1.7.0" } }, + "sigmund": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", + "integrity": "sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA=" + }, "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", @@ -6470,6 +6826,34 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, + "sqlite3": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-4.2.0.tgz", + "integrity": "sha512-roEOz41hxui2Q7uYnWsjMOTry6TcNUNmp8audCx18gF10P2NknwdpF+E+HKvz/F2NvPKGGBF4NGc+ZPQ+AABwg==", + "requires": { + "nan": "^2.12.1", + "node-pre-gyp": "^0.11.0" + }, + "dependencies": { + "node-pre-gyp": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.11.0.tgz", + "integrity": "sha512-TwWAOZb0j7e9eGaf9esRx3ZcLaE5tQ2lvYy1pb5IAaG1a2e2Kv5Lms1Y4hpj+ciXJRofIxxlt5haeQ/2ANeE0Q==", + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.1", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4" + } + } + } + }, "sshpk": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", @@ -7015,6 +7399,15 @@ "xtend": "~4.0.1" } }, + "timers-ext": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", + "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", + "requires": { + "es5-ext": "~0.10.46", + "next-tick": "1" + } + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -7190,6 +7583,11 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" }, + "toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha1-f/0feMi+KMO6Rc1OGj9e4ZO9mYg=" + }, "tough-cookie": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", @@ -7296,6 +7694,11 @@ } } }, + "type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", + "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" + }, "type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", @@ -7330,6 +7733,14 @@ "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz", "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==" }, + "umzug": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", + "integrity": "sha512-Z274K+e8goZK8QJxmbRPhl89HPO1K+ORFtm6rySPhFKfKc5GHhqdzD0SGhSWHkzoXasqJuItdhorSvY7/Cgflw==", + "requires": { + "bluebird": "^3.7.2" + } + }, "unc-path-regex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", @@ -7446,6 +7857,11 @@ "spdx-expression-parse": "^3.0.0" } }, + "validator": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-10.11.0.tgz", + "integrity": "sha512-X/p3UZerAIsbBfN/IwahhYaBbY68EN/UQBWHtsbXGT5bfrH/p4NQzUCG1kF/rtKaNpnJ7jAu6NGTdSNtyNIXMw==" + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -7550,6 +7966,24 @@ "triple-beam": "^1.2.0" } }, + "winston-transport-sequelize": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/winston-transport-sequelize/-/winston-transport-sequelize-4.0.3.tgz", + "integrity": "sha512-tox3LV0ZzMalgDhuNHlMAqCzjyAxD3ymPmM/0KFTbpy0p6hemSiXlTeeglt7rvaB1LHSMqstYUk5NZbmlGJstQ==", + "requires": { + "sequelize": "^5.21.5", + "winston": "^3.0.0", + "winston-transport": "^4.2.0" + } + }, + "wkx": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.4.8.tgz", + "integrity": "sha512-ikPXMM9IR/gy/LwiOSqWlSL3X/J5uk9EO2hHNRXS41eTLXaUFEVw9fn/593jW/tE5tedNg8YjT5HkCa4FqQZyQ==", + "requires": { + "@types/node": "*" + } + }, "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", diff --git a/package.json b/package.json index 49228ad3e..cfff66e41 100644 --- a/package.json +++ b/package.json @@ -19,18 +19,21 @@ "async": "^3.2.0", "basic-auth": "^2.0.1", "better-queue": "^3.8.10", - "better-sqlite3": "^5.4.2", "binance-api-node": "^0.9.19", "bitfinex-api-node": "^3.0.2", "bitmex-realtime-api": "^0.4.0", + "bootstrap": "^4.5.0", "ccxt": "^1.27.89", "coinbase-pro": "^0.9.0", "colors": "^1.3.2", "commander": "^4.1.1", "compression": "^1.7.4", "cookie-parser": "^1.4.5", + "datatables.net-plugins": "^1.10.20", + "datatables.net-responsive-bs4": "^2.2.5", "express": "^4.16.4", "http": "0.0.0", + "jquery": "^3.5.1", "lodash": "^4.17.11", "mocha": "^7.1.2", "moment": "^2.25.3", @@ -43,6 +46,10 @@ "queue-promise": "^1.3.33", "request": "^2.88.2", "sendmail": "^1.4.1", + "sequelize": "^5.21.7", + "sequelize-cli": "^5.5.1", + "sequelize-datatables": "^3.0.0", + "sqlite3": "^4.2.0", "talib": "^1.0.5", "technicalindicators": "^3.1.0", "telegraf": "^3.38.0", @@ -51,6 +58,7 @@ "twig": "^1.15.1", "typescript": "^3.9.2", "winston": "^3.2.1", + "winston-transport-sequelize": "^4.0.3", "ws": "^7.3.0", "zero-fill": "^2.2.3" }, @@ -82,7 +90,7 @@ }, "scripts": { "start": "node index.js trade", - "postinstall": "patch-package", + "postinstall": "patch-package && npx sequelize db:migrate", "test": "mocha 'test/**/*.test.js'", "setup": "pm2 deploy ecosystem.config.js production setup", "deploy": "pm2 deploy ecosystem.config.js production" diff --git a/src/modules/http.js b/src/modules/http.js index d5f6deeb6..4a5df948f 100644 --- a/src/modules/http.js +++ b/src/modules/http.js @@ -5,6 +5,7 @@ const auth = require('basic-auth'); const cookieParser = require('cookie-parser'); const crypto = require('crypto'); const moment = require('moment'); +const Path = require('path'); module.exports = class Http { constructor( @@ -80,6 +81,35 @@ module.exports = class Http { app.use(cookieParser()); app.use(compression()); app.use(express.static(`${this.projectDir}/web/static`, { maxAge: 3600000 * 24 })); + app.use('/scripts/jquery', express.static(Path.join(this.projectDir, 'node_modules/jquery/dist'))); + app.use('/scripts/moment', express.static(Path.join(this.projectDir, '/node_modules/moment/min'))); + app.use('/scripts/datatables.net', express.static(Path.join(this.projectDir, '/node_modules/datatables.net/js'))); + app.use('/scripts/bootstrap', express.static(Path.join(this.projectDir, '/node_modules/bootstrap/dist/js'))); + app.use('/css/bootstrap', express.static(Path.join(this.projectDir, '/node_modules/bootstrap/dist/css'))); + app.use( + '/scripts/datatables.net-bs4/', + express.static(Path.join(this.projectDir, '/node_modules/datatables.net-bs4/js')) + ); + app.use( + '/css/datatables.net-bs4/', + express.static(Path.join(this.projectDir, '/node_modules/datatables.net-bs4/css')) + ); + app.use( + '/scripts/datatables.net-plugins/', + express.static(Path.join(this.projectDir, '/node_modules/datatables.net-plugins')) + ); + app.use( + '/scripts/datatables.net-responsive/', + express.static(Path.join(this.projectDir, '/node_modules/datatables.net-responsive/js')) + ); + app.use( + '/scripts/datatables.net-responsive-bs4/', + express.static(Path.join(this.projectDir, '/node_modules/datatables.net-responsive-bs4/js')) + ); + app.use( + '/css/datatables.net-responsive-bs4/', + express.static(Path.join(this.projectDir, '/node_modules/datatables.net-responsive-bs4/css')) + ); const username = this.systemUtil.getConfig('webserver.username'); const password = this.systemUtil.getConfig('webserver.password'); @@ -143,13 +173,35 @@ module.exports = class Http { }); app.get('/pairs', async (req, res) => { - res.render('../templates/pairs.html.twig', { - pairs: await this.pairsHttp.getTradePairs() + res.render('../templates/pairs.html.twig', {}); + }); + + app.get('/pairs/trade', async (req, res) => { + const pairs = await this.pairsHttp.getTradePairs(); + res.json({ + draw: req.body.draw, + data: pairs, + recordsFiltered: pairs.length, + recordsTotal: pairs.length }); }); + app.get('/pairs/:exchange/:symbol/:action', async (req, res) => { + const { exchange, symbol, action } = req.params; + await this.pairsHttp.triggerOrder(exchange, symbol, action); + + // simple sleep for async ui blocking for exchange communication + setTimeout(() => { + res.redirect('/pairs'); + }, 800); + }); + app.get('/logs', async (req, res) => { - res.render('../templates/logs.html.twig', await this.logsHttp.getLogsPageVariables(req, res)); + res.render('../templates/logs.html.twig', await this.logsHttp.getLogsPageVariables()); + }); + + app.post('/logsTable', async (req, res) => { + res.json(await this.logsHttp.getLogsData(req)); }); app.get('/desks/:desk', async (req, res) => { @@ -210,36 +262,25 @@ module.exports = class Http { res.redirect('/tools/candles'); }); - app.post('/pairs/:pair', async (req, res) => { - const pair = req.params.pair.split('-'); - const { body } = req; - - // exchange-ETC-FOO - // exchange-ETCFOO - const symbol = req.params.pair.substring(pair[0].length + 1); - - await this.pairsHttp.triggerOrder(pair[0], symbol, body.action); - - // simple sleep for async ui blocking for exchange communication - setTimeout(() => { - res.redirect('/pairs'); - }, 800); - }); - const { exchangeManager } = this; - app.get('/order/:exchange/:id', async (req, res) => { - const exchangeName = req.params.exchange; - const { id } = req.params; + app.delete('/orders/:exchange/:id', async (req, res) => { + const { exchange, id } = req.params; - const exchange = exchangeManager.get(exchangeName); + const exchangeMgr = exchangeManager.get(exchange); try { - await exchange.cancelOrder(id); + await exchangeMgr.cancelOrder(id); } catch (e) { - console.log(`Cancel order error: ${JSON.stringify([exchangeName, id, String(e)])}`); + const error = `Cancel order error: ${JSON.stringify([exchange, id, String(e)])}`; + console.log(error); + res.json(JSON.stringify(error)); } + res.json({ [id]: 'OK' }); + }); - res.redirect('/trades'); + app.get('/orders/:pair/cancel-all', async (req, res) => { + await this.ordersHttp.cancelAll(req.params.pair); + res.redirect(`/orders/${req.params.pair}`); }); app.get('/orders', async (req, res) => { @@ -256,6 +297,7 @@ module.exports = class Http { res.render('../templates/orders/orders.html.twig', { pair: pair, + exchange: tradingview[0], pairs: this.ordersHttp.getPairs(), orders: await this.ordersHttp.getOrders(pair), position: await this.exchangeManager.getPosition(tradingview[0], tradingview[1]), @@ -307,24 +349,14 @@ module.exports = class Http { }); }); - app.get('/orders/:pair/cancel/:id', async (req, res) => { - const foo = await this.ordersHttp.cancel(req.params.pair, req.params.id); - res.redirect(`/orders/${req.params.pair}`); - }); - - app.get('/orders/:pair/cancel-all', async (req, res) => { - await this.ordersHttp.cancelAll(req.params.pair); - res.redirect(`/orders/${req.params.pair}`); + app.get('/trades', async (req, res) => { + res.render('../templates/trades.html.twig', {}); }); - app.get('/trades', async (req, res) => { + app.get('/trades/positions', async (req, res) => { const positions = []; - const orders = []; - - const exchanges = exchangeManager.all(); - for (const key in exchanges) { - const exchange = exchanges[key]; - + // TODO: Get rid of async calls in for loop + for (const exchange of exchangeManager.all()) { const exchangeName = exchange.getName(); const myPositions = await exchange.getPositions(); @@ -341,30 +373,40 @@ module.exports = class Http { currencyValue = position.entry * Math.abs(position.amount); } - positions.push({ - exchange: exchangeName, - position: position, - currency: currencyValue - }); + if (req.query.search.value === '' || position.symbol.includes((req.query.search.value).toUpperCase())) { + positions.push({ + exchange: exchangeName, + position: position, + currency: currencyValue, + actions: 'close' + }); + } }); + }; + res.json({ + draw: req.body.draw, + data: positions, + recordsFiltered: positions.length, + recordsTotal: positions.length + }); + }); + app.get('/trades/orders', async (req, res) => { + const orders = []; + // TODO: Get rid of async calls in for loop + for (const exchange of exchangeManager.all()) { const myOrders = await exchange.getOrders(); myOrders.forEach(order => { - orders.push({ - exchange: exchange.getName(), - order: order - }); + if (req.query.search.value === '' || order.symbol.includes(req.query.search.value.toUpperCase())) { + orders.push({ + exchange: exchange.getName(), + order: order, + actions: 'cancel' + }); + } }); } - - res.render('../templates/trades.html.twig', { - orders: orders, - positions: positions.sort( - (a, b) => - (!a.position.createdAt ? 0 : a.position.createdAt.getTime()) - - (!b.position.createdAt ? 0 : b.position.createdAt.getTime()) - ) - }); + res.json({ draw: req.body.draw, data: orders, recordsFiltered: orders.length, recordsTotal: orders.length }); }); const ip = this.systemUtil.getConfig('webserver.ip', '0.0.0.0'); diff --git a/src/modules/listener/tick_listener.js b/src/modules/listener/tick_listener.js index f67e4167e..db60f3463 100644 --- a/src/modules/listener/tick_listener.js +++ b/src/modules/listener/tick_listener.js @@ -8,7 +8,7 @@ module.exports = class TickListener { tickers, instances, notifier, - signalLogger, + signalRepository, strategyManager, exchangeManager, pairStateManager, @@ -18,7 +18,7 @@ module.exports = class TickListener { this.tickers = tickers; this.instances = instances; this.notifier = notifier; - this.signalLogger = signalLogger; + this.signalRepository = signalRepository; this.strategyManager = strategyManager; this.exchangeManager = exchangeManager; this.pairStateManager = pairStateManager; @@ -78,7 +78,7 @@ module.exports = class TickListener { this.notifier.send(`[${signal} (${strategyKey})` + `] ${symbol.exchange}:${symbol.symbol} - ${ticker.ask}`); // log signal - this.signalLogger.signal( + await this.signalRepository.insertSignal( symbol.exchange, symbol.symbol, { @@ -142,7 +142,7 @@ module.exports = class TickListener { [new Date().toISOString(), signal, strategyKey, symbol.exchange, symbol.symbol, ticker.ask].join(' ') ); this.notifier.send(`[${signal} (${strategyKey})` + `] ${symbol.exchange}:${symbol.symbol} - ${ticker.ask}`); - this.signalLogger.signal( + await this.signalRepository.insertSignal( symbol.exchange, symbol.symbol, { diff --git a/src/modules/listener/ticker_database_listener.js b/src/modules/listener/ticker_database_listener.js index 61ce002d3..4c3c35f05 100644 --- a/src/modules/listener/ticker_database_listener.js +++ b/src/modules/listener/ticker_database_listener.js @@ -2,22 +2,11 @@ const _ = require('lodash'); module.exports = class TickerDatabaseListener { constructor(tickerRepository) { - this.trottle = {}; - - setInterval(async () => { - const tickers = Object.values(this.trottle); - this.trottle = {}; - - if (tickers.length > 0) { - for (const chunk of _.chunk(tickers, 100)) { - await tickerRepository.insertTickers(chunk); - } - } - }, 1000 * 15); + this.tickerRepository = tickerRepository; } onTicker(tickerEvent) { const { ticker } = tickerEvent; - this.trottle[ticker.symbol + ticker.exchange] = ticker; + this.tickerRepository.insertTickers([ticker]); } }; diff --git a/src/modules/pairs/pairs_http.js b/src/modules/pairs/pairs_http.js index 16bd4b0b1..23d0572af 100644 --- a/src/modules/pairs/pairs_http.js +++ b/src/modules/pairs/pairs_http.js @@ -30,6 +30,14 @@ module.exports = class PairsHttp { item.process = state.state; } + item.actions = {}; + if (!item.has_position && !item.process) { + item.actions = ['short', 'long', 'short_market', 'long_market']; + } else if (item.has_position && !item.process) { + item.actions = ['close', 'close_market']; + } else if (item.process) { + item.actions = ['close']; + } return item; }) ); diff --git a/src/modules/repository/candlestick_repository.js b/src/modules/repository/candlestick_repository.js index 99658cf72..f472181a2 100644 --- a/src/modules/repository/candlestick_repository.js +++ b/src/modules/repository/candlestick_repository.js @@ -1,89 +1,102 @@ -const Candlestick = require('../../dict/candlestick'); +const { Op } = require('sequelize'); -module.exports = class CandlestickRepository { - constructor(db) { - this.db = db; - } +module.exports = function(sequelize, DataTypes) { + const CandlestickRepository = sequelize.define( + 'Candlestick', + { + exchange: { + type: DataTypes.STRING(255), + primaryKey: true + }, + symbol: { + type: DataTypes.STRING(255), + primaryKey: true + }, + period: { + type: DataTypes.STRING(255), + primaryKey: true, + validate: { + isIn: [['m', 'h', 'd', 'y']] + } + }, + time: { + type: DataTypes.INTEGER, + primaryKey: true, + validate: { + min: 631148400 + } + }, + open: { + type: DataTypes.REAL, + allowNull: true + }, + high: { + type: DataTypes.REAL, + allowNull: true + }, + low: { + type: DataTypes.REAL, + allowNull: true + }, + close: { + type: DataTypes.REAL, + allowNull: true + }, + volume: { + type: DataTypes.REAL, + allowNull: true + } + }, + { + tableName: 'candlesticks', + timestamps: false + } + ); - getLookbacksForPair(exchange, symbol, period, limit = 750) { - return new Promise(resolve => { - const stmt = this.db.prepare( - `SELECT * from candlesticks where exchange = ? AND symbol = ? and period = ? order by time DESC LIMIT ${limit}` - ); + CandlestickRepository.getLookbacksForPair = async (exchange, symbol, period, limit = 750) => { + return CandlestickRepository.getCandlesInWindow(exchange, symbol, period, undefined, undefined, limit); + }; - const result = stmt.all([exchange, symbol, period]).map(row => { - return new Candlestick(row.time, row.open, row.high, row.low, row.close, row.volume); - }); + CandlestickRepository.getLookbacksSince = async (exchange, symbol, period, start) => { + return CandlestickRepository.getCandlesInWindow(exchange, symbol, period, start); + }; - resolve(result); - }); - } - - getLookbacksSince(exchange, symbol, period, start) { - return new Promise(resolve => { - const stmt = this.db.prepare( - 'SELECT * from candlesticks where exchange = ? AND symbol = ? and period = ? and time > ? order by time DESC' - ); - - const result = stmt.all([exchange, symbol, period, start]).map(row => { - return new Candlestick(row.time, row.open, row.high, row.low, row.close, row.volume); - }); + CandlestickRepository.getCandlesInWindow = async (exchange, symbol, period, start, end, limit = 1000) => { + const whereCondition = { + exchange: exchange, + symbol: symbol, + period: period + }; + const timeConditions = { + ...(typeof(start) !== 'undefined') && {[Op.gt]: start}, + ...(typeof(end) !== 'undefined') && {[Op.lt]: end} + }; + if (Reflect.ownKeys(timeConditions).length > 0) { + whereCondition.time = timeConditions; + }; + return CandlestickRepository.findAll({ + attributes: ['time', 'open', 'high', 'low', 'close', 'volume'], + where: whereCondition, + order: [['time', 'DESC']], + limit: limit, + raw : true + }); + }; - resolve(result); + CandlestickRepository.getExchangePairs = async () => { + return CandlestickRepository.findAll({ + group: ['exchange', 'symbol'], + attributes: ['exchange', 'symbol'], + order: ['exchange', 'symbol'], + raw : true }); - } + }; - getCandlesInWindow(exchange, symbol, period, start, end) { - return new Promise(resolve => { - const stmt = this.db.prepare( - 'SELECT * from candlesticks where exchange = ? AND symbol = ? and period = ? and time > ? and time < ? order by time DESC LIMIT 1000' - ); - - const result = stmt - .all([exchange, symbol, period, Math.round(start.getTime() / 1000), Math.round(end.getTime() / 1000)]) - .map(row => { - return new Candlestick(row.time, row.open, row.high, row.low, row.close, row.volume); - }); - - resolve(result); + CandlestickRepository.insertCandles = (exchangeCandlesticks) => { + return CandlestickRepository.bulkCreate(exchangeCandlesticks, { + updateOnDuplicate: ['open', 'high', 'low', 'close', 'volume'] }); - } + }; - getExchangePairs() { - return new Promise(resolve => { - const stmt = this.db.prepare( - 'select exchange, symbol from candlesticks group by exchange, symbol order by exchange, symbol' - ); - resolve(stmt.all()); - }); - } - - insertCandles(exchangeCandlesticks) { - return new Promise(resolve => { - const upsert = this.db.prepare( - 'INSERT INTO candlesticks(exchange, symbol, period, time, open, high, low, close, volume) VALUES ($exchange, $symbol, $period, $time, $open, $high, $low, $close, $volume) ' + - 'ON CONFLICT(exchange, symbol, period, time) DO UPDATE SET open=$open, high=$high, low=$low, close=$close, volume=$volume' - ); - - this.db.transaction(() => { - exchangeCandlesticks.forEach(exchangeCandlestick => { - const parameters = { - exchange: exchangeCandlestick.exchange, - symbol: exchangeCandlestick.symbol, - period: exchangeCandlestick.period, - time: exchangeCandlestick.time, - open: exchangeCandlestick.open, - high: exchangeCandlestick.high, - low: exchangeCandlestick.low, - close: exchangeCandlestick.close, - volume: exchangeCandlestick.volume - }; - - upsert.run(parameters); - }); - })(); - - resolve(); - }); - } + return CandlestickRepository; }; diff --git a/src/modules/repository/logs_repository.js b/src/modules/repository/logs_repository.js deleted file mode 100644 index 7b70db820..000000000 --- a/src/modules/repository/logs_repository.js +++ /dev/null @@ -1,49 +0,0 @@ -const moment = require('moment'); - -module.exports = class LogsRepository { - constructor(db) { - this.db = db; - } - - getLatestLogs(excludes = ['debug'], limit = 200) { - return new Promise(resolve => { - let sql = `SELECT * from logs order by created_at DESC LIMIT ${limit}`; - - const parameters = {}; - - if (excludes.length > 0) { - sql = `SELECT * from logs WHERE level NOT IN (${excludes - .map((exclude, index) => `$level_${index}`) - .join(', ')}) order by created_at DESC LIMIT ${limit}`; - - excludes.forEach((exclude, index) => { - parameters[`level_${index}`] = exclude; - }); - } - - const stmt = this.db.prepare(sql); - resolve(stmt.all(parameters)); - }); - } - - getLevels() { - return new Promise(resolve => { - const stmt = this.db.prepare('SELECT level from logs GROUP BY level'); - resolve(stmt.all().map(r => r.level)); - }); - } - - cleanOldLogEntries(days = 7) { - return new Promise(resolve => { - const stmt = this.db.prepare('DELETE FROM logs WHERE created_at < $created_at'); - - stmt.run({ - created_at: moment() - .subtract(days, 'days') - .unix() - }); - - resolve(); - }); - } -}; diff --git a/src/modules/repository/signal_repository.js b/src/modules/repository/signal_repository.js index fae9dcc4f..b603f3d9f 100644 --- a/src/modules/repository/signal_repository.js +++ b/src/modules/repository/signal_repository.js @@ -1,27 +1,52 @@ -module.exports = class SignalRepository { - constructor(db) { - this.db = db; - } +const { Op } = require('sequelize'); - getSignals(since) { - return new Promise(resolve => { - const stmt = this.db.prepare('SELECT * from signals where income_at > ? order by income_at DESC LIMIT 100'); - resolve(stmt.all(since)); - }); - } +module.exports = function(sequelize, DataTypes) { + const SignalRepository = sequelize.define( + 'Signal', + { + exchange: { + type: DataTypes.STRING(255), + allowNull: true + }, + symbol: { + type: DataTypes.STRING(255), + allowNull: true + }, + options: { + type: DataTypes.TEXT, + allowNull: true + }, + side: { + type: DataTypes.STRING(50), + allowNull: true + }, + strategy: { + type: DataTypes.STRING(50), + allowNull: true + } + }, + { + tableName: 'signals' + } + ); - insertSignal(exchange, symbol, options, side, strategy) { - const stmt = this.db.prepare( - 'INSERT INTO signals(exchange, symbol, options, side, strategy, income_at) VALUES ($exchange, $symbol, $options, $side, $strategy, $income_at)' - ); + SignalRepository.getSignals = async since => { + return SignalRepository.findAll({ + where: { updatedAt: { [Op.gt]: since } }, + order: [['updatedAt', 'DESC']], + limit: 1000 + // raw : true + }); + }; - stmt.run({ + SignalRepository.insertSignal = async (exchange, symbol, options, side, strategy) => + SignalRepository.create({ exchange: exchange, symbol: symbol, options: JSON.stringify(options || {}), side: side, - strategy: strategy, - income_at: Math.floor(Date.now() / 1000) + strategy: strategy }); - } + + return SignalRepository; }; diff --git a/src/modules/repository/ticker_log_repository.js b/src/modules/repository/ticker_log_repository.js deleted file mode 100644 index 17f4464ea..000000000 --- a/src/modules/repository/ticker_log_repository.js +++ /dev/null @@ -1,21 +0,0 @@ -const moment = require('moment'); - -module.exports = class TickerLogRepository { - constructor(db) { - this.db = db; - } - - cleanOldLogEntries(days = 14) { - return new Promise(resolve => { - const stmt = this.db.prepare('DELETE FROM ticker_log WHERE income_at < $income_at'); - - stmt.run({ - income_at: moment() - .subtract(days, 'days') - .unix() - }); - - resolve(); - }); - } -}; diff --git a/src/modules/repository/ticker_repository.js b/src/modules/repository/ticker_repository.js index e4b91db34..cc5de9820 100644 --- a/src/modules/repository/ticker_repository.js +++ b/src/modules/repository/ticker_repository.js @@ -1,31 +1,36 @@ -module.exports = class TickerRepository { - constructor(db, logger) { - this.db = db; - this.logger = logger; - } +module.exports = function(sequelize, DataTypes) { + const TickerRepository = sequelize.define( + 'Ticker', + { + exchange: { + type: DataTypes.STRING(255), + allowNull: true, + primaryKey: true + }, + symbol: { + type: DataTypes.STRING(255), + allowNull: true, + primaryKey: true + }, + ask: { + type: DataTypes.REAL, + allowNull: true + }, + bid: { + type: DataTypes.REAL, + allowNull: true + } + }, + { + tableName: 'tickers' + } + ); - insertTickers(tickers) { - return new Promise(resolve => { - const upsert = this.db.prepare( - 'INSERT INTO ticker(exchange, symbol, ask, bid, updated_at) VALUES ($exchange, $symbol, $ask, $bid, $updated_at) ' + - 'ON CONFLICT(exchange, symbol) DO UPDATE SET ask=$ask, bid=$bid, updated_at=$updated_at' - ); - - this.db.transaction(() => { - tickers.forEach(ticker => { - const parameters = { - exchange: ticker.exchange, - symbol: ticker.symbol, - ask: ticker.ask, - bid: ticker.bid, - updated_at: new Date().getTime() - }; - - upsert.run(parameters); - }); - })(); - - resolve(); + TickerRepository.insertTickers = function(tickers) { + return TickerRepository.bulkCreate(tickers, { + updateOnDuplicate: ['ask', 'bid'] }); - } + }; + + return TickerRepository; }; diff --git a/src/modules/services.js b/src/modules/services.js index f8d73a273..4a472dd82 100644 --- a/src/modules/services.js +++ b/src/modules/services.js @@ -1,10 +1,12 @@ -const fs = require('fs'); +const Fs = require('fs'); +const Path = require('path'); const events = require('events'); const { createLogger, transports, format } = require('winston'); +const WinstonTransportSequelize = require('winston-transport-sequelize'); const _ = require('lodash'); -const Sqlite = require('better-sqlite3'); +const Sequelize = require('sequelize'); const Notify = require('../notify/notify'); const Slack = require('../notify/slack'); const Mail = require('../notify/mail'); @@ -19,11 +21,8 @@ const TickerDatabaseListener = require('../modules/listener/ticker_database_list const ExchangeOrderWatchdogListener = require('../modules/listener/exchange_order_watchdog_listener'); const ExchangePositionWatcher = require('../modules/exchange/exchange_position_watcher'); -const SignalLogger = require('../modules/signal/signal_logger'); const SignalHttp = require('../modules/signal/signal_http'); -const SignalRepository = require('../modules/repository/signal_repository'); -const CandlestickRepository = require('../modules/repository/candlestick_repository'); const StrategyManager = require('./strategy/strategy_manager'); const ExchangeManager = require('./exchange/exchange_manager'); @@ -42,11 +41,7 @@ const PairStateExecution = require('../modules/pairs/pair_state_execution'); const PairConfig = require('../modules/pairs/pair_config'); const SystemUtil = require('../modules/system/system_util'); const TechnicalAnalysisValidator = require('../utils/technical_analysis_validator'); -const WinstonSqliteTransport = require('../utils/winston_sqlite_transport'); const LogsHttp = require('./system/logs_http'); -const LogsRepository = require('../modules/repository/logs_repository'); -const TickerLogRepository = require('../modules/repository/ticker_log_repository'); -const TickerRepository = require('../modules/repository/ticker_repository'); const CandlestickResample = require('../modules/system/candlestick_resample'); const RequestClient = require('../utils/request_client'); const Queue = require('../utils/queue'); @@ -84,7 +79,6 @@ let tickListener; let createOrderListener; let exchangeOrderWatchdogListener; -let signalLogger; let signalHttp; let signalRepository; @@ -106,7 +100,6 @@ let systemUtil; let technicalAnalysisValidator; let logsHttp; let logsRepository; -let tickerLogRepository; let candlestickResample; let exchanges; let requestClient; @@ -135,12 +128,14 @@ module.exports = { } try { - config = JSON.parse(fs.readFileSync(`${parameters.projectDir}/conf.json`, 'utf8')); + config = JSON.parse(Fs.readFileSync(Path.join(parameters.projectDir, 'conf.json'), 'utf8')); } catch (e) { throw new Error(`Invalid conf.json file. Please check: ${String(e)}`); } this.getDatabase(); + this.getLogger(); + await db.sequelize.sync(); }, getDatabase: () => { @@ -148,13 +143,22 @@ module.exports = { return db; } - const myDb = Sqlite('bot.db'); - myDb.pragma('journal_mode = WAL'); + const env = process.env.NODE_ENV || 'development'; + const dbSettings = _.get(config, `${env}`); + const sequelize = new Sequelize(dbSettings.database, dbSettings.user, dbSettings.password, dbSettings); - myDb.pragma('SYNCHRONOUS = 1;'); - myDb.pragma('LOCKING_MODE = EXCLUSIVE;'); + db = {}; + Fs.readdirSync(Path.join(parameters.projectDir, 'src', 'modules', 'repository')) + .filter(file => file.indexOf('.') !== 0 && file !== 'index.js') + .forEach(file => { + const model = sequelize.import(Path.join(parameters.projectDir, 'src', 'modules', 'repository', file)); + db[model.name] = model; + }); - return (db = myDb); + db.sequelize = sequelize; + db.Sequelize = Sequelize; + + return db; }, getTa: function() { @@ -202,7 +206,8 @@ module.exports = { return createOrderListener; } - return (createOrderListener = new CreateOrderListener(this.getExchangeManager(), this.getLogger())); + createOrderListener = new CreateOrderListener(this.getExchangeManager(), this.getLogger()); + return createOrderListener; }, getTickListener: function() { @@ -210,17 +215,18 @@ module.exports = { return tickListener; } - return (tickListener = new TickListener( + tickListener = new TickListener( this.getTickers(), this.getInstances(), this.getNotifier(), - this.getSignalLogger(), + this.getSignalRepository(), this.getStrategyManager(), this.getExchangeManager(), this.getPairStateManager(), this.getLogger(), this.getSystemUtil() - )); + ); + return tickListener; }, getExchangeOrderWatchdogListener: function() { @@ -228,7 +234,7 @@ module.exports = { return exchangeOrderWatchdogListener; } - return (exchangeOrderWatchdogListener = new ExchangeOrderWatchdogListener( + exchangeOrderWatchdogListener = new ExchangeOrderWatchdogListener( this.getExchangeManager(), this.getInstances(), this.getStopLossCalculator(), @@ -237,7 +243,8 @@ module.exports = { this.getPairStateManager(), this.getLogger(), this.getTickers() - )); + ); + return exchangeOrderWatchdogListener; }, getTickerDatabaseListener: function() { @@ -245,15 +252,8 @@ module.exports = { return tickerDatabaseListener; } - return (tickerDatabaseListener = new TickerDatabaseListener(this.getTickerRepository())); - }, - - getSignalLogger: function() { - if (signalLogger) { - return signalLogger; - } - - return (signalLogger = new SignalLogger(this.getSignalRepository())); + tickerDatabaseListener = new TickerDatabaseListener(this.getTickerRepository()); + return tickerDatabaseListener; }, getSignalHttp: function() { @@ -261,7 +261,8 @@ module.exports = { return signalHttp; } - return (signalHttp = new SignalHttp(this.getSignalRepository())); + signalHttp = new SignalHttp(this.getSignalRepository()); + return signalHttp; }, getSignalRepository: function() { @@ -269,7 +270,8 @@ module.exports = { return signalRepository; } - return (signalRepository = new SignalRepository(this.getDatabase())); + signalRepository = this.getDatabase().Signal; + return signalRepository; }, getCandlestickRepository: function() { @@ -277,7 +279,8 @@ module.exports = { return candlestickRepository; } - return (candlestickRepository = new CandlestickRepository(this.getDatabase())); + candlestickRepository = this.getDatabase().Candlestick; + return candlestickRepository; }, getEventEmitter: function() { @@ -293,7 +296,14 @@ module.exports = { return logger; } - return (logger = createLogger({ + const options = { + sequelize: this.getDatabase().sequelize, // sequelize instance [required] + tableName: 'logs', // default name + level: 'debug' + }; + + const winstonTransportSequelize = new WinstonTransportSequelize(options); + logger = createLogger({ format: format.combine(format.timestamp(), format.json()), transports: [ new transports.File({ @@ -303,13 +313,11 @@ module.exports = { new transports.Console({ level: 'error' }), - new WinstonSqliteTransport({ - level: 'debug', - database_connection: this.getDatabase(), - table: 'logs' - }) + winstonTransportSequelize ] - })); + }); + this.getDatabase().Log = winstonTransportSequelize.model; + return logger; }, getNotifier: function() { @@ -477,7 +485,7 @@ module.exports = { return logsRepository; } - return (logsRepository = new LogsRepository(this.getDatabase())); + return (logsRepository = this.getDatabase().Log); }, getLogsHttp: function() { @@ -488,20 +496,12 @@ module.exports = { return (logsHttp = new LogsHttp(this.getLogsRepository())); }, - getTickerLogRepository: function() { - if (tickerLogRepository) { - return tickerLogRepository; - } - - return (tickerLogRepository = new TickerLogRepository(this.getDatabase())); - }, - getTickerRepository: function() { if (tickerRepository) { return tickerRepository; } - return (tickerRepository = new TickerRepository(this.getDatabase(), this.getLogger())); + return (tickerRepository = this.getDatabase().Ticker); }, getCandlestickResample: function() { @@ -646,7 +646,6 @@ module.exports = { this.getPairStateExecution(), this.getSystemUtil(), this.getLogsRepository(), - this.getTickerLogRepository(), this.getExchangePositionWatcher() ); }, diff --git a/src/modules/signal/signal_http.js b/src/modules/signal/signal_http.js index 4010ef044..2fbb084c6 100644 --- a/src/modules/signal/signal_http.js +++ b/src/modules/signal/signal_http.js @@ -4,6 +4,6 @@ module.exports = class SignalHttp { } async getSignals(since) { - return await this.signalRepository.getSignals(since); + return this.signalRepository.getSignals(since); } }; diff --git a/src/modules/signal/signal_logger.js b/src/modules/signal/signal_logger.js deleted file mode 100644 index 277713369..000000000 --- a/src/modules/signal/signal_logger.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = class SignalLogger { - constructor(signalRepository) { - this.signalRepository = signalRepository; - } - - signal(exchange, symbol, options, side, strategy) { - this.signalRepository.insertSignal(exchange, symbol, options, side, strategy); - } -}; diff --git a/src/modules/system/candle_export_http.js b/src/modules/system/candle_export_http.js index 792888a91..cd16801a7 100644 --- a/src/modules/system/candle_export_http.js +++ b/src/modules/system/candle_export_http.js @@ -4,7 +4,13 @@ module.exports = class CandleExportHttp { } async getCandles(exchange, symbol, period, start, end) { - return this.candlestickRepository.getCandlesInWindow(exchange, symbol, period, start, end); + return this.candlestickRepository.getCandlesInWindow( + exchange, + symbol, + period, + start.getTime() / 1000, + end.getTime() / 1000 + ); } async getPairs() { diff --git a/src/modules/system/logs_http.js b/src/modules/system/logs_http.js index 2bc2b3274..1a967b17c 100644 --- a/src/modules/system/logs_http.js +++ b/src/modules/system/logs_http.js @@ -1,27 +1,23 @@ -const _ = require('lodash'); +const Datatable = require('sequelize-datatables'); module.exports = class LogsHttp { constructor(logsRepository) { this.logsRepository = logsRepository; } - async getLogsPageVariables(request, response) { - let excludeLevels = request.query.exclude_levels || []; - - if (excludeLevels.length === 0 && !('filters' in request.cookies)) { - excludeLevels = ['debug']; - } - - response.cookie('filters', excludeLevels, { - maxAge: 60 * 60 * 24 * 30 * 1000 - }); - + async getLogsPageVariables() { return { - logs: await this.logsRepository.getLatestLogs(excludeLevels), - levels: await this.logsRepository.getLevels(), - form: { - excludeLevels: excludeLevels - } + levels: await this.logsRepository + .findAll({ + attributes: ['level'], + group: ['level'], + raw: true + }) + .map(row => row.level) }; } + + async getLogsData(request) { + return Datatable(this.logsRepository, request.body, undefined, { replaceRegexp: true }); + } }; diff --git a/src/modules/trade.js b/src/modules/trade.js index 9cc453bff..ab57fb074 100644 --- a/src/modules/trade.js +++ b/src/modules/trade.js @@ -18,7 +18,6 @@ module.exports = class Trade { pairStateExecution, systemUtil, logsRepository, - tickerLogRepository, exchangePositionWatcher ) { this.eventEmitter = eventEmitter; @@ -34,7 +33,6 @@ module.exports = class Trade { this.pairStateExecution = pairStateExecution; this.systemUtil = systemUtil; this.logsRepository = logsRepository; - this.tickerLogRepository = tickerLogRepository; this.exchangePositionWatcher = exchangePositionWatcher; } @@ -96,9 +94,7 @@ module.exports = class Trade { // cronjob like tasks setInterval(async () => { - await me.logsRepository.cleanOldLogEntries(); - await me.tickerLogRepository.cleanOldLogEntries(); - + // await me.logsRepository.cleanOldLogEntries(); me.logger.debug('Logs: Cleanup old entries'); }, 86455000); diff --git a/src/utils/winston_sqlite_transport.js b/src/utils/winston_sqlite_transport.js deleted file mode 100644 index 292d85627..000000000 --- a/src/utils/winston_sqlite_transport.js +++ /dev/null @@ -1,48 +0,0 @@ -const Transport = require('winston-transport'); - -module.exports = class WinstonSqliteTransport extends Transport { - constructor(opts) { - super(opts); - - if (!opts.database_connection) { - throw new Error('database_connection is needed'); - } - - if (!opts.table) { - throw new Error('table is needed'); - } - - this.db = opts.database_connection; - this.table = opts.table; - } - - log(info, callback) { - setImmediate(() => { - this.emit('logged', info); - }); - - const parameters = { - uuid: WinstonSqliteTransport.createUUID(), - level: info.level, - message: info.message, - created_at: Math.floor(Date.now() / 1000) - }; - - this.db - .prepare( - `INSERT INTO ${this.table}(uuid, level, message, created_at) VALUES ($uuid, $level, $message, $created_at)` - ) - .run(parameters); - - callback(); - } - - static createUUID() { - let dt = new Date().getTime(); - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = (dt + Math.random() * 16) % 16 | 0; - dt = Math.floor(dt / 16); - return (c == 'x' ? r : (r & 0x3) | 0x8).toString(16); - }); - } -}; diff --git a/templates/layout.html.twig b/templates/layout.html.twig index 6300a6031..7abaaa018 100644 --- a/templates/layout.html.twig +++ b/templates/layout.html.twig @@ -17,10 +17,15 @@ scratch. This page gets rid of all links and provides the needed markup only. - - + + + + + + + {% block stylesheet %}{% endblock stylesheet %} @@ -102,11 +107,18 @@ scratch. This page gets rid of all links and provides the needed markup only. - - + - + + + + + + + + + +{% endblock javascript %} diff --git a/templates/orders/orders.html.twig b/templates/orders/orders.html.twig index 7281e58c6..ff8aca5e6 100644 --- a/templates/orders/orders.html.twig +++ b/templates/orders/orders.html.twig @@ -79,13 +79,11 @@

Orders

- - - +
- +
@@ -93,6 +91,8 @@ + + @@ -109,10 +109,10 @@ {% endif %} + + {% endfor %} diff --git a/templates/pairs.html.twig b/templates/pairs.html.twig index 6408897b1..6143a5fed 100644 --- a/templates/pairs.html.twig +++ b/templates/pairs.html.twig @@ -31,63 +31,8 @@
-
TypeAmount Side CancelExchangeID
- - - + {{ exchange }}{{ order.id }}
- - - - - - - - - - - - - - - {% for pair in pairs %} - - - - - - - - - - - {% endfor %} - -
ExchangeSymbolStateCapitalStrategiesOptionsProcessLimit
{{ pair.exchange }}{{ pair.symbol }}{{ pair.state }}{{ pair.capital }}{{ pair.strategies|json_encode }} - - {% if pair.watchdogs %} - {{ pair.watchdogs|json_encode }} - {% endif %} - - {{ pair.process }} -
-
- {% if pair.has_position|default(false) == false and not pair.process %} - - - - - - - {% endif %} - - {% if pair.has_position|default(false) and not pair.process %} - - - {% endif %} - - {% if pair.process|default %} - - {% endif %} -
-
-
+ +
@@ -101,3 +46,7 @@ {% endblock %} +{% block javascript %} +{{ parent() }} + +{% endblock javascript %} diff --git a/templates/signals.html.twig b/templates/signals.html.twig index 9f5b4cf1f..159c5663d 100644 --- a/templates/signals.html.twig +++ b/templates/signals.html.twig @@ -59,7 +59,7 @@ {% endif %} {{ signal.strategy }} - {{ signal.income_at|date('d.m.y H:i') }} + {{ signal.updatedAt|date('d.m.y H:i') }} {{ signal.state }} {{ signal.options }} diff --git a/templates/trades.html.twig b/templates/trades.html.twig index b3a260e01..5d48dbd85 100644 --- a/templates/trades.html.twig +++ b/templates/trades.html.twig @@ -34,74 +34,9 @@
-
- - - - - - - - - - - - - - - - - - {% for position in positions %} - - - - - - - - - - - - - {% endfor %} - -
ExchangeSymbolAmountCurrencyProfitEntryUpdatedAtCreatedAtSideAction
{{ position.exchange }}{{ position.position.symbol }} - {{ position.position.amount|price_format }} - - {% if position.currency %}{{ position.currency|price_format }}{% endif %} - - {% if position.position.profit is defined %} - {{ position.position.profit|round(2) }} % - {% endif %} - - {% if position.position.entry %} - {{ position.position.entry|price_format }} - {% endif %} - - {% if position.position.updatedAt %} - {{ position.position.updatedAt|date('d.m.y H:i') }} - {% endif %} - - {% if position.position.createdAt %} - {{ position.position.createdAt|date('d.m.y H:i') }} - {% endif %} - - {% if position.position.side == 'short' %} - - {% elseif position.position.side == 'long' %} - - {% endif %} - -
- -
-
-
+ + +
@@ -113,53 +48,8 @@
- - - - - - - - - - - - - - - - - - - - - {% for order in orders %} - - - - - - - - - - - - - - - - {% endfor %} - -
ExchangeSymbolTypeIDPriceAmountRetryOurIdCreatedAtUpdatedAtStatusSideAction
{{ order.exchange }}{{ order.order.symbol }}{{ order.order.type }}{{ order.order.id }}{{ order.order.price }}{{ order.order.amount }}{{ order.order.retry }}{{ order.order.ourId }}{{ order.order.createdAt|date('d.m.y H:i') }}{{ order.order.updateAt|date('d.m.y H:i') }}{{ order.order.status }} - {% if order.order.side == 'buy' %} - - {% elseif order.order.side == 'sell' %} - - {% endif %} - - -
+ +
@@ -173,3 +63,7 @@ {% endblock %} +{% block javascript %} +{{ parent() }} + +{% endblock javascript %} diff --git a/test/modules/listener/tick_listener.test.js b/test/modules/listener/tick_listener.test.js index 0702abf1b..e6ff6358d 100644 --- a/test/modules/listener/tick_listener.test.js +++ b/test/modules/listener/tick_listener.test.js @@ -11,7 +11,7 @@ describe('#tick listener for order', function() { { get: () => new Ticker() }, {}, { send: () => {} }, - { signal: () => {} }, + { insertSignal: () => {} }, { executeStrategy: async () => { return SignalResult.createSignal('short', {}); @@ -56,7 +56,7 @@ describe('#tick listener for order', function() { {}, { send: () => {} }, { - signal: (exchange, symbol, opts, signal, strategyKey) => { + insertSignal: (exchange, symbol, opts, signal, strategyKey) => { calls.push(exchange, symbol, opts, signal, strategyKey); return []; } diff --git a/web/static/css/style.css b/web/static/css/style.css index 7a44a3064..9388c6884 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -53,4 +53,12 @@ nav { -ms-user-select: none; user-select: none; } + +#tradesTable button, #ordersTable button, #pairsTable button { + padding: 0 0 0 3px; +} + +.breakAll { + overflow-wrap: anywhere; +} /*end base*/ diff --git a/web/static/js/customdatarenderer.js b/web/static/js/customdatarenderer.js new file mode 100644 index 000000000..177a37b5e --- /dev/null +++ b/web/static/js/customdatarenderer.js @@ -0,0 +1,127 @@ +jQuery.fn.dataTable.render.greenRed = function(format = {}) { + return function(d, type, row) { + // Order, search and type get the original data + if (type !== 'display') { + return d; + } + + if (typeof d !== 'number' && typeof d !== 'string') { + return d; + } + const dd = format.style === 'percent' ? parseFloat(d) / 100 : parseFloat(d); + return `${dd.toLocaleString(undefined, format)}`; + }; +}; + +jQuery.fn.dataTable.render.tradingviewLink = function(exchangeColumn) { + return function(d, type, row) { + // Order, search and type get the original data + if (type !== 'display') { + return d; + } + + if (typeof d !== 'string' || !exchangeColumn) { + return d; + } + + return `${d}`; + }; +}; + +jQuery.fn.dataTable.render.highlightProfit = function() { + return function(data, type, row) { + // Order, search and type get the original data + if (type !== 'display') { + return data; + } + + if (typeof data !== 'string') { + return data; + } + + const profit = data.match(/(?<=profit":)(-?\d+.\d+)/); + if (!profit) { + return data; + } + + return data.replace(profit[0], '' + profit[0]+ '') + }; +}; + + +jQuery.fn.dataTable.render.highlightProfit = function() { + return function(data, type, row) { + // Order, search and type get the original data + if (type !== 'display') { + return data; + } + + if (typeof data !== 'string') { + return data; + } + + const profit = data.match(/(?<=profit":)(-?\d+.\d+)/); + if (!profit) { + return data; + } + + return data.replace(profit[0], '' + profit[0]+ '') + }; +}; + +// Renders arrow: green up if data == 'long', else red down. +jQuery.fn.dataTable.render.arrows = function() { + return function(data, type, row) { + // Order, search and type get the original data + if (type !== 'display') { + return data; + } + + if (typeof data !== 'string') { + return data; + } + + return (data === 'short' ? `` : + ``) + }; +}; + +jQuery.fn.dataTable.render.JSON = function() { + return function(data, type, row) { + // Order, search and type get the original data + if (type !== 'display') { + return data; + } + + return JSON.stringify(data); + }; +}; + +// Renders buttons for actions +// Parameters: +// assetType - controller name to handle action +// buttonss - an object containing { action : `ActionTitle`, action2 ....} +// or string value to reference row column containing actions JSON objec. +jQuery.fn.dataTable.render.actionButtons = function(showLable = false) { + return function(data, type, row) { + // Order, search and type get the original data + if (type !== 'display') { + return data; + } + + const btnConfig = { + short: { title: `Limit Short`, btnClass: `btn-danger`, iClass: `fa-shopping-cart` }, + long: { title: `Limit Long`, btnClass: `btn-success`, iClass: `fa-cart-plus` }, + short_market: { title: `Market Short`, btnClass: `btn-danger`, iClass: `fa-shopping-cart` }, + long_market: { title: `Market Long`, btnClass: `btn-success`, iClass: `fa-cart-plus` }, + close: { title: `Limit Close`, btnClass: ``, iClass: `fa-window-close` }, + close_market: { title: `Market Close`, btnClass: ``, iClass: `fa-window-close` }, + cancel: { title: `Close`, btnClass: ``, iClass: `fas fa-window-close text-dark` } + }; + const actions = typeof data == 'string' ? [data] : data; + let results = actions.map(action => + `` + ); + return `
` + results.join('') + `
` + }; +}; diff --git a/web/static/js/logs.js b/web/static/js/logs.js new file mode 100644 index 000000000..d1873c7cd --- /dev/null +++ b/web/static/js/logs.js @@ -0,0 +1,53 @@ +$(function() { + function getExcludeFilter() { + // build a regex filter string with an or(|) condition + const positions = $('input:checkbox[name="logExcludeLevels"]:checked') + .map(function() { + return this.value; + }) + .get() + .join('|'); + + return positions ? `^((?!${positions}).*)$` : ''; + } + + const logsTable = $('#logsTable').DataTable({ + paging: true, + pageLength: 10, + responsive: true, + serverSide: true, + ajax: { + type: 'POST', + url: '/logsTable' + }, + columns: [ + { data: 'level', title: 'Level' }, + { data: 'message', title: 'Message' }, + { data: 'createdAt', title: 'CreatedAt' } + ], + order: [[2, 'desc']], + columnDefs: [ + { render: $.fn.dataTable.render.highlightProfit(), targets: 1 }, + { render: $.fn.dataTable.render.moment('YYYY-MM-DDTHH:mm:ss.SSSZ', 'YYYY-MM-DD HH:mm:ss'), targets: 2 }, + { className: 'breakAll', targets: 1 }, + { responsivePriority: 10001, targets: [0, 2] } + ] + }); + + // do not break words on header + logsTable.on('draw', function() { + logsTable + .column(1) + .header() + .classList.remove('breakAll'); + }); + + $('input:checkbox').on('change', function() { + // filter in column 1 by removing checked items, with an regex, no smart filtering, not case sensitive + // Example: ^((?!word1|word2).*)$ + logsTable + .column(0) + .search(getExcludeFilter(), true, false, false) + .draw(false); + }); +}); diff --git a/web/static/js/orders.js b/web/static/js/orders.js index c22f50857..1ad7acde8 100644 --- a/web/static/js/orders.js +++ b/web/static/js/orders.js @@ -39,4 +39,44 @@ $(function() { scope.find('#amount').val((value / assetPrice).toFixed(8)); // precision (tick / lot size?) } }); + + const ordersTable = $('#ordersTable').DataTable({ + bFilter: false, + paging: false, + info: false, + responsive: true, + columnDefs: [ + { + targets: [5, 6], + visible: false, + searchable: false + } + ] + }); + + // bind button actions + $('#ordersTable tbody').on('click', 'button', function() { + const data = ordersTable.row($(this).parents('tr')).data(); + $.ajax({ + url: `/orders/${data[5]}/${data[6]}`, + type: 'DELETE', + success: function(result) { + // ordersTable.ajax.reload(); + location.reload(); + } + }); + }); + + $('button.cancel-all').on('click', 'button', function() { + const data = ordersTable.rows(0).data(); + $.ajax({ + url: `/orders/${data[5]}`, + type: 'DELETE', + success: function(result) { + // ordersTable.ajax.reload(); + location.reload(); + } + }); + }); + }); diff --git a/web/static/js/pairs.js b/web/static/js/pairs.js new file mode 100644 index 000000000..e2fb068f5 --- /dev/null +++ b/web/static/js/pairs.js @@ -0,0 +1,34 @@ +$(function() { + const pairsTable = $('#pairsTable').DataTable({ + bFilter: false, + paging: false, + info: false, + serverSide: true, + ajax: { + type: 'GET', + url: '/pairs/trade' + }, + columns: [ + { data: 'exchange', title: 'Exchange' }, + { data: 'symbol', title: 'Symbol' }, + { data: 'state', title: 'State' }, + { data: 'capital', title: 'Capital' }, + { data: 'strategies', title: 'Strategies' }, + { data: 'watchdogs', title: 'Options', defaultContent: '' }, + { data: 'process', title: 'Process', defaultContent: '' }, + { data: 'actions', title: 'Actions' } + // { data: 'actions', visible: false } + ], + order: [[1, 'desc']], + columnDefs: [ + { render: $.fn.dataTable.render.tradingviewLink('exchange'), targets: 1 }, + { render: $.fn.dataTable.render.JSON(), targets: [4, 5] }, + { render: $.fn.dataTable.render.actionButtons(), targets: 7 } + ] + }); + + $('#pairsTable tbody').on('click', 'button', function() { + const data = pairsTable.row($(this).parents('tr')).data(); + $.get(`/pairs/${data.exchange}/${data.symbol}/${this.value}`); + }); +}); diff --git a/web/static/js/trades.js b/web/static/js/trades.js new file mode 100644 index 000000000..176f09c2d --- /dev/null +++ b/web/static/js/trades.js @@ -0,0 +1,90 @@ +$(function() { + const tradesTable = $('#tradesTable').DataTable({ + bFilter: false, + paging: false, + info: false, + ordering: false, + serverSide: true, + ajax: { + type: 'GET', + url: '/trades/positions' + }, + columns: [ + { data: 'exchange', title: 'Exchange' }, + { data: 'position.symbol', title: 'Symbol' }, + { data: 'position.side', title: 'Side' }, + { data: 'position.amount', title: 'Amount' }, + { data: 'currency', title: 'Currency' }, + { data: 'position.profit', title: 'Profit', defaultContent: '' }, + { data: 'position.entry', title: 'Entry' }, + { data: 'position.updatedAt', title: 'UpdatedAt' }, + { data: 'position.createdAt', title: 'CreatedAt' }, + { data: 'actions', title: 'Actions' } + ], + order: [[1, 'desc']], + columnDefs: [ + { render: $.fn.dataTable.render.tradingviewLink('exchange'), targets: 1 }, + { render: $.fn.dataTable.render.arrows(), targets: 2 }, + { render: $.fn.dataTable.render.greenRed({ minimumFractionDigits: 2 }), targets: 3 }, + { render: $.fn.dataTable.render.number('', '.', 2), targets: [4, 6] }, + { render: $.fn.dataTable.render.greenRed({ style: 'percent', minimumFractionDigits: 2 }), targets: 5 }, + { render: $.fn.dataTable.render.moment('YYYY-MM-DDTHH:mm:ss.SSSZ', 'YYYY-MM-DD HH:mm:ss'), targets: [7, 8] }, + { render: $.fn.dataTable.render.actionButtons(), targets: 9 }, + { responsivePriority: 10001, targets: [7, 8] } + ] + }); + + // bind button actions + $('#tradesTable tbody').on('click', 'button', function() { + const data = tradesTable.row($(this).parents('tr')).data(); + $.get(`/pairs/${data.exchange}/${data.position.symbol}/${this.value}`); + }); + + const ordersTable = $('#ordersTable').DataTable({ + bFilter: false, + paging: false, + info: false, + ordering: false, + serverSide: true, + ajax: { + type: 'GET', + url: '/trades/orders' + }, + columns: [ + { data: 'exchange', title: 'Exchange' }, + { data: 'order.symbol', title: 'Symbol' }, + { data: 'order.side', title: 'Side' }, + { data: 'order.amount', title: 'Amount' }, + { data: 'order.price', title: 'Price' }, + { data: 'order.type', title: 'Type' }, + { data: 'order.id', title: 'ID' }, + { data: 'order.retry', title: 'Retry' }, + { data: 'order.ourId', title: 'OurId' }, + { data: 'order.createdAt', title: 'CreatedAt' }, + { data: 'order.updatedAt', title: 'UpdatedAt' }, + { data: 'order.status', title: 'Status' }, + { data: 'actions', title: 'Actions' } + ], + order: [[1, 'desc']], + columnDefs: [ + { render: $.fn.dataTable.render.tradingviewLink('exchange'), targets: 1 }, + { render: $.fn.dataTable.render.arrows(), targets: 2 }, + { render: $.fn.dataTable.render.greenRed({ minimumFractionDigits: 2 }), targets: 3 }, + { render: $.fn.dataTable.render.number('', '.', 2), targets: 4 }, + { render: $.fn.dataTable.render.moment('YYYY-MM-DDTHH:mm:ss.SSSZ', 'YYYY-MM-DD HH:mm:ss'), targets: [9, 10] }, + { render: $.fn.dataTable.render.actionButtons(), targets: 12 } + ] + }); + + // bind button actions + $('#ordersTable tbody').on('click', 'button', function() { + const data = ordersTable.row($(this).parents('tr')).data(); + $.ajax({ + url: `/orders/${data.exchange}/${data.order.id}`, + type: 'DELETE', + success: function() { + ordersTable.ajax.reload(); + } + }); + }); +});