├── .eslintrc.cjs ├── .github ├── dependabot.yml └── workflows │ ├── eslint.yml │ └── test.js.yml ├── .gitignore ├── .mocharc.json ├── LICENSE ├── README.md ├── cancel-orders-mainnet.js ├── cancel-orders-testnet.js ├── config ├── custom-environment-variables.json ├── default.json └── test.json ├── package-lock.json ├── package.json ├── src ├── core │ └── constants.ts ├── dexapi.ts ├── dexrpc.ts ├── index.ts ├── interfaces │ ├── config.interface.ts │ ├── index.ts │ ├── order.interface.ts │ └── strategy.interface.ts ├── slackapi.ts ├── strategies │ ├── base.ts │ ├── gridbot.ts │ ├── index.ts │ └── marketmaker.ts └── utils.ts ├── test ├── dexapi.test.ts └── strategies │ └── marketmaker.test.ts └── tsconfig.json /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: false, 4 | es2021: true, 5 | mocha: true, 6 | }, 7 | extends: [ 8 | 'airbnb-base', 9 | ], 10 | overrides: [ 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 'latest', 14 | sourceType: 'module', 15 | }, 16 | plugins: ['mocha'], 17 | rules: { 18 | // override to not flag .js imports as errors 19 | 'import/extensions': ['error', 'ignorePackages', { js: 'always' }], 20 | 'mocha/no-skipped-tests': 'error', 21 | 'mocha/no-exclusive-tests': 'error', 22 | 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], 23 | }, 24 | settings: { 25 | 'mocha/additionalCustomNames': [ 26 | { name: 'describeModule', type: 'suite', interfaces: ['BDD'] }, 27 | { name: 'testModule', type: 'testCase', interfaces: ['TDD'] }, 28 | ], 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/eslint.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # ESLint is a tool for identifying and reporting on patterns 6 | # found in ECMAScript/JavaScript code. 7 | # More details at https://github.com/eslint/eslint 8 | # and https://eslint.org 9 | 10 | name: ESLint 11 | 12 | on: 13 | push: 14 | branches: [ "main" ] 15 | pull_request: 16 | # The branches below must be a subset of the branches above 17 | branches: [ "main" ] 18 | schedule: 19 | - cron: '41 20 * * 1' 20 | 21 | jobs: 22 | eslint: 23 | name: Run eslint scanning 24 | runs-on: ubuntu-latest 25 | permissions: 26 | contents: read 27 | security-events: write 28 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v3 32 | 33 | - name: Install ESLint 34 | run: | 35 | npm install eslint@8.10.0 36 | npm install @microsoft/eslint-formatter-sarif@2.1.7 37 | 38 | - name: Run ESLint 39 | run: npx eslint . 40 | --config .eslintrc.cjs 41 | --ext .js,.jsx,.ts,.tsx 42 | --format @microsoft/eslint-formatter-sarif 43 | --output-file eslint-results.sarif 44 | 45 | - name: Upload analysis results to GitHub 46 | uses: github/codeql-action/upload-sarif@v2 47 | if: success() || failure() 48 | with: 49 | sarif_file: eslint-results.sarif 50 | wait-for-processing: true 51 | -------------------------------------------------------------------------------- /.github/workflows/test.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Run Tests 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 19.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: NODE_ENV=test npm run test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | npm-debug.log 4 | .nyc 5 | .env 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension": ["ts"], 3 | "spec": "test/strategies/marketmaker.test.ts", 4 | "loader": "ts-node/esm" 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Maura Wilder 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dexbot 2 | 3 | This is the code for both market maker and grid trading bot strategies against the MetalX.com DEX 4 | ### API and docs information 5 | Website: https://metalx.com. 6 | 7 | App: https://app.metalx.com 8 | 9 | Docs: https://docs.metalx.com. 10 | 11 | API Reference: https://docs.metalx.com/dex/what-is-metal-x 12 | 13 | [![ESLint SAST scan workflow](https://github.com/squdgy/dexbot/actions/workflows/eslint.yml/badge.svg?event=push)](https://github.com/squdgy/dexbot/security/code-scanning) 14 | 15 | ![Tests](https://github.com/squdgy/dexbot/actions/workflows/test.js.yml/badge.svg?event=push) 16 | 17 | GRID BOT: 18 | Grid Trading Bots are programs that allow users to automatically buy at low and sell at high within a pre-set price range. When one sell order is fully executed, the Grid Trading Bot places a buy order in next round based on timeinterval set in tool at a lower grid level, and vice versa. The Grid Trading strategy might perform best in volatile markets, making profits through a series of orders as token’s price fluctuates. 19 | 20 | Working Model: 21 | Bot automatically buys low and sells high based on the parameters you have set. 22 | 23 | Example: 24 | "symbol": "XBTC_XMD", 25 | "upperLimit": 23000, 26 | "lowerLimit": 21000, 27 | "gridLevels": 10, 28 | "bidAmountPerLevel": 0.0001 29 | 30 | Above setting would set 10 grid levels with each grid size i.e. (23300 - 23100)/10 = 200 31 | Note: The orders closet to the sale price would be elimiated on placing Initial orders. 32 | 33 | Market Maker BOT: 34 | This bot works against multiple markets to place orders based on levels defined in settings. The purpose of the market making strategy is to put buy and sell orders on the DEX' order books. This strategy doesn’t care about which way the market’s going. The strategy places a ladder of sells at regular intervals above base price, and another ladder of buys beneath it. Use this as a reference and implement yor own trading algorithm. 35 | 36 | The bots has been tested on the mainnet with different pairs like XPR_XUSDC, XPR_XMD, and XETH_XMD etc. A new market can always be added under pairs section and restart bot to take effect. 37 | 38 | NOTE: Cancelling orders - Script called `cancel-orders-mainnet.js` and `cancel-orders-testnet.js` are available to cancel either market specific or all open orders by the user. 39 | 40 | NOTE: User balance and open orders can be integrated to slack channel on running gridbot based periodic intervals. Options slackBotToken and channelId needs to be updated in the config file. Slack bot token can be created by following the documentation at https://api.slack.com/authentication/basics and make sure that invite app into the slack channel 41 | 42 | ## Getting Started 43 | 44 | ### prerequisites 45 | - an XPR Network account (You can use WebAuth.com from the app store or online [WebAuth.com](https://wauth.co) 46 | - enough funds in your account to buy and/or sell in the market that you want to trade in 47 | 48 | ### run the code 49 | 1. `npm install` 50 | 1. Add your account name and private key to environment variables, eg 51 | ``` 52 | Mac and Linux: 53 | export PROTON_USERNAME=user1 54 | export PROTON_PRIVATE_KEY=private_key 55 | 56 | Windows using powershell: 57 | $env:PROTON_USERNAME = 'user1' 58 | $env:PROTON_PRIVATE_KEY = 'private_key' 59 | ``` 60 | 1. edit config/default.json to use the market you would like to trade in (symbol value) 61 | 1. `npm run bot` 62 | 1. To run on testnet: `npm run bot:test` (windows - `$env:NODE_ENV = 'test'` and `npm run bot`) 63 | 64 | ## config params 65 | config/default.json has other config values you can change 66 | ``` 67 | { 68 | "bot" : { 69 | // how often to attempt trade 70 | "tradeIntervalMS": "5000", 71 | 72 | // Slack bot token eg: xoxb-5672345689032-4846869117232-1clJ35VeuI2y3F1oczinKKHm 73 | "slackBotToken": "", 74 | // This argument can be a channel ID, a DM ID, a MPDM ID, or a group ID for the slack bot 75 | "channelId" = ''; 76 | 77 | // set to true in order to cancel all open orders when the bot shuts down 78 | "cancelOpenOrdersOnExit": false, 79 | 80 | // Enable this to true if you want to place orders always one level above/below depends on buy/sell order for the executed orders 81 | "gridPlacement": true, 82 | 83 | // strategy to be applied, marketmaker or gridbot 84 | "strategy": "gridBot", 85 | "marketMaker": { 86 | // represents pairs(markets ids) for the market maker strategy 87 | "pairs": [ 88 | // symbol: market to trade in 89 | 90 | // gridLevels: how many buy and how many sell orders to put on the books 91 | 92 | // gridInterval: interval(price step or spread between each grid level 0.01 = 1%) 93 | 94 | // base: base for start price to place order - AVERAGE: avg of highestBid and lowestAsk, BID: highestBid price 95 | // ASK: lowestAsk price, LAST: last price traded 96 | 97 | // orderSide: orderSide represents whether to place gird orders for BOTH(BUY and SELL) or BUY or SELL 98 | // Options are "BOTH", "BUY", "SELL" 99 | { 100 | "symbol": "XPR_XMD", 101 | "gridLevels": 3, 102 | "gridInterval": 0.01, 103 | "base": "BID" 104 | "orderSide": "BUY" 105 | }, 106 | { 107 | "symbol": "XETH_XMD", 108 | "gridLevels": 2, 109 | "gridInterval": 0.01, 110 | "base": "LAST" 111 | "orderSide": "SELL" 112 | } 113 | ] 114 | }, 115 | // represents pairs(markets ids) for the gridbot strategy 116 | "gridBot": { 117 | "pairs": [ 118 | // symbol: market to trade in 119 | // upperLimit: represents price - upper limit of the trading range 120 | // lowerLimit: represents price - upper limit of the trading range 121 | // gridLevels: number of orders to keep 122 | // bidAmountPerLevel: Amount to bid/ask per each level 123 | { 124 | "symbol": "XPR_XMD", 125 | "upperLimit": 0.0019, 126 | "lowerLimit": 0.0016, 127 | "gridLevels": 10, 128 | "bidAmountPerLevel": 800.00 129 | }, 130 | { 131 | "symbol": "XBTC_XMD", 132 | "upperLimit": 23000, 133 | "lowerLimit": 21000, 134 | "gridLevels": 10, 135 | "bidAmountPerLevel": 0.00006 136 | } 137 | ] 138 | }, 139 | // permissions on the key ex. active or owner 140 | "privateKeyPermission": "active" 141 | "rpc": { 142 | 143 | // endpoints for RPC API 144 | "endpoints" : [ 145 | "https://rpc.api.mainnet.metalx.com" 146 | ], 147 | 148 | // api for readonly dex api 149 | "apiRoot": "https://dex.api.mainnet.metalx.com/dex", 150 | 151 | // api for readonly proton api 152 | "lightApiRoot": "https://lightapi.eosamsterdam.net/api" 153 | } 154 | } 155 | } 156 | ``` 157 | 158 | ## Below actions used in this bot code base 159 | 160 | ### Markets 161 | - **fetchLatestPrice** - retrieves the latest price for a given symbol 162 | ``` 163 | const price = await fetchLatestPrice('XPR_XUSDC'); 164 | logger.info(price); 165 | ``` 166 | - **fetchMarkets** - retrieves all markets that exist on metalx trading 167 | ``` 168 | const response = await fetchMarkets(); 169 | logger.info(response); 170 | ``` 171 | - **fetchOrderBook** - retrieves order book data for a single market 172 | ``` 173 | const response = await fetchOrderBook('XBTC_XUSDC', 100, 0.01); 174 | logger.info(response); 175 | ``` 176 | - **fetchTrades** - retrieves trades on the given market 177 | ``` 178 | const response = await fetchTrades('XPR_XUSDC', 100, 0); 179 | logger.info(response); 180 | ``` 181 | 182 | ### Orders 183 | - **cancelOrder** - cancel a single order 184 | ``` 185 | const orderId = 966550; 186 | cancelOrder(orderId); 187 | ``` 188 | - **cancelAllOrders** - cancel all orders for a given user 189 | ``` 190 | cancelAllOrders(); 191 | ``` 192 | - **fetchOpenOrders** - retrieve all open orders for a given user 193 | ``` 194 | const response = await fetchOpenOrders(username); 195 | logger.info(response); 196 | ``` 197 | - **fetchOrderHistory** - retrieves order history for a given user 198 | ``` 199 | const response = await fetchOrderHistory('metallicus', 20, 0); 200 | logger.info(response); 201 | ``` 202 | - **prepareLimitOrder** - submit a buy or sell limit order to the dex in postonly mode (ensure it is a maker trade) 203 | ``` 204 | // place an order to sell XPR into USDC 205 | const quantity = 570; 206 | const price = 0.002020; 207 | prepareLimitOrder('XPR_XUSDC', ORDERSIDES.SELL, quantity, price); 208 | ``` 209 | 210 | ### Accounts 211 | - **fetchBalances** - retrieves all balances for a given user 212 | ``` 213 | const response = await fetchBalances('metallicus'); 214 | logger.info(response); 215 | ``` 216 | 217 | ### coding references 218 | - basics for a simple limit order placement, including signing: [https://docs.metalx.com/developers-dex/examples/submit-dex-order](https://docs.metalx.com/developers-dex/examples/submit-dex-order) 219 | - instructions on finding your private key: https://help.xprnetwork.org/hc/en-us/articles/4410313687703-How-do-I-backup-my-private-key-in-the-WebAuth-Wallet- 220 | - actions available on the DEX contract: https://docs.metalx.com/developers-dex/smart-contract/actions 221 | - general documentation on interacting with XPR Network contracts: https://docs.xprnetwork.org/ 222 | - base version imported from https://github.com/squdgy/dexbot 223 | -------------------------------------------------------------------------------- /cancel-orders-mainnet.js: -------------------------------------------------------------------------------- 1 | import { JsonRpc, Api, JsSignatureProvider, Serialize } from '@proton/js'; 2 | import fetch from 'node-fetch'; 3 | 4 | 5 | // ***** Need to update PRIVATE_KEY, market id and username ******** 6 | // To export private key from your wallet, follow: 7 | // https://help.proton.org/hc/en-us/articles/4410313687703-How-do-I-backup-my-private-key-in-the-WebAuth-Wallet- 8 | const PRIVATE_KEY = process.env.PROTON_PRIVATE_KEY; 9 | // To cancel all orders eg: const marketSymbol = '' : For specific pair set marketSymbol eg: const marketSymbol = 'XPR_XMD' 10 | const marketSymbol = ''; 11 | 12 | // Authorization 13 | const username = process.env.PROTON_USERNAME; 14 | const authorization = [ 15 | { 16 | actor: username, 17 | permission: 'active', 18 | }, 19 | ]; 20 | 21 | const apiRoot = 'https://dex.api.mainnet.metalx.com/dex'; 22 | const ENDPOINTS = ['https://rpc.api.mainnet.metalx.com']; 23 | // Initialize 24 | const rpc = new JsonRpc(ENDPOINTS); 25 | 26 | const api = new Api({ 27 | rpc, 28 | signatureProvider: new JsSignatureProvider([PRIVATE_KEY]), 29 | }); 30 | 31 | const fetchFromAPI = async (root, path, returnData = true) => { 32 | const response = await fetch(`${root}${path}`); 33 | const responseJson = await response.json(); 34 | if (returnData) { 35 | return responseJson.data; 36 | } 37 | return responseJson; 38 | }; 39 | 40 | const fetchOpenOrders = async (username, limit = 150, offset = 0) => { 41 | const openOrders = await fetchFromAPI( 42 | apiRoot, 43 | `/v1/orders/open?limit=${limit}&offset=${offset}&account=${username}`, 44 | ); 45 | return openOrders; 46 | }; 47 | 48 | const fetchMarkets = async () => { 49 | const marketData = await fetchFromAPI(apiRoot, '/v1/markets/all'); 50 | return marketData; 51 | }; 52 | 53 | const transact = (actions) => api.transact( 54 | { actions }, 55 | { 56 | blocksBehind: 300, 57 | expireSeconds: 3600, 58 | }, 59 | ); 60 | 61 | const createCancelAction = (orderId) => ({ 62 | account: 'dex', 63 | name: 'cancelorder', 64 | data: { 65 | account: username, 66 | order_id: orderId, 67 | }, 68 | authorization, 69 | }); 70 | 71 | const withdrawActions = async () => { 72 | let actions = []; 73 | actions.push( 74 | { 75 | account: 'dex', 76 | name: 'process', 77 | data: { 78 | q_size: 30, 79 | show_error_msg: 0, 80 | }, 81 | authorization, 82 | }, 83 | { 84 | account: 'dex', 85 | name: "withdrawall", 86 | data: { 87 | account: username, 88 | }, 89 | authorization, 90 | },); 91 | 92 | const response = await transact(actions); 93 | } 94 | 95 | const main = async () => { 96 | let cancelList = []; 97 | let i = 0; 98 | while(true) { 99 | const ordersList = await fetchOpenOrders(username, 150, 150 * i); 100 | if(!ordersList.length) break; 101 | cancelList.push(...ordersList); 102 | i++; 103 | } 104 | 105 | if (marketSymbol) { 106 | const allMarkets = await fetchMarkets(); 107 | const market = allMarkets.filter( 108 | (markets) => markets.symbol === marketSymbol, 109 | ); 110 | if (market === undefined) { 111 | throw new Error(`Market ${marketSymbol} does not exist`); 112 | } 113 | const marketOrders = cancelList.filter( 114 | (orders) => orders.market_id === market[0].market_id, 115 | ); 116 | if (!marketOrders.length) { 117 | console.log(`No orders to cancel for market symbol (${marketSymbol})`); 118 | return; 119 | } 120 | cancelList = marketOrders; 121 | } 122 | if(!cancelList.length) { 123 | console.log(`No orders to cancel`); 124 | return; 125 | } 126 | console.log(`Cancelling all (${cancelList.length}) orders`); 127 | const actions = cancelList.map((order) => createCancelAction(order.order_id)); 128 | const response = await transact(actions); 129 | withdrawActions(); 130 | return response; 131 | }; 132 | main(); 133 | -------------------------------------------------------------------------------- /cancel-orders-testnet.js: -------------------------------------------------------------------------------- 1 | import { JsonRpc, Api, JsSignatureProvider, Serialize } from '@proton/js'; 2 | import fetch from 'node-fetch'; 3 | 4 | 5 | // ***** Need to update PRIVATE_KEY, market id and username ******** 6 | // To export private key from your wallet, follow: 7 | // https://help.proton.org/hc/en-us/articles/4410313687703-How-do-I-backup-my-private-key-in-the-WebAuth-Wallet- 8 | const PRIVATE_KEY = process.env.PROTON_PRIVATE_KEY; 9 | // To cancel all orders eg: const marketSymbol = '' 10 | const marketSymbol = ''; 11 | 12 | // Authorization 13 | const username = process.env.PROTON_USERNAME; 14 | const authorization = [ 15 | { 16 | actor: username, 17 | permission: 'active', 18 | }, 19 | ]; 20 | 21 | const apiRoot = 'https://dex.api.testnet.metalx.com/dex'; 22 | const ENDPOINTS = ['https://rpc.api.testnet.metalx.com']; 23 | // Initialize 24 | const rpc = new JsonRpc(ENDPOINTS); 25 | 26 | const api = new Api({ 27 | rpc, 28 | signatureProvider: new JsSignatureProvider([PRIVATE_KEY]), 29 | }); 30 | 31 | const fetchFromAPI = async (root, path, returnData = true) => { 32 | const response = await fetch(`${root}${path}`); 33 | const responseJson = await response.json(); 34 | if (returnData) { 35 | return responseJson.data; 36 | } 37 | return responseJson; 38 | }; 39 | 40 | const fetchOpenOrders = async (username, limit = 150, offset = 0) => { 41 | const openOrders = await fetchFromAPI( 42 | apiRoot, 43 | `/v1/orders/open?limit=${limit}&offset=${offset}&account=${username}`, 44 | ); 45 | return openOrders; 46 | }; 47 | 48 | const fetchMarkets = async () => { 49 | const marketData = await fetchFromAPI(apiRoot, '/v1/markets/all'); 50 | return marketData; 51 | }; 52 | 53 | const transact = (actions) => api.transact( 54 | { actions }, 55 | { 56 | blocksBehind: 300, 57 | expireSeconds: 3600, 58 | }, 59 | ); 60 | 61 | const createCancelAction = (orderId) => ({ 62 | account: 'dex', 63 | name: 'cancelorder', 64 | data: { 65 | account: username, 66 | order_id: orderId, 67 | }, 68 | authorization, 69 | }); 70 | 71 | const withdrawActions = async () => { 72 | let actions = []; 73 | actions.push( 74 | { 75 | account: 'dex', 76 | name: 'process', 77 | data: { 78 | q_size: 30, 79 | show_error_msg: 0, 80 | }, 81 | authorization, 82 | }, 83 | { 84 | account: 'dex', 85 | name: "withdrawall", 86 | data: { 87 | account: username, 88 | }, 89 | authorization, 90 | },); 91 | 92 | const response = await transact(actions); 93 | } 94 | 95 | const main = async () => { 96 | let cancelList = []; 97 | let i = 0; 98 | while(true) { 99 | const ordersList = await fetchOpenOrders(username, 150, 150 * i); 100 | if(!ordersList.length) break; 101 | cancelList.push(...ordersList); 102 | i++; 103 | } 104 | 105 | if (marketSymbol) { 106 | const allMarkets = await fetchMarkets(); 107 | const market = allMarkets.filter( 108 | (markets) => markets.symbol === marketSymbol, 109 | ); 110 | if (market === undefined) { 111 | throw new Error(`Market ${marketSymbol} does not exist`); 112 | } 113 | const marketOrders = cancelList.filter( 114 | (orders) => orders.market_id === market[0].market_id, 115 | ); 116 | if (!marketOrders.length) { 117 | console.log(`No orders to cancel for market symbol (${marketSymbol})`); 118 | return; 119 | } 120 | cancelList = marketOrders; 121 | } 122 | if(!cancelList.length) { 123 | console.log(`No orders to cancel`); 124 | return; 125 | } 126 | console.log(`Cancelling all (${cancelList.length}) orders`); 127 | const actions = cancelList.map((order) => createCancelAction(order.order_id)); 128 | const response = await transact(actions); 129 | withdrawActions(); 130 | return response; 131 | }; 132 | main(); 133 | -------------------------------------------------------------------------------- /config/custom-environment-variables.json: -------------------------------------------------------------------------------- 1 | { 2 | "bot" : { 3 | "rpc": { 4 | "privateKey": "PROTON_PRIVATE_KEY" 5 | }, 6 | "username": "PROTON_USERNAME" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "bot" : { 3 | "tradeIntervalMS": "10000", 4 | "slackIntervalMS": "1000000", 5 | "slackBotToken": "", 6 | "channelId": "", 7 | "cancelOpenOrdersOnExit": false, 8 | "gridPlacement": true, 9 | "strategy": "gridBot", 10 | "marketMaker": { 11 | "pairs": [ 12 | { 13 | "symbol": "XPR_XMD", 14 | "gridLevels": 20, 15 | "gridInterval": 0.005, 16 | "base": "AVERAGE", 17 | "orderSide": "BOTH", 18 | "bidAmountPerLevel": 5 19 | }, 20 | { 21 | "symbol": "XBTC_XMD", 22 | "gridLevels": 10, 23 | "gridInterval": 0.005, 24 | "base": "BID", 25 | "orderSide": "BOTH", 26 | "bidAmountPerLevel": 10 27 | } 28 | ] 29 | }, 30 | "gridBot": { 31 | "pairs": [ 32 | { 33 | "symbol": "XPR_XMD", 34 | "upperLimit": 0.0050000, 35 | "lowerLimit": 0.0038000, 36 | "gridLevels": 5, 37 | "bidAmountPerLevel": 40000 38 | } 39 | ] 40 | }, 41 | "rpc": { 42 | "privateKeyPermission": "active", 43 | "endpoints" : [ 44 | "https://rpc.api.mainnet.metalx.com", 45 | "https://rpc.api.mainnet.metalx.com" 46 | ], 47 | "apiRoot": "https://dex.api.mainnet.metalx.com/dex", 48 | "lightApiRoot": "https://lightapi.eosamsterdam.net/api" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "bot" : { 3 | "rpc": { 4 | "endpoints" : [ 5 | "https://rpc.api.testnet.metalx.com" 6 | ], 7 | "apiRoot": "https://dex.api.testnet.metalx.com/dex", 8 | "lightApiRoot": "https://testnet-lightapi.eosams.xeos.me/api" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dexbot", 3 | "version": "1.0.0", 4 | "description": "trading bot against proton dex", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "bot": "tsc --noEmit && node --no-warnings=ExperimentalWarning --loader ts-node/esm ./src/index.ts", 9 | "bot:test": "cross-env NODE_ENV=test npm run bot", 10 | "lint": "eslint . --ext .js", 11 | "lint:fix": "eslint . --fix --ext .js", 12 | "test": "mocha" 13 | }, 14 | "keywords": [ 15 | "proton", 16 | "dex", 17 | "trade", 18 | "bot", 19 | "order", 20 | "crypto" 21 | ], 22 | "author": "Pravin Battu", 23 | "license": "MIT", 24 | "dependencies": { 25 | "@proton/js": "^27.3.0", 26 | "@proton/light-api": "^3.3.3", 27 | "@proton/wrap-constants": "^0.2.68", 28 | "@slack/bolt": "^3.12.2", 29 | "@slack/socket-mode": "^1.3.2", 30 | "@slack/web-api": "^6.8.1", 31 | "bignumber.js": "^9.1.1", 32 | "config": "^3.3.8", 33 | "node-fetch": "^3.3.0", 34 | "winston": "^3.8.2" 35 | }, 36 | "devDependencies": { 37 | "@types/chai": "^4.3.4", 38 | "@types/config": "^3.3.0", 39 | "@types/mocha": "^10.0.1", 40 | "chai": "^4.3.7", 41 | "cross-env": "^7.0.3", 42 | "eslint": "^8.32.0", 43 | "eslint-config-airbnb-base": "^15.0.0", 44 | "eslint-plugin-import": "^2.27.5", 45 | "eslint-plugin-mocha": "^10.1.0", 46 | "mocha": "^10.2.0", 47 | "ts-node": "^10.9.1", 48 | "typescript": "^4.9.5" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/core/constants.ts: -------------------------------------------------------------------------------- 1 | export { 2 | orderSide as ORDERSIDES, 3 | orderType as ORDERTYPES, 4 | fillType as FILLTYPES 5 | } from '@proton/wrap-constants' 6 | 7 | -------------------------------------------------------------------------------- /src/dexapi.ts: -------------------------------------------------------------------------------- 1 | import { Depth, Market, OrderHistory, Trade } from '@proton/wrap-constants'; 2 | import fetch from 'node-fetch'; 3 | import { getConfig } from './utils'; 4 | 5 | // Contains methods for interacting with the off-chain DEX API 6 | const { apiRoot } = getConfig().rpc; 7 | const { lightApiRoot } = getConfig().rpc; 8 | 9 | /** 10 | * Generic GET request to one of the APIs 11 | */ 12 | const fetchFromAPI = async (root: string, path: string, returnData = true, times = 3): Promise => { 13 | try { 14 | const response = await fetch(`${root}${path}`); 15 | const responseJson = await response.json() as any; 16 | if (returnData) { 17 | return responseJson.data as T; 18 | } 19 | return responseJson; 20 | } 21 | catch { 22 | if (times > 0) { 23 | times--; 24 | await fetchFromAPI(root, path, returnData, times); 25 | } else { 26 | throw new Error(" Not able to reach API server"); 27 | } 28 | } 29 | return {} as T; 30 | }; 31 | 32 | // export async const fetchFromAPI = async (root: string, path: string, returnData = true, retries: number): Promise => { 33 | // fetch(`${root}${path}`) 34 | // .then(res => { 35 | // if (res.ok) { 36 | // const responseJson = await res.json() as any; 37 | // if (returnData) { 38 | // return responseJson.data as T; 39 | // } 40 | // return responseJson; 41 | // } 42 | // if (retries > 0) { 43 | // return fetchFromAPI(root, path, returnData, retries) 44 | // } 45 | // throw new Error() 46 | // }) 47 | // .catch(error => console.error(error.message)) 48 | // } 49 | 50 | export const fetchMarkets = async (): Promise => { 51 | const marketData = await fetchFromAPI(apiRoot, '/v1/markets/all'); 52 | return marketData; 53 | }; 54 | 55 | /** 56 | * Return an orderbook for the provided market. Use a higher step number for low priced currencies 57 | */ 58 | export const fetchOrderBook = async (symbol: string, limit = 100, step = 100000): Promise<{ bids: Depth[], asks: Depth[] }> => { 59 | const orderBook = await fetchFromAPI<{ bids: Depth[], asks: Depth[] }>(apiRoot, `/v1/orders/depth?symbol=${symbol}&limit=${limit}&step=${step}`); 60 | return orderBook; 61 | }; 62 | 63 | /** 64 | * Get all open orders for a given user 65 | * @param {string} username - name of proton user/account to retrieve orders for 66 | * @returns {Promise} - list of all open orders 67 | */ 68 | export const fetchOpenOrders = async (username: string, limit = 250, offset = 0): Promise => { 69 | const openOrders = await fetchFromAPI(apiRoot, `/v1/orders/open?limit=${limit}&offset=${offset}&account=${username}`); 70 | return openOrders; 71 | }; 72 | 73 | export const fetchPairOpenOrders = async (username: string, symbol: string): Promise => { 74 | const openOrders = await fetchFromAPI(apiRoot, `/v1/orders/open?limit=250&offset=0&account=${username}&symbol=${symbol}`); 75 | return openOrders; 76 | }; 77 | 78 | /** 79 | * Return history of unopened orders for a given user 80 | */ 81 | export const fetchOrderHistory = async (username: string, limit = 100, offset = 0): Promise => { 82 | const orderHistory = await fetchFromAPI(apiRoot, `/v1/orders/history?limit=${limit}&offset=${offset}&account=${username}`); 83 | return orderHistory; 84 | }; 85 | 86 | /** 87 | * Given a market symbol, return the most recent trades to have executed in that market 88 | */ 89 | export const fetchTrades = async (symbol: string, count = 100, offset = 0): Promise => { 90 | const response = await fetchFromAPI(apiRoot, `/v1/trades/recent?symbol=${symbol}&limit=${count}&offset=${offset}`); 91 | return response; 92 | }; 93 | 94 | /** 95 | * Given a market symbol, get the price for it 96 | */ 97 | export const fetchLatestPrice = async (symbol: string): Promise => { 98 | const trades = await fetchTrades(symbol, 1); 99 | return trades[0].price; 100 | }; 101 | 102 | export interface Balance { 103 | currency: string; 104 | amount: number; 105 | contract: string; 106 | decimals: number; 107 | } 108 | 109 | type TokenBalance = string; 110 | /** 111 | * 112 | * @param {string} username - name of proton user/account to retrieve history for 113 | * @returns {Promise} - array of balances, 114 | * ex. {"decimals":"4","contract":"eosio.token","amount":"123.4567","currency":"XPR"} 115 | */ 116 | export const fetchBalances = async (username: string): Promise => { 117 | const chain = process.env.NODE_ENV === 'test' ? 'protontest' : 'proton'; 118 | const response = await fetchFromAPI<{ balances: Balance[] }>(lightApiRoot, `/balances/${chain}/${username}`, false); 119 | return response.balances; 120 | }; 121 | 122 | export const fetchTokenBalance = async (username: string, contractname: string, token: string): Promise => { 123 | const chain = process.env.NODE_ENV === 'test' ? 'protontest' : 'proton'; 124 | const tBalance = await fetchFromAPI(lightApiRoot, `/tokenbalance/${chain}/${username}/${contractname}/${token}`, false); 125 | return tBalance; 126 | }; 127 | 128 | const marketsRepo: { 129 | byId: Map; 130 | bySymbol: Map; 131 | } = { 132 | byId: new Map(), 133 | bySymbol: new Map() 134 | }; 135 | export const getMarketById = (id: number): Market | undefined => marketsRepo.byId.get(id); 136 | export const getMarketBySymbol = (symbol: string): Market | undefined => marketsRepo.bySymbol.get(symbol); 137 | 138 | /** 139 | * Initialize. Gets and stores all dex markets 140 | */ 141 | export const initialize = async () => { 142 | // load all markets for later use 143 | const allMarkets = await fetchMarkets(); 144 | allMarkets.forEach((market) => { 145 | marketsRepo.byId.set(market.market_id, market); 146 | marketsRepo.bySymbol.set(market.symbol, market); 147 | }); 148 | }; 149 | -------------------------------------------------------------------------------- /src/dexrpc.ts: -------------------------------------------------------------------------------- 1 | // Interactions with the DEX contract, via RPC 2 | import { JsonRpc, Api, JsSignatureProvider, Serialize } from '@proton/js'; 3 | import { BigNumber } from 'bignumber.js'; 4 | import { FILLTYPES, ORDERSIDES, ORDERTYPES } from './core/constants'; 5 | import * as dexapi from './dexapi'; 6 | import { getConfig, getLogger, getUsername } from './utils'; 7 | 8 | type OrderAction = Serialize.Action 9 | 10 | const logger = getLogger(); 11 | 12 | const config = getConfig(); 13 | const { endpoints, privateKey, privateKeyPermission } = config.rpc; 14 | const username = getUsername(); 15 | 16 | let signatureProvider = process.env.npm_lifecycle_event === 'test'? undefined : new JsSignatureProvider([privateKey]); 17 | let actions: OrderAction[] = []; 18 | 19 | // Initialize 20 | const rpc = new JsonRpc(endpoints); 21 | const api = new Api({ 22 | rpc, 23 | signatureProvider 24 | }); 25 | 26 | const apiTransact = (actions: Serialize.Action[] ) => api.transact({ actions }, { 27 | blocksBehind: 300, 28 | expireSeconds: 3000, 29 | }); 30 | 31 | const authorization = [{ 32 | actor: username, 33 | permission: privateKeyPermission, 34 | }]; 35 | 36 | /** 37 | * Given a list of on-chain actions, apply authorization and send 38 | */ 39 | const transact = async (actions: OrderAction[]) => { 40 | // apply authorization to each action 41 | const authorization = [{ 42 | actor: username, 43 | permission: privateKeyPermission, 44 | }]; 45 | const authorizedActions = actions.map((action) => ({ ...action, authorization })); 46 | const maxRetries = 3; 47 | let attempts = 0; 48 | while(attempts < maxRetries) { 49 | try { 50 | await apiTransact(authorizedActions); 51 | break; 52 | } 53 | catch { 54 | attempts++; 55 | if (attempts >= maxRetries) { 56 | logger.error(`Failed after ${maxRetries} attempts`); 57 | throw Error; 58 | } 59 | logger.info(`Retrying RPC connection`); 60 | } 61 | } 62 | }; 63 | 64 | /** 65 | * Place a buy or sell limit order. Quantity and price are string values to 66 | * avoid loss of precision when placing order 67 | */ 68 | export const prepareLimitOrder = async (marketSymbol: string, orderSide: ORDERSIDES, quantity: BigNumber.Value, price: number): Promise => { 69 | const market = dexapi.getMarketBySymbol(marketSymbol); 70 | if(!market) { 71 | throw new Error(`No market found by symbol ${marketSymbol}`); 72 | } 73 | const askToken = market.ask_token; 74 | const bidToken = market.bid_token; 75 | 76 | const bnQuantity = new BigNumber(quantity); 77 | const quantityText = orderSide === ORDERSIDES.SELL 78 | ? `${bnQuantity.toFixed(bidToken.precision)} ${bidToken.code}` 79 | : `${bnQuantity.toFixed(askToken.precision)} ${askToken.code}`; 80 | 81 | const orderSideText = orderSide === ORDERSIDES.SELL ? 'sell' : 'buy'; 82 | logger.info(`Placing ${orderSideText} order for ${quantityText} at ${price}`); 83 | 84 | const quantityNormalized = orderSide === ORDERSIDES.SELL 85 | ? (bnQuantity.times(bidToken.multiplier)).toString() 86 | : (bnQuantity.times(askToken.multiplier)).toString(); 87 | 88 | const cPrice = new BigNumber(price); 89 | const priceNormalized = cPrice.multipliedBy(askToken.multiplier); 90 | 91 | actions.push( 92 | { 93 | account: orderSide === ORDERSIDES.SELL ? bidToken.contract : askToken.contract, 94 | name: 'transfer', 95 | data: { 96 | from: username, 97 | to: 'dex', 98 | quantity: quantityText, 99 | memo: '', 100 | }, 101 | authorization, 102 | }, 103 | { 104 | account: 'dex', 105 | name: 'placeorder', 106 | data: { 107 | market_id: market.market_id, 108 | account: username, 109 | order_type: ORDERTYPES.LIMIT, 110 | order_side: orderSide, 111 | quantity: quantityNormalized, 112 | price: priceNormalized, 113 | bid_symbol: { 114 | sym: `${bidToken.precision},${bidToken.code}`, 115 | contract: bidToken.contract, 116 | }, 117 | ask_symbol: { 118 | sym: `${askToken.precision},${askToken.code}`, 119 | contract: askToken.contract, 120 | }, 121 | trigger_price: 0, 122 | fill_type: FILLTYPES.GTC, 123 | referrer: '', 124 | }, 125 | authorization, 126 | }, 127 | ); 128 | }; 129 | 130 | export const submitOrders = async (): Promise => { 131 | actions.push( 132 | { 133 | account: 'dex', 134 | name: 'process', 135 | data: { 136 | q_size: 60, 137 | show_error_msg: 0, 138 | }, 139 | authorization, 140 | }, 141 | { 142 | account: 'dex', 143 | name: "withdrawall", 144 | data: { 145 | account: username, 146 | }, 147 | authorization, 148 | },); 149 | 150 | const response = await apiTransact(actions); 151 | actions = []; 152 | } 153 | 154 | export const submitProcessAction = async (): Promise => { 155 | const processAction = [({ 156 | account: 'dex', 157 | name: 'process', 158 | data: { 159 | q_size: 100, 160 | show_error_msg: 0, 161 | }, 162 | authorization, 163 | })]; 164 | 165 | const response = apiTransact(processAction); 166 | } 167 | 168 | const createCancelAction = (orderId: string | number): OrderAction => ({ 169 | account: 'dex', 170 | name: 'cancelorder', 171 | data: { 172 | account: username, 173 | order_id: orderId, 174 | }, 175 | authorization, 176 | }); 177 | 178 | const withdrawAction = () => ({ 179 | account: 'dex', 180 | name: "withdrawall", 181 | data: { 182 | account: username, 183 | }, 184 | authorization, 185 | }); 186 | 187 | 188 | /** 189 | * Cancel a single order 190 | */ 191 | export const cancelOrder = async (orderId: string): Promise => { 192 | logger.info(`Canceling order with id: ${orderId}`); 193 | const response = await transact([createCancelAction(orderId)]); 194 | return response; 195 | }; 196 | 197 | /** 198 | * Cancel all orders for the current account 199 | */ 200 | export const cancelAllOrders = async (): Promise => { 201 | try { 202 | let cancelList = []; 203 | let i = 0; 204 | while(true) { 205 | const ordersList = await dexapi.fetchOpenOrders(username, 150, 150 * i); 206 | if(!ordersList.length) break; 207 | cancelList.push(...ordersList); 208 | i++; 209 | } 210 | if(!cancelList.length) { 211 | console.log(`No orders to cancel`); 212 | return; 213 | } 214 | console.log(`Cancelling all (${cancelList.length}) orders`); 215 | const actions = cancelList.map((order) => createCancelAction(order.order_id)); 216 | const response = await transact(actions); 217 | return response; 218 | } 219 | catch (e) { 220 | console.log('cancel orders error', e) 221 | return undefined 222 | } 223 | }; 224 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { getConfig, getLogger } from './utils'; 2 | import * as dexapi from './dexapi'; 3 | import * as dexrpc from './dexrpc'; 4 | import { getStrategy } from './strategies'; 5 | import readline from 'readline'; 6 | import { postSlackMsg } from './slackapi'; 7 | 8 | function delay(ms: number) { 9 | return new Promise((resolve) => { 10 | setTimeout(resolve, ms); 11 | }); 12 | } 13 | 14 | const execTrade = async () => { 15 | console.log('Bot is live'); 16 | await currentStrategy.trade() 17 | await delay(config.tradeIntervalMS) 18 | execTrade() 19 | } 20 | 21 | const execSlack = async () => { 22 | await postSlackMsg() 23 | await delay(config.slackIntervalMS) 24 | execSlack() 25 | } 26 | const config = getConfig(); 27 | const currentStrategy = getStrategy(config.strategy); 28 | currentStrategy.initialize(config[config.strategy]); 29 | 30 | /** 31 | * Main 32 | * This sets up the logic for the application, the looping, timing, and what to do on exit. 33 | */ 34 | const main = async () => { 35 | const logger = getLogger(); 36 | 37 | await dexapi.initialize(); 38 | 39 | try { 40 | process.stdin.resume(); 41 | if (config.cancelOpenOrdersOnExit) { 42 | if (process.platform === "win32") { 43 | var rl = readline.createInterface({ 44 | input: process.stdin, 45 | output: process.stdout 46 | }); 47 | 48 | rl.on("SIGINT", function () { 49 | process.emit("SIGINT"); 50 | }); 51 | } 52 | 53 | async function signalHandler() { 54 | await dexrpc.cancelAllOrders(); 55 | process.exit(); 56 | } 57 | 58 | process.on('SIGINT', signalHandler) 59 | process.on('SIGTERM', signalHandler) 60 | process.on('SIGQUIT', signalHandler) 61 | } 62 | 63 | await currentStrategy.trade() 64 | logger.info(`Waiting for few seconds before fetching the placed orders`); 65 | await delay(15000) 66 | execTrade() 67 | execSlack() 68 | } catch (error) { 69 | logger.error((error as Error).message); 70 | } 71 | }; 72 | 73 | // start it all up 74 | await main(); -------------------------------------------------------------------------------- /src/interfaces/config.interface.ts: -------------------------------------------------------------------------------- 1 | export interface GridBotPair { 2 | symbol: string; 3 | upperLimit: number; 4 | lowerLimit: number; 5 | gridLevels: number; 6 | bidAmountPerLevel: number; 7 | } 8 | 9 | export interface GridBotPairRaw extends Omit{ 10 | upperLimit: number | string; 11 | lowerLimit: number | string; 12 | gridLevels: number | string; 13 | bidAmountPerLevel: number | string; 14 | } 15 | 16 | export interface MarketMakerPair { 17 | symbol: string; 18 | gridLevels: number; 19 | gridInterval: number; 20 | base: number; 21 | orderSide: number; 22 | bidAmountPerLevel: number; 23 | } 24 | 25 | export interface BotConfig { 26 | tradeIntervalMS: number; 27 | slackIntervalMS: number; 28 | slackBotToken: string; 29 | channelId: string; 30 | cancelOpenOrdersOnExit: boolean; 31 | gridPlacement: boolean; 32 | strategy: 'gridBot' | 'marketMaker'; 33 | marketMaker: { 34 | pairs: MarketMakerPair[]; 35 | }; 36 | gridBot: { 37 | pairs: GridBotPairRaw[] 38 | }; 39 | rpc: { 40 | privateKeyPermission: string; 41 | endpoints : string[]; 42 | apiRoot: string; 43 | lightApiRoot: string; 44 | privateKey: string; 45 | }; 46 | username: string; 47 | } -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config.interface'; 2 | export * from './strategy.interface'; 3 | export * from './order.interface'; -------------------------------------------------------------------------------- /src/interfaces/order.interface.ts: -------------------------------------------------------------------------------- 1 | import { ORDERSIDES } from '../core/constants'; 2 | 3 | export interface TradeOrder { 4 | orderSide: ORDERSIDES; 5 | price: number; 6 | quantity: number; 7 | marketSymbol: string; 8 | } -------------------------------------------------------------------------------- /src/interfaces/strategy.interface.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export interface TradingStrategy { 4 | initialize(options?: any): Promise; 5 | trade(): Promise; 6 | } 7 | 8 | export interface TradingStrategyConstructor { 9 | new (): TradingStrategy; 10 | } 11 | -------------------------------------------------------------------------------- /src/slackapi.ts: -------------------------------------------------------------------------------- 1 | import { getConfig, getLogger, getUsername } from './utils'; 2 | import * as dexapi from './dexapi'; 3 | import { WebClient } from '@slack/web-api'; 4 | import { JsonRpc } from "@proton/light-api"; 5 | 6 | const config = getConfig(); 7 | const logger = getLogger(); 8 | const username = getUsername(); 9 | 10 | export const postSlackMsg = async (): Promise => { 11 | 12 | const channelId = config.channelId; 13 | const slackBotToken = config.slackBotToken; 14 | 15 | if(!channelId || !slackBotToken) { 16 | logger.info(' Slack bot configuration is missing, so not sharing details(balance, open-orders) to slack channel'); 17 | return; 18 | } 19 | 20 | const chain = process.env.NODE_ENV === 'test' ? 'protontest' : 'proton'; 21 | const rpc = new JsonRpc(chain); 22 | const web = new WebClient(config.slackBotToken); 23 | 24 | const balance = await rpc.get_balances(username); 25 | var obj = JSON.stringify(balance); 26 | const res1 = await web.chat.postMessage({ channel: config.channelId, text: obj }); 27 | 28 | let orders = []; 29 | let i = 0; 30 | while(true) { 31 | const ordersList = await dexapi.fetchOpenOrders(username, 150, 150 * i); 32 | if(!ordersList.length) break; 33 | orders.push(...ordersList); 34 | i++; 35 | } 36 | obj = JSON.stringify(orders); 37 | const res2 = await web.chat.postMessage({ channel: config.channelId, text: obj }); 38 | // `res` contains information about the posted message 39 | logger.info('Message sent: ', res2.ts); 40 | } 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/strategies/base.ts: -------------------------------------------------------------------------------- 1 | import { prepareLimitOrder, submitProcessAction, submitOrders } from "../dexrpc"; 2 | import { TradeOrder, TradingStrategy } from "../interfaces"; 3 | import * as dexapi from "../dexapi"; 4 | import { getUsername } from "../utils"; 5 | import { Market } from '@proton/wrap-constants'; 6 | 7 | export interface MarketDetails { 8 | highestBid: number; 9 | lowestAsk: number; 10 | market?: Market; 11 | price: number; 12 | } 13 | 14 | function delay(ms: number) { 15 | return new Promise( resolve => setTimeout(resolve, ms) ); 16 | } 17 | 18 | export abstract class TradingStrategyBase implements TradingStrategy { 19 | abstract initialize(options?: any): Promise; 20 | 21 | abstract trade(): Promise; 22 | 23 | protected dexAPI = dexapi; 24 | protected username = getUsername(); 25 | 26 | protected async placeOrders(orders: TradeOrder[], delayTime = 2000): Promise { 27 | for(var i = 1; i <= orders.length; i++) { 28 | await prepareLimitOrder( 29 | orders[i-1].marketSymbol, 30 | orders[i-1].orderSide, 31 | orders[i-1].quantity, 32 | orders[i-1].price 33 | ); 34 | if(i%10 === 0 || i === orders.length) { 35 | await submitProcessAction(); 36 | await submitOrders(); 37 | await delay(delayTime); 38 | }; 39 | } 40 | } 41 | 42 | protected async getOpenOrders(marketSymbol: string) { 43 | const market = this.dexAPI.getMarketBySymbol(marketSymbol); 44 | if (market === undefined) { 45 | throw new Error(`Market ${marketSymbol} does not exist`); 46 | } 47 | 48 | const allOrders = await this.dexAPI.fetchPairOpenOrders(this.username, marketSymbol); 49 | console.log(`Open orders size for pair ${marketSymbol} ${allOrders.length}`); 50 | return allOrders; 51 | } 52 | 53 | protected async getMarketDetails(marketSymbol: string): Promise { 54 | const market = dexapi.getMarketBySymbol(marketSymbol); 55 | const price = await dexapi.fetchLatestPrice(marketSymbol); 56 | const orderBook = await dexapi.fetchOrderBook(marketSymbol, 1); 57 | const lowestAsk = 58 | orderBook.asks.length > 0 ? orderBook.asks[0].level : price; 59 | const highestBid = 60 | orderBook.bids.length > 0 ? orderBook.bids[0].level : price; 61 | 62 | const details = { 63 | highestBid, 64 | lowestAsk, 65 | market, 66 | price, 67 | }; 68 | 69 | return details; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/strategies/gridbot.ts: -------------------------------------------------------------------------------- 1 | // grid bot strategy 2 | import { BigNumber as BN } from 'bignumber.js'; 3 | import { ORDERSIDES } from '../core/constants'; 4 | import { BotConfig, GridBotPair, TradeOrder, TradingStrategy } from '../interfaces'; 5 | import { configValueToFloat, configValueToInt, getConfig, getLogger, getUsername } from '../utils'; 6 | import { TradingStrategyBase } from './base'; 7 | import { fetchTokenBalance } from '../dexapi'; 8 | 9 | const logger = getLogger(); 10 | const config = getConfig(); 11 | 12 | /** 13 | * Grid Trading Bot Strategy 14 | * Grid Trading Bots are programs that allow users to automatically buy low and sell high within a pre-set price range. 15 | * The number of orders is determined by config values like limits, gridLevels, refer config/default.json 16 | */ 17 | export class GridBotStrategy extends TradingStrategyBase implements TradingStrategy { 18 | private oldOrders: TradeOrder[][] = [] 19 | private pairs: GridBotPair[] = []; 20 | 21 | async initialize(options?: BotConfig['gridBot']): Promise { 22 | if(options){ 23 | this.pairs = this.parseEachPairConfig(options.pairs); 24 | this.pairs.forEach((_, i) => { 25 | this.oldOrders[i] = []; 26 | }) 27 | } 28 | } 29 | 30 | async trade(): Promise { 31 | for (var i = 0; i < this.pairs.length; i++) { 32 | try { 33 | console.log("Checking for trades"); 34 | const marketSymbol = this.pairs[i].symbol; 35 | const marketDetails = await this.getMarketDetails(marketSymbol); 36 | const { market } = marketDetails; 37 | if(!market) { 38 | console.log('Invalid market'); 39 | continue; 40 | } 41 | 42 | const gridLevels = this.pairs[i].gridLevels; 43 | const bidPrecision = market.bid_token.precision; 44 | const askPrecision = market.ask_token.precision; 45 | const lastSalePrice = new BN(marketDetails.price).toFixed(askPrecision); 46 | const openOrders = await this.getOpenOrders(marketSymbol); 47 | const upperLimit = new BN( 48 | this.pairs[i].upperLimit * 10 ** bidPrecision 49 | ); 50 | const lowerLimit = new BN( 51 | this.pairs[i].lowerLimit * 10 ** bidPrecision 52 | ); 53 | const bidAmountPerLevel = new BN(this.pairs[i].bidAmountPerLevel); 54 | const gridSize = upperLimit.minus(lowerLimit).dividedBy(gridLevels); 55 | const gridPrice = gridSize 56 | .dividedBy(10 ** bidPrecision) 57 | .toFixed(askPrecision); 58 | let latestOrders = []; 59 | const username = getUsername(); 60 | 61 | if (!this.oldOrders[i].length) { 62 | // Place orders on bot initialization 63 | let index = 0; 64 | let maxGrids = gridLevels; 65 | logger.info(`gridLevels ${gridLevels}, ${maxGrids}`); 66 | if(!maxGrids) 67 | continue; 68 | const priceTraded = new BN(lastSalePrice).times(10 ** bidPrecision); 69 | logger.info(`upperLimit ${upperLimit}, lowerLimit: ${lowerLimit}, priceTraded: ${priceTraded}`); 70 | if(upperLimit.isGreaterThanOrEqualTo(priceTraded) && lowerLimit.isGreaterThanOrEqualTo(priceTraded)) maxGrids -= 1; 71 | if(upperLimit.isLessThanOrEqualTo(priceTraded) && lowerLimit.isLessThanOrEqualTo(priceTraded)) index = 1; 72 | var sellToken = 0; 73 | var buyToken = 0; 74 | for (; index <= maxGrids; index += 1) { 75 | const price = upperLimit 76 | .minus(gridSize.multipliedBy(index)) 77 | .dividedBy(10 ** bidPrecision) 78 | .toFixed(askPrecision); 79 | const { quantity, adjustedTotal } = this.getQuantityAndAdjustedTotal( 80 | price, 81 | bidAmountPerLevel, 82 | bidPrecision, 83 | askPrecision 84 | ); 85 | const validOrder = new BN( 86 | Math.abs(parseFloat(price) - parseFloat(lastSalePrice)) 87 | ).isGreaterThanOrEqualTo(+gridPrice / 2); 88 | 89 | const validBuyOrder = new BN( 90 | (parseFloat(lastSalePrice) - parseFloat(price)) 91 | ).isGreaterThan(0); 92 | 93 | const validSellOrder = new BN( 94 | (parseFloat(price) - parseFloat(lastSalePrice)) 95 | ).isGreaterThan(0); 96 | 97 | // Prepare orders and push into a list 98 | if (validOrder) { 99 | if (validSellOrder) { 100 | const order = { 101 | orderSide: ORDERSIDES.SELL, 102 | price: +price, 103 | quantity: quantity, 104 | marketSymbol, 105 | }; 106 | sellToken += quantity; 107 | this.oldOrders[i].push(order); 108 | } else if (validBuyOrder) { 109 | const order = { 110 | orderSide: ORDERSIDES.BUY, 111 | price: +price, 112 | quantity: adjustedTotal, 113 | marketSymbol, 114 | }; 115 | buyToken += adjustedTotal; 116 | this.oldOrders[i].push(order); 117 | } 118 | } 119 | } 120 | const sellTotal = new BN(sellToken).toFixed(bidPrecision); 121 | const buyTotal = new BN(buyToken).toFixed(askPrecision); 122 | const sellBalances = await fetchTokenBalance(username, market.bid_token.contract, market.bid_token.code); 123 | const buyBalances = await fetchTokenBalance(username, market.ask_token.contract, market.ask_token.code); 124 | if(sellTotal > sellBalances || buyTotal > buyBalances) { 125 | logger.error(`LOW BALANCES - Current balance ${sellBalances} ${market.bid_token.code} - Expected ${sellTotal} ${market.bid_token.code} 126 | Current balance ${buyBalances} ${market.ask_token.code} - Expected ${buyTotal} ${market.ask_token.code}`); 127 | logger.info(` Overdrawn Balance - Not placing orders for ${market.bid_token.code}-${market.ask_token.code} `); 128 | continue; 129 | } 130 | // Makesure to place only maxGrids order, as there is a possibility to place 1 additional order because of the non divisible configuration 131 | if (this.oldOrders[i].length <= maxGrids) { 132 | await this.placeOrders(this.oldOrders[i]); 133 | } 134 | } else if (openOrders.length > 0 && openOrders.length < gridLevels) { 135 | // compare open orders with old orders and placce counter orders for the executed orders 136 | let currentOrders: TradeOrder[] = openOrders.map((order) => ({ 137 | orderSide: order.order_side, 138 | price: order.price, 139 | quantity: order.quantity_curr, 140 | marketSymbol, 141 | })); 142 | for (var j = 0; j < this.oldOrders[i].length; j++) { 143 | const newOrder = openOrders.find( 144 | (openOrders) => 145 | openOrders.price === this.oldOrders[i][j].price 146 | ); 147 | if (!newOrder) { 148 | if (this.oldOrders[i][j].orderSide === ORDERSIDES.BUY) { 149 | const lowestAsk = this.getLowestAsk(currentOrders); 150 | var sellPrice; 151 | // Place a counter sell order for the executed buy order 152 | if (!lowestAsk || config.gridPlacement) 153 | sellPrice = new BN(this.oldOrders[i][j].price) 154 | .plus(gridPrice) 155 | .toFixed(askPrecision); 156 | else 157 | sellPrice = new BN(lowestAsk) 158 | .minus(gridPrice) 159 | .toFixed(askPrecision); 160 | const { quantity } = this.getQuantityAndAdjustedTotal( 161 | sellPrice, 162 | bidAmountPerLevel, 163 | bidPrecision, 164 | askPrecision 165 | ); 166 | const order = { 167 | orderSide: ORDERSIDES.SELL, 168 | price: +sellPrice, 169 | quantity: quantity, 170 | marketSymbol, 171 | }; 172 | latestOrders.push(order); 173 | currentOrders.push(order); 174 | } else if (this.oldOrders[i][j].orderSide === ORDERSIDES.SELL) { 175 | const highestBid = this.getHighestBid(currentOrders); 176 | // Place a counter buy order for the executed sell order 177 | var buyPrice; 178 | if (!highestBid || config.gridPlacement) 179 | buyPrice = new BN(this.oldOrders[i][j].price) 180 | .minus(gridPrice) 181 | .toFixed(askPrecision); 182 | else 183 | buyPrice = new BN(highestBid) 184 | .plus(gridPrice) 185 | .toFixed(askPrecision); 186 | const { adjustedTotal } = this.getQuantityAndAdjustedTotal( 187 | buyPrice, 188 | bidAmountPerLevel, 189 | bidPrecision, 190 | askPrecision 191 | ); 192 | const order = { 193 | orderSide: ORDERSIDES.BUY, 194 | price: +buyPrice, 195 | quantity: adjustedTotal, 196 | marketSymbol, 197 | }; 198 | latestOrders.push(order); 199 | currentOrders.push(order); 200 | } 201 | } 202 | } 203 | await this.placeOrders(latestOrders); 204 | // Update old orders for next round of inspection 205 | this.oldOrders[i] = currentOrders; 206 | } 207 | } catch (error) { 208 | logger.error((error as Error).message); 209 | } 210 | } 211 | } 212 | 213 | private parseEachPairConfig(pairs: BotConfig['gridBot']['pairs']): GridBotPair[] { 214 | const result: GridBotPair[] = []; 215 | 216 | pairs.forEach((pair, idx) => { 217 | if (pair.symbol === undefined) { 218 | throw new Error( 219 | `Market symbol option is missing for gridBot pair with index ${idx} in default.json` 220 | ); 221 | } 222 | 223 | if ( 224 | pair.upperLimit === undefined || 225 | pair.lowerLimit === undefined || 226 | pair.gridLevels === undefined || 227 | pair.bidAmountPerLevel === undefined 228 | ) { 229 | throw new Error( 230 | `Options are missing for market or gridBot pair ${pair.symbol} in default.json` 231 | ); 232 | } 233 | 234 | result.push({ 235 | symbol: pair.symbol, 236 | upperLimit: configValueToFloat(pair.upperLimit), 237 | lowerLimit: configValueToFloat(pair.lowerLimit), 238 | gridLevels: configValueToInt(pair.gridLevels), 239 | bidAmountPerLevel: configValueToFloat(pair.bidAmountPerLevel), 240 | }); 241 | }); 242 | return result; 243 | } 244 | 245 | /** 246 | * Given a price and total cost return a quantity value. Use precision values in the bid and ask 247 | * currencies, and return an adjusted total to account for losses during rounding. The adjustedTotal 248 | * value is used for buy orders 249 | */ 250 | private getQuantityAndAdjustedTotal(price: BN | string, totalCost: BN, bidPrecision: number, askPrecision: number): { 251 | quantity: number; 252 | adjustedTotal: number; 253 | } { 254 | const adjustedTotal = +new BN(totalCost).times(price).toFixed(askPrecision); 255 | const quantity = +new BN(adjustedTotal).dividedBy(price).toFixed(bidPrecision); 256 | return { 257 | quantity, 258 | adjustedTotal, 259 | }; 260 | } 261 | 262 | private getHighestBid(orders: TradeOrder[]): BN | null{ 263 | const buyOrders = orders.filter((order) => order.orderSide === ORDERSIDES.BUY); 264 | if (buyOrders.length === 0) return null; 265 | 266 | buyOrders.sort((orderA, orderB): number => { 267 | if(BN(orderA.price).isGreaterThan(BN(orderB.price))) return -1; 268 | if(BN(orderA.price).isLessThan(BN(orderB.price))) return 1; 269 | return 0 270 | }); 271 | 272 | const highestBid = new BN(buyOrders[0].price); 273 | return highestBid; 274 | } 275 | 276 | private getLowestAsk(orders: TradeOrder[]): BN | null { 277 | const sellOrders = orders.filter((order) => order.orderSide === ORDERSIDES.SELL); 278 | if (sellOrders.length === 0) return null; 279 | 280 | sellOrders.sort((orderA, orderB): number => { 281 | if(BN(orderA.price).isGreaterThan(BN(orderB.price))) return 1; 282 | if(BN(orderA.price).isLessThan(BN(orderB.price))) return -1; 283 | return 0; 284 | }); 285 | 286 | const lowestAsk = new BN(sellOrders[0].price); 287 | return lowestAsk; 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/strategies/index.ts: -------------------------------------------------------------------------------- 1 | import { TradingStrategy, TradingStrategyConstructor } from '../interfaces'; 2 | import { GridBotStrategy } from './gridbot'; 3 | import { MarketMakerStrategy } from './marketmaker'; 4 | 5 | const strategiesMap = new Map([ 6 | ['gridBot', GridBotStrategy], 7 | ['marketMaker', MarketMakerStrategy] 8 | ]) 9 | 10 | export function getStrategy(name: string): TradingStrategy { 11 | const strategy = strategiesMap.get(name); 12 | if(strategy){ 13 | return new strategy(); 14 | } 15 | throw new Error(`No strategy named ${name} found.`) 16 | } 17 | -------------------------------------------------------------------------------- /src/strategies/marketmaker.ts: -------------------------------------------------------------------------------- 1 | // a basic market maker strategy 2 | import { Market, OrderHistory } from '@proton/wrap-constants'; 3 | import { BigNumber as BN } from 'bignumber.js'; 4 | import { ORDERSIDES } from '../core/constants'; 5 | import { BotConfig, MarketMakerPair, TradeOrder, TradingStrategy } from '../interfaces'; 6 | import { getLogger } from '../utils'; 7 | import { MarketDetails, TradingStrategyBase } from './base'; 8 | import { fetchTokenBalance } from '../dexapi'; 9 | 10 | const logger = getLogger(); 11 | 12 | /** 13 | * Market Making Trading Strategy 14 | * The goal is to always have some buy and some sell side orders on the books. 15 | * The number of orders is determined by config value gridLevels, see config/default.json 16 | * The orders should be maker orders. 17 | */ 18 | export class MarketMakerStrategy extends TradingStrategyBase implements TradingStrategy { 19 | private pairs: MarketMakerPair[] = []; 20 | 21 | async initialize(options?: BotConfig['marketMaker']): Promise { 22 | if(options) { 23 | this.pairs = options.pairs; 24 | } 25 | } 26 | 27 | async trade() { 28 | for (let i = 0; i < this.pairs.length; ++i) { 29 | logger.info(`Checking ${this.pairs[i].symbol} market maker orders on account ${this.username}`); 30 | 31 | try { 32 | const openOrders = await this.getOpenOrders(this.pairs[i].symbol); 33 | 34 | // any orders to place? 35 | const gridLevels = new BN(this.getGridLevels(this.pairs[i].symbol)); 36 | const buys = openOrders.filter((order) => order.order_side === ORDERSIDES.BUY); 37 | const sells = openOrders.filter((order) => order.order_side === ORDERSIDES.SELL); 38 | if (buys.length >= gridLevels.toNumber() && sells.length >= gridLevels.toNumber()) { 39 | logger.info(`No change - there are enough orders(as per the grid levels in config) on the orderbook for ${this.pairs[i].symbol}`); 40 | continue; 41 | } 42 | 43 | const marketDetails = await this.getMarketDetails(this.pairs[i].symbol); 44 | const preparedOrders = await this.prepareOrders(this.pairs[i].symbol, marketDetails, openOrders); 45 | await this.placeOrders(preparedOrders, 100); 46 | } catch (error) { 47 | logger.error((error as Error).message); 48 | } 49 | } 50 | } 51 | 52 | /** 53 | * Given a price and total cost return a quantity value. Use precision values in the bid and ask 54 | * currencies, and return an adjusted total to account for losses during rounding. The adjustedTotal 55 | * value is used for buy orders 56 | */ 57 | private getQuantityAndAdjustedTotal(price: BN | string, totalCost: BN | string | number, bidPrecision: number, askPrecision: number): { 58 | quantity: number; 59 | adjustedTotal: number; 60 | } { 61 | const quantity = +new BN(totalCost).dividedBy(price).toFixed(bidPrecision, BN.ROUND_UP); 62 | const adjustedTotal = +new BN(price).times(quantity).toFixed(askPrecision, BN.ROUND_UP); 63 | return { 64 | adjustedTotal, 65 | quantity, 66 | }; 67 | } 68 | 69 | private getBidAmountPerLevel(marketSymbol: string) { 70 | let bidAmountPerLevel; 71 | 72 | this.pairs.forEach((pair) => { 73 | if (marketSymbol === pair.symbol){ 74 | bidAmountPerLevel = pair.bidAmountPerLevel; 75 | } 76 | }) 77 | 78 | if (bidAmountPerLevel === undefined) { 79 | throw new Error(`Bid Amount option is missing for market ${marketSymbol} in default.json`); 80 | } 81 | 82 | return bidAmountPerLevel; 83 | } 84 | 85 | private getGridInterval(marketSymbol: string) { 86 | let interval; 87 | 88 | this.pairs.forEach((pair) => { 89 | if (marketSymbol === pair.symbol){ 90 | interval = pair.gridInterval; 91 | } 92 | }) 93 | 94 | if (interval === undefined) { 95 | throw new Error(`GridInterval option is missing for market ${marketSymbol} in default.json`); 96 | } 97 | 98 | return interval; 99 | } 100 | 101 | private getGridLevels(marketSymbol: string) { 102 | let levels; 103 | this.pairs.forEach((pair) => { 104 | if (marketSymbol === pair.symbol){ 105 | levels = pair.gridLevels; 106 | } 107 | }); 108 | 109 | if (levels === undefined) { 110 | throw new Error(`GridLevels option is missing for market ${marketSymbol} in default.json`); 111 | } 112 | 113 | return levels; 114 | } 115 | 116 | // prepare the orders we want to have on the books 117 | private async prepareOrders(marketSymbol: string, marketDetails: MarketDetails, openOrders: OrderHistory[]): Promise { 118 | const { market } = marketDetails; 119 | if (market === undefined) { 120 | throw new Error(`Market ${marketSymbol} does not exist`); 121 | } 122 | const orders: TradeOrder[] = []; 123 | let numBuys = openOrders.filter((order) => order.order_side === ORDERSIDES.BUY).length; 124 | let numSells = openOrders.filter((order) => order.order_side === ORDERSIDES.SELL).length; 125 | 126 | const levels = new BN(this.getGridLevels(marketSymbol)); 127 | const side = this.getGridOrderSide(marketSymbol); 128 | const levelsN = levels.toNumber(); 129 | var sellToken = 0; 130 | var buyToken = 0; 131 | for (let index = 0; index < levelsN; index += 1) { 132 | // buy order 133 | if ((numBuys < levelsN) && ((side === 'BOTH') || (side === 'BUY'))) { 134 | const order = this.createBuyOrder(marketSymbol, marketDetails, index); 135 | if(order){ 136 | orders.push(order); 137 | buyToken += order.quantity; 138 | } 139 | numBuys += 1; 140 | } 141 | 142 | // sell order 143 | if ((numSells < levelsN) && ((side === 'BOTH') || (side === 'SELL'))) { 144 | const order = this.createSellOrder(marketSymbol, marketDetails, index); 145 | if(order) { 146 | orders.push(order); 147 | sellToken += order.quantity; 148 | } 149 | numSells += 1; 150 | } 151 | } 152 | 153 | const sellTotal = new BN(sellToken).toFixed(market.bid_token.precision); 154 | const buyTotal = new BN(buyToken).toFixed(market.ask_token.precision); 155 | const sellBalances = await fetchTokenBalance(this.username, market.bid_token.contract, market.bid_token.code); 156 | const buyBalances = await fetchTokenBalance(this.username, market.ask_token.contract, market.ask_token.code); 157 | if(sellTotal > sellBalances || buyTotal > buyBalances) { 158 | logger.error(`LOW BALANCES - Current balance ${sellBalances} ${market.bid_token.code} - Expected ${sellTotal} ${market.bid_token.code} 159 | Current balance ${buyBalances} ${market.ask_token.code} - Expected ${buyTotal} ${market.ask_token.code}`); 160 | process.exit(); 161 | } 162 | 163 | return orders; 164 | } 165 | 166 | private getGridOrderSide(marketSymbol: string) { 167 | let side; 168 | this.pairs.forEach((pair) => { 169 | if (marketSymbol === pair.symbol){ 170 | side =pair.orderSide; 171 | } 172 | }); 173 | 174 | if (side === undefined) { 175 | throw new Error(`OrderSide option is missing for market ${marketSymbol} in default.json`); 176 | } 177 | 178 | return side; 179 | } 180 | 181 | private createBuyOrder(marketSymbol: string, marketDetails: MarketDetails, index: number): TradeOrder | undefined { 182 | const { market } = marketDetails; 183 | if(!market) { 184 | return undefined; 185 | } 186 | const askPrecision = market.ask_token.precision; 187 | const bidPrecision = market.bid_token.precision; 188 | const bigMinSpread = new BN(this.getGridInterval(marketSymbol)); 189 | const pair = this.pairs.find(p => p.symbol === marketSymbol); 190 | if (!pair) { 191 | return undefined; 192 | } 193 | const bidAmountPerLevel = new BN(pair.bidAmountPerLevel); 194 | 195 | 196 | const lastSalePrice = new BN(marketDetails.price); 197 | const lowestAsk = new BN(marketDetails.lowestAsk); 198 | const highestBid = new BN(marketDetails.highestBid); 199 | const base = new String(this.getBase(marketSymbol)); 200 | const avgPrice = lowestAsk.plus(highestBid).dividedBy(2); 201 | let startPrice; 202 | 203 | switch (base) { 204 | case 'BID': 205 | startPrice = highestBid; 206 | break; 207 | case 'ASK': 208 | startPrice = lowestAsk; 209 | break; 210 | case 'LAST': 211 | startPrice = lastSalePrice; 212 | break; 213 | case 'AVERAGE': 214 | default: 215 | startPrice = avgPrice; 216 | break; 217 | } 218 | 219 | const buyPrice = (bigMinSpread.times(0 - (index + 1)).plus(1)) 220 | .times(startPrice).decimalPlaces(askPrecision, BN.ROUND_DOWN); 221 | const { adjustedTotal } = this.getQuantityAndAdjustedTotal( 222 | buyPrice, 223 | bidAmountPerLevel, 224 | bidPrecision, 225 | askPrecision, 226 | ); 227 | 228 | const order = { 229 | orderSide: ORDERSIDES.BUY, 230 | price: +buyPrice, 231 | quantity: adjustedTotal, 232 | marketSymbol, 233 | }; 234 | return order; 235 | }; 236 | 237 | private createSellOrder(marketSymbol: string, marketDetails: MarketDetails, index: number): TradeOrder | undefined { 238 | const { market } = marketDetails; 239 | if(!market) { 240 | return undefined; 241 | } 242 | const askPrecision = market.ask_token.precision; 243 | const bidPrecision = market.bid_token.precision; 244 | const bigMinSpread = new BN(this.getGridInterval(marketSymbol)); 245 | const pair = this.pairs.find(p => p.symbol === marketSymbol); 246 | if (!pair) { 247 | return undefined; 248 | } 249 | const bidAmountPerLevel = new BN(pair.bidAmountPerLevel); 250 | 251 | const lastSalePrice = new BN(marketDetails.price); 252 | const lowestAsk = new BN(marketDetails.lowestAsk); 253 | const highestBid = new BN(marketDetails.highestBid); 254 | const base = new String(this.getBase(marketSymbol)); 255 | const avgPrice = lowestAsk.plus(highestBid).dividedBy(2); 256 | let startPrice; 257 | 258 | switch (base) { 259 | case 'BID': 260 | startPrice = highestBid; 261 | break; 262 | case 'ASK': 263 | startPrice = lowestAsk; 264 | break; 265 | case 'LAST': 266 | startPrice = lastSalePrice; 267 | break; 268 | default: 269 | startPrice = avgPrice; 270 | break; 271 | } 272 | 273 | const sellPrice = (bigMinSpread.times(0 + (index + 1)).plus(1)) 274 | .times(startPrice).decimalPlaces(askPrecision, BN.ROUND_UP); 275 | const { quantity } = this.getQuantityAndAdjustedTotal( 276 | sellPrice, 277 | bidAmountPerLevel, 278 | bidPrecision, 279 | askPrecision, 280 | ); 281 | 282 | const order = { 283 | orderSide: ORDERSIDES.SELL, 284 | price: +sellPrice, 285 | quantity, 286 | marketSymbol, 287 | }; 288 | 289 | return order; 290 | } 291 | 292 | private getBase(marketSymbol: string) { 293 | let type; 294 | this.pairs.forEach((pair) => { 295 | if (marketSymbol === pair.symbol){ 296 | type = pair.base; 297 | } 298 | }); 299 | 300 | if (type === undefined) { 301 | throw new Error(`Base option is missing for market ${marketSymbol} in default.json`); 302 | } 303 | 304 | return type; 305 | } 306 | } 307 | 308 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import config from "config"; 2 | import winston from "winston"; 3 | import { BotConfig } from "./interfaces"; 4 | 5 | const logger = winston.createLogger({ 6 | format: winston.format.prettyPrint(), 7 | transports: [new winston.transports.Console()], 8 | }); 9 | 10 | export const getLogger = () => logger; 11 | 12 | const botConfig = config.get("bot"); 13 | export const getConfig = () => botConfig; 14 | 15 | export const getUsername = () => botConfig.username; 16 | 17 | export const configValueToFloat = (value: string | number) => { 18 | return typeof value == "number" ? value : parseFloat(value); 19 | }; 20 | 21 | export const configValueToInt = (value: string | number) => { 22 | return typeof value == "number" ? value : parseInt(value); 23 | }; 24 | -------------------------------------------------------------------------------- /test/dexapi.test.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import { fetchBalances } from '../src/dexapi'; 3 | 4 | const { assert } = chai; 5 | 6 | describe('fetchBalances', () => { 7 | it('should fetch real balances', async () => { 8 | const balances = await fetchBalances('user1'); 9 | assert.isArray(balances); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/strategies/marketmaker.test.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from 'bignumber.js'; 2 | import chai from 'chai'; 3 | import { MarketMakerStrategy } from '../../src/strategies/marketmaker'; 4 | import { getConfig } from '../../src/utils'; 5 | 6 | const { assert } = chai; 7 | // const { createBuyOrder, createSellOrder } = MarketMakerStrategy; 8 | 9 | const marketXbtcXusdt = { 10 | market_id: 2, 11 | symbol: 'XBTC_XUSDT', 12 | status_code: 1, 13 | type: 'spot', 14 | maker_fee: 0.001, 15 | taker_fee: 0.002, 16 | order_min: '100000', 17 | bid_token: { 18 | code: 'XBTC', precision: 8, contract: 'xtokens', multiplier: 100000000, 19 | }, 20 | ask_token: { 21 | code: 'XUSDT', precision: 6, contract: 'xtokens', multiplier: 1000000, 22 | }, 23 | }; 24 | const marketXprXusdc = { 25 | market_id: 1, 26 | symbol: 'XPR_XUSDC', 27 | status_code: 1, 28 | type: 'spot', 29 | maker_fee: 0.001, 30 | taker_fee: 0.002, 31 | order_min: '100000', 32 | bid_token: { 33 | code: 'XPR', precision: 4, contract: 'eosio.token', multiplier: 10000, 34 | }, 35 | ask_token: { 36 | code: 'XUSDC', precision: 6, contract: 'xtokens', multiplier: 1000000, 37 | }, 38 | }; 39 | const marketXprXmd = { 40 | market_id: 3, 41 | symbol: 'XPR_XMD', 42 | status_code: 1, 43 | type: 'spot', 44 | maker_fee: 0.001, 45 | taker_fee: 0.002, 46 | order_min: '10', 47 | bid_token: { 48 | code: 'XPR', precision: 4, contract: 'eosio.token', multiplier: 10000, 49 | }, 50 | ask_token: { 51 | code: 'XMD', precision: 6, contract: 'xmd.token', multiplier: 1000000, 52 | }, 53 | }; 54 | 55 | let currentStrategy: MarketMakerStrategy; 56 | 57 | describe('createBuyOrder', () => { 58 | beforeEach(() => { 59 | const config = getConfig(); 60 | currentStrategy = new MarketMakerStrategy(); 61 | currentStrategy.initialize(config.marketMaker); 62 | 63 | }) 64 | 65 | it('should always create an XPR_XUSDC buy order that is at least the order_min value', () => { 66 | for (let i = 0; i < 10; i += 1) { 67 | const market = marketXprXusdc; 68 | const order = (currentStrategy as any).createBuyOrder(market.symbol, { 69 | 70 | highestBid: 0.3745, 71 | lowestAsk: 0.3925, 72 | market, 73 | price: 0.38, 74 | }, i); 75 | const total = +(new BigNumber(order.quantity) 76 | .times(new BigNumber(market.ask_token.multiplier))); 77 | const orderMin = parseInt(market.order_min, 10); 78 | assert.isAtLeast(total, orderMin, `total: ${total}, orderMin: ${orderMin}`); 79 | } 80 | }); 81 | 82 | xit('should always create an XPR_XMD buy order that is at least the order_min value', () => { 83 | for (let i = 0; i < 10; i += 1) { 84 | const market = marketXprXmd; 85 | const order = (currentStrategy as any).createBuyOrder(market.symbol, { 86 | highestBid: 0.0456, 87 | lowestAsk: 0.1001, 88 | market, 89 | price: 0.1001, 90 | }, i); 91 | const total = +(new BigNumber(order.quantity) 92 | .times(new BigNumber(market.ask_token.multiplier))); 93 | const orderMin = parseInt(market.order_min, 10); 94 | assert.isAtLeast(total, orderMin, `total: ${total}, orderMin: ${orderMin}`); 95 | } 96 | }); 97 | 98 | // No such pair in default set 99 | xit('should always create an XBTC_XUSDT buy order that is at least the order_min value', () => { 100 | for (let i = 0; i < 10; i += 1) { 101 | const market = marketXbtcXusdt; 102 | const order = (currentStrategy as any).createBuyOrder(market.symbol, { 103 | highestBid: 18345.1234, 104 | lowestAsk: 18345.0111, 105 | market, 106 | price: 18345.2222, 107 | }, i); 108 | const total = +(new BigNumber(order.quantity) 109 | .times(new BigNumber(market.ask_token.multiplier))); 110 | const orderMin = parseInt(market.order_min, 10); 111 | assert.isAtLeast(total, orderMin, `total: ${total}, orderMin: ${orderMin}`); 112 | } 113 | }); 114 | 115 | it('should create an XPR_XUSDC buy order that will succeed as a postonly order', () => { 116 | for (let i = 0; i < 10; i += 1) { 117 | const market = marketXprXusdc; 118 | const lowestAsk = 0.3925; 119 | const order = (currentStrategy as any).createBuyOrder(market.symbol, { 120 | highestBid: 0.3745, 121 | lowestAsk, 122 | market, 123 | price: 0.38, 124 | }, i); 125 | const price = parseFloat(order.price); 126 | assert.isBelow(price, lowestAsk, `buy order would execute, price:${order.price} lowestAsk: ${lowestAsk}`); 127 | } 128 | }); 129 | 130 | xit('should create an XPR_XMD buy order that will succeed as a postonly order', () => { 131 | for (let i = 0; i < 10; i += 1) { 132 | const market = marketXprXmd; 133 | const lowestAsk = 0.1001; 134 | const order = (currentStrategy as any).createBuyOrder(market.symbol, { 135 | highestBid: 0.0456, 136 | lowestAsk, 137 | market, 138 | price: 0.1001, 139 | }, i); 140 | const price = parseFloat(order.price); 141 | assert.isBelow(price, lowestAsk, `buy order would execute, price:${order.price} lowestAsk: ${lowestAsk}`); 142 | } 143 | }); 144 | 145 | // No such pair in default set 146 | xit('should create an XBTC_XUSDT buy order that will succeed as a postonly order', () => { 147 | for (let i = 0; i < 10; i += 1) { 148 | const market = marketXprXmd; 149 | const lowestAsk = 18345.0111; 150 | const order = (currentStrategy as any).createBuyOrder(market.symbol, { 151 | highestBid: 18345.1234, 152 | lowestAsk, 153 | market, 154 | price: 18345.2222, 155 | }, i); 156 | const price = parseFloat(order.price); 157 | assert.isBelow(price, lowestAsk, `buy order would execute, price:${order.price} lowestAsk: ${lowestAsk}`); 158 | } 159 | }); 160 | }); 161 | 162 | describe('createSellOrder', () => { 163 | it('should always create an XPR_XUSDC sell order that is at least the order_min value', () => { 164 | for (let i = 0; i < 10; i += 1) { 165 | const market = marketXprXusdc; 166 | const order = (currentStrategy as any).createBuyOrder(market.symbol, { 167 | highestBid: 0.3745, 168 | lowestAsk: 0.3925, 169 | market, 170 | price: 0.38, 171 | }, i); 172 | const total = +(new BigNumber(order.price) 173 | .times(new BigNumber(order.quantity)) 174 | .times(new BigNumber(market.ask_token.multiplier))); 175 | const orderMin = parseInt(market.order_min, 10); 176 | assert.isAtLeast(total, orderMin, `total: ${total}, orderMin: ${orderMin}`); 177 | } 178 | }); 179 | 180 | xit('should always create an XPR_XMD sell order that is at least the order_min value', () => { 181 | for (let i = 0; i < 10; i += 1) { 182 | const market = marketXprXmd; 183 | const order = (currentStrategy as any).createSellOrder(market.symbol, { 184 | highestBid: 0.3745, 185 | lowestAsk: 0.3925, 186 | market, 187 | price: 0.38, 188 | }, i); 189 | const total = +(new BigNumber(order.price) 190 | .times(new BigNumber(order.quantity)) 191 | .times(new BigNumber(market.ask_token.multiplier))); 192 | const orderMin = parseInt(market.order_min, 10); 193 | assert.isAtLeast(total, orderMin, `total: ${total}, orderMin: ${orderMin}`); 194 | } 195 | }); 196 | 197 | xit('should always create an XBTC_XUSDC sell order that is at least the order_min value', () => { 198 | for (let i = 0; i < 10; i += 1) { 199 | const market = marketXbtcXusdt; 200 | const order = (currentStrategy as any).createSellOrder(market.symbol, { 201 | highestBid: 18345.1234, 202 | lowestAsk: 18345.0111, 203 | market, 204 | price: 18345.2222, 205 | }, i); 206 | const total = +(new BigNumber(order.price) 207 | .times(new BigNumber(order.quantity)) 208 | .times(new BigNumber(market.ask_token.multiplier))); 209 | const orderMin = parseInt(market.order_min, 10); 210 | assert.isAtLeast(total, orderMin, `total: ${total}, orderMin: ${orderMin}`); 211 | } 212 | }); 213 | 214 | it('should create an XPR_XUSDC sell order that will succeed as a postonly order', () => { 215 | for (let i = 0; i < 10; i += 1) { 216 | const market = marketXprXusdc; 217 | const highestBid = 0.3745; 218 | const order = (currentStrategy as any).createSellOrder(market.symbol, { 219 | highestBid, 220 | lowestAsk: 0.3925, 221 | market, 222 | price: 0.38, 223 | }, i); 224 | const price = parseFloat(order.price); 225 | assert.isAbove(price, highestBid, `sell order would execute, price:${order.price} lowestAsk: ${highestBid}`); 226 | } 227 | }); 228 | 229 | xit('should create an XPR_XMD sell order that will succeed as a postonly order', () => { 230 | for (let i = 0; i < 10; i += 1) { 231 | const market = marketXprXmd; 232 | const highestBid = 0.0456; 233 | const order = (currentStrategy as any).createSellOrder(market.symbol, { 234 | highestBid, 235 | lowestAsk: 0.1001, 236 | market, 237 | price: 0.1001, 238 | }, i); 239 | const price = parseFloat(order.price); 240 | assert.isAbove(price, highestBid, `sell order would execute, price:${order.price} lowestAsk: ${highestBid}`); 241 | } 242 | }); 243 | 244 | xit('should create an XBTC_XUSDT sell order that will succeed as a postonly order', () => { 245 | for (let i = 0; i < 10; i += 1) { 246 | const market = marketXprXmd; 247 | const highestBid = 18345.1234; 248 | const order = (currentStrategy as any).createSellOrder(market.symbol, { 249 | highestBid, 250 | lowestAsk: 18345.0111, 251 | market, 252 | price: 18345.2222, 253 | }, i); 254 | const price = parseFloat(order.price); 255 | assert.isAbove(price, highestBid, `sell order would execute, price:${order.price} lowestAsk: ${highestBid}`); 256 | } 257 | }); 258 | }); 259 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": [ 5 | "esnext" 6 | ], 7 | "skipLibCheck": true, 8 | "module": "esnext", 9 | "moduleResolution": "node", 10 | "outDir": "lib", 11 | "strict": true, 12 | "target": "esnext", 13 | "forceConsistentCasingInFileNames": true, 14 | "typeRoots": [ 15 | "./src/types", 16 | "./node_modules/@types", 17 | ] 18 | }, 19 | "ts-node": { 20 | "esm": true, 21 | "experimentalSpecifierResolution": "node" 22 | }, 23 | "include": [ 24 | "src/**/*.ts", 25 | "test/**/*.ts" 26 | ], 27 | "exclude": [ 28 | "node_modules", 29 | "lib" 30 | ] 31 | } --------------------------------------------------------------------------------