├── .env-example ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── LICENSE.md ├── README.md ├── jest.config.js ├── mock-data └── tls.cert ├── package-lock.json ├── package.json ├── src ├── __snapshots__ │ └── config.spec.ts.snap ├── arby.spec.ts ├── arby.ts ├── centralized │ ├── __snapshots__ │ │ ├── exchange-price.spec.ts.snap │ │ ├── minimum-order-quantity-filter.spec.ts.snap │ │ └── verify-markets.spec.ts.snap │ ├── assets.spec.ts │ ├── assets.ts │ ├── binance-price.ts │ ├── ccxt │ │ ├── __snapshots__ │ │ │ └── exchange.spec.ts.snap │ │ ├── cancel-order.spec.ts │ │ ├── cancel-order.ts │ │ ├── create-order.spec.ts │ │ ├── create-order.ts │ │ ├── exchange.spec.ts │ │ ├── exchange.ts │ │ ├── fetch-balance.spec.ts │ │ ├── fetch-balance.ts │ │ ├── fetch-open-orders.ts │ │ ├── fetch-orders.spec.ts │ │ ├── init.spec.ts │ │ ├── init.ts │ │ ├── load-markets.spec.ts │ │ └── load-markets.ts │ ├── convert-balances.spec.ts │ ├── convert-balances.ts │ ├── derive-order-quantity.spec.ts │ ├── derive-order-quantity.ts │ ├── exchange-price.spec.ts │ ├── exchange-price.ts │ ├── execute-order.spec.ts │ ├── execute-order.ts │ ├── kraken-price.ts │ ├── minimum-order-quantity-filter.spec.ts │ ├── minimum-order-quantity-filter.ts │ ├── order-builder.spec.ts │ ├── order-builder.ts │ ├── order.spec.ts │ ├── order.ts │ ├── remove-orders.spec.ts │ ├── remove-orders.ts │ ├── verify-markets.spec.ts │ └── verify-markets.ts ├── config.spec.ts ├── config.ts ├── constants.ts ├── logger.ts ├── opendex │ ├── __snapshots__ │ │ ├── assets-utils.spec.ts.snap │ │ └── process-listorders.spec.ts.snap │ ├── assets-utils.spec.ts │ ├── assets-utils.ts │ ├── assets.spec.ts │ ├── assets.ts │ ├── catch-error.spec.ts │ ├── catch-error.ts │ ├── complete.spec.ts │ ├── complete.ts │ ├── create-orders.spec.ts │ ├── create-orders.ts │ ├── errors.ts │ ├── orders.spec.ts │ ├── orders.ts │ ├── process-listorders.spec.ts │ ├── process-listorders.ts │ ├── remove-orders.spec.ts │ ├── remove-orders.ts │ ├── should-create-orders.spec.ts │ ├── should-create-orders.ts │ ├── swap-success.spec.ts │ ├── swap-success.ts │ └── xud │ │ ├── balance.spec.ts │ │ ├── balance.ts │ │ ├── client.spec.ts │ │ ├── client.ts │ │ ├── create-order.spec.ts │ │ ├── create-order.ts │ │ ├── list-orders.spec.ts │ │ ├── list-orders.ts │ │ ├── process-response.spec.ts │ │ ├── process-response.ts │ │ ├── remove-order.spec.ts │ │ ├── remove-order.ts │ │ ├── subscribe-swaps.spec.ts │ │ ├── subscribe-swaps.ts │ │ ├── trading-limits.spec.ts │ │ └── trading-limits.ts ├── proto │ ├── annotations_grpc_pb.js │ ├── annotations_pb.d.ts │ ├── annotations_pb.js │ ├── google │ │ ├── api │ │ │ ├── http_grpc_pb.js │ │ │ ├── http_pb.d.ts │ │ │ └── http_pb.js │ │ └── protobuf │ │ │ ├── descriptor_grpc_pb.js │ │ │ ├── descriptor_pb.d.ts │ │ │ └── descriptor_pb.js │ ├── xudrpc.swagger.json │ ├── xudrpc_grpc_pb.d.ts │ ├── xudrpc_grpc_pb.js │ ├── xudrpc_pb.d.ts │ └── xudrpc_pb.js ├── store.spec.ts ├── store.ts ├── test-utils.ts ├── trade │ ├── accumulate-fills.spec.ts │ ├── accumulate-fills.ts │ ├── cleanup.spec.ts │ ├── cleanup.ts │ ├── info.spec.ts │ ├── info.ts │ ├── trade.spec.ts │ └── trade.ts ├── utils.spec.ts └── utils.ts └── tsconfig.json /.env-example: -------------------------------------------------------------------------------- 1 | LOG_LEVEL=trace 2 | CEX=Binance 3 | CEX_API_KEY=abc 4 | CEX_API_SECRET=123 5 | DATA_DIR=/path/to/arby/data/dir 6 | OPENDEX_CERT_PATH=/path/to/.xud/tls.cert 7 | OPENDEX_RPC_HOST=localhost 8 | OPENDEX_RPC_PORT=8886 9 | MARGIN=0.06 10 | BASEASSET=BTC 11 | QUOTEASSET=USDT 12 | CEX_BASEASSET=BTC 13 | CEX_QUOTEASSET=USDT 14 | TEST_CENTRALIZED_EXCHANGE_BASEASSET_BALANCE=10 15 | TEST_CENTRALIZED_EXCHANGE_QUOTEASSET_BALANCE=100000 16 | LIVE_CEX=false 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | # don't lint build output (make sure it's set to your correct build folder name) 4 | dist 5 | # proto definitions 6 | src/proto 7 | # mock data 8 | mock-data 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'node': true 4 | }, 5 | rules: { 6 | '@typescript-eslint/no-non-null-assertion': 'off', 7 | '@typescript-eslint/no-explicit-any': 'off', 8 | '@typescript-eslint/no-var-requires': 'off', 9 | '@typescript-eslint/no-empty-function': 'off', 10 | '@typescript-eslint/ban-ts-comment': 'off', 11 | 'quotes': [2, 'single', { 'avoidEscape': true }], 12 | }, 13 | root: true, 14 | parser: '@typescript-eslint/parser', 15 | plugins: [ 16 | '@typescript-eslint', 17 | 'jest', 18 | ], 19 | extends: [ 20 | 'eslint:recommended', 21 | 'plugin:@typescript-eslint/recommended', 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [12.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: npm install and test 21 | run: | 22 | npm ci 23 | npm test 24 | env: 25 | CI: true 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /data 4 | .env 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.17.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src/proto 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | tabWidth: 2, 4 | singleQuote: true, 5 | arrowParens: 'avoid', 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Market Maker Bot 2 | 3 | ## Arby 4 | Arby is a market maker bot that allows to easily arbitrage between [OpenDEX](http://opendex.network/) and centralized exchanges like [Binance](https://www.binance.com). In other words, anyone with capital to spare is able to "earn interest" on their assets by running arby and providing liquidity to the OpenDEX network. 5 | 6 | The overall goal of Arby is to pull liquidity from centralized exchanges into OpenDEX and incentivize this process financially. 7 | 8 | ### How it works 9 | Arby looks for arbitrage opportunities on the OpenDEX network. It issues orders on OpenDEX based on the price of a connected centralized exchange, adding the configured `margin` on top. It will update the orders as soon as the price on the centralized exchange changes. 10 | 11 | Trades on OpenDEX are settled in seconds. This allows arby to execute the counter trade on the connected centralized exchange right after an order is settled on OpenDEX. Since the order on OpenDEX was issued with an additional margin, the trade on the centralized exchange can almost always be settled for a significantly better price. The price difference between the two trades is the profit (the "interest") arby generates. 12 | 13 | Example with real numbers: 14 | 15 | - Balances before starting Arby 16 | 17 | | Currency | OpenDEX | Binance | 18 | | -------- | ------- | ------- | 19 | | BTC | 1.0 | 1.0 | 20 | | USDT | 11000 | 11000 | 21 | 22 | - Total BTC balance between OpenDEX and Binance `1.0 + 1.0 = 2.0 BTC` 23 | - Total USDT balance between OpenDEX and Binance `11000 + 11000 = 22000 USDT` 24 | 25 | --- 26 | - Arby is started for BTC/USDT trading pair with 3% margin. The configured centralized exchange is Binance. 27 | - Balance on OpenDEX is 1 BTC and 1 BTC on Binance 28 | - USDT balance on OpenDEX is 10000 USDT and 10000 USDT on Binance 29 | - Arby monitors the latest price of BTC/USDT on Binance. The latest price is $10000. 30 | - Arby creates/updates the buy order on OpenDEX for `10000 - 0.03 * 10000 = $9700`. 31 | - Arby creates/updates the sell order on OpenDEX for `10000 * 1.03 = $10300`. 32 | - Whenever the latest price on Binance changes, Arby updates the orders on OpenDEX accordingly. 33 | 34 | ##### Scenario A 35 | - OpenDEX buy order is filled and settled for 1 BTC ($9700). 36 | - Arby issues a sell order on Binance for 0.97 BTC ($9700), locking in 0.03 BTC ($300) profit. 37 | - Balances after scenario A 38 | 39 | | Currency | OpenDEX | Binance | 40 | | -------- | ------- | ------- | 41 | | BTC | 2.0 | 0.03 | 42 | | USDT | 1300 | 20700 | 43 | 44 | - Total BTC balance between OpenDEX and Binance `2.0 + 0.03 = 2.03 BTC` (0.03 BTC yield) 45 | - Total USDT balance between OpenDEX and Binance `1300 + 20700 = 22000 USDT` 46 | 47 | --- 48 | 49 | ##### Scenario B 50 | - OpenDEX sell order is filled and settled for 1 BTC ($10300) 51 | - Arby issues a buy order on Binance for 1.03 ($10300), locking in 0.03 BTC ($300) profit. 52 | 53 | | Currency | OpenDEX | Binance | 54 | | -------- | ------- | ------- | 55 | | BTC | 0 | 2.03 | 56 | | USDT | 21300 | 700 | 57 | 58 | Total BTC balance between OpenDEX and Binance `2.0 + 0.03 = 2.03 BTC` (0.03 BTC yield) 59 | Total USDT balance between OpenDEX and Binance `21300 + 700 = 22000 USDT` 60 | 61 | --- 62 | 63 | ### FAQ 64 | 65 | #### Which exchanges are supported? 66 | Currently, Binance and Kraken are supported. Bitfinex is next on the roadmap. 67 | 68 | https://github.com/ExchangeUnion/market-maker-tools/issues/92 69 | 70 | #### What happens if I lose connectivity to Binance? 71 | Arby will automatically remove all orders on OpenDEX until connection is re-established. 72 | 73 | #### What happens if the order quantity is too big to execute on Binance? 74 | Arby will not execute trades on OpenDEX that it cannot counter-trade on Binance. 75 | 76 | #### What happens when the order quantity is too small to execute on Binance? 77 | Arby will automatically accumulate the traded quantity on OpenDEX and only execute an order on Binance when it is greater than or equal to the minimum amount on Binance. 78 | 79 | #### Is it possible to configure Arby to take profits in non-BTC assets? 80 | Support for taking profits in other assets (ETH, DAI, USDT etc.) is technically supported, but currently not enabled. 81 | 82 | #### What about rebalancing between centralized exchange and OpenDEX balances? 83 | Support for automatic rebalancing of the assets is planned in the upcoming releases. 84 | 85 | ### Setup instructions 86 | Recommended way of running Arby is by following the [market maker guide](https://docs.exchangeunion.com/start-earning/market-maker-guide). 87 | 88 | #### Development Setup 89 | The setup guide below is **only** for development purposes. Please refer to the guide above for production use. 90 | 91 | The development mode assumes a working [xud](https://github.com/ExchangeUnion/xud) setup with functioning swap clients for all currencies Arby is configured to use. 92 | 93 | ##### Requirements 94 | - Node v12.18.0+ 95 | 96 | ##### Install dependencies 97 | `npm i` 98 | 99 | ##### Configuration 100 | Copy `.env-example` to `.env` 101 | 102 | ##### Start in development mode 103 | `npm run dev:arby` 104 | 105 | ##### Tests 106 | `npm run test` 107 | or 108 | `npm run test:watch` to continuously run the tests. 109 | 110 | ### Disclaimer 111 | This is alpha software. Please be extra careful when using this on the mainnet. 112 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: [ '**/?(*.)+(spec|test).[jt]s?(x)' ], 5 | }; 6 | -------------------------------------------------------------------------------- /mock-data/tls.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICCTCCAXKgAwIBAgICB2AwDQYJKoZIhvcNAQELBQAwOjEmMCQGA1UEChMdWFVE 3 | IGF1dG9nZW5lcmF0ZWQgY2VydGlmaWNhdGUxEDAOBgNVBAMTB2FyYnVudHUwHhcN 4 | MjAwNjAzMTIyODI4WhcNMjUwNjAzMDAwMDAwWjA6MSYwJAYDVQQKEx1YVUQgYXV0 5 | b2dlbmVyYXRlZCBjZXJ0aWZpY2F0ZTEQMA4GA1UEAxMHYXJidW50dTCBnzANBgkq 6 | hkiG9w0BAQEFAAOBjQAwgYkCgYEA3rnc40B62X8ibIOQADqM3L6YqSeg/qPKznPY 7 | 6UupcPikzCNCOvGjWjdlGc6Pr/CVi5ZD4u01yaUUfNagAQTVFbgmcPI2seUwC4yR 8 | Sm9EvSGQkjJBsvKCu4FehBfI6vpq0UqRWncxZapTELYvrgCg0z15xRUNN4TXNm8K 9 | vs1YmpMCAwEAAaMeMBwwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMA0GCSqG 10 | SIb3DQEBCwUAA4GBAE9pGJSUPXAKEjf46osm7ZUnbJHqLI3PaeGuxoahUOsmlcwN 11 | ZO9h1dxRxeyH8NUMOlBCjdym0Nqo3MNlX2lZSO6RS+lmamF43xWYKhHeTFnNPbJQ 12 | XtyqXJiIBCl1hPM8Ce3M/W0t2MzURiqyH9Rq1ja6BP/TpR6lTDTrDVEBt1iJ 13 | -----END CERTIFICATE----- 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "market-maker-tools", 3 | "version": "1.4.0", 4 | "description": "Market maker tools for OpenDEX", 5 | "main": "dist/arby.js", 6 | "scripts": { 7 | "start:arby": "npm run compile && node dist/arby.js", 8 | "dev": "npm run compile && concurrently --kill-others \"npm run compile:watch\" \"npm run nodemon:watch\"", 9 | "compile": "npm run lint && tsc && cross-os postcompile", 10 | "lint": "eslint . --ext .js,.ts", 11 | "compile:watch": "tsc -w", 12 | "nodemon:watch": "nodemon --watch dist -e js dist/arby.js", 13 | "test": "prettier --check src/ && npm run compile && npm run jest:clear:cache && jest", 14 | "test:watch": "npm run compile && npm run jest:clear:cache && jest --watch", 15 | "jest:clear:cache": "jest --clearCache", 16 | "prettier": "prettier --write src/", 17 | "clean": "rm -Rf dist/* && npm i" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/ExchangeUnion/market-maker-tools.git" 22 | }, 23 | "keywords": [ 24 | "arbitrage", 25 | "opendex", 26 | "exchange" 27 | ], 28 | "author": "Karl Ranna", 29 | "bugs": { 30 | "url": "https://github.com/ExchangeUnion/market-maker-tools/issues" 31 | }, 32 | "homepage": "https://github.com/ExchangeUnion/market-maker-tools#readme", 33 | "devDependencies": { 34 | "@types/jest": "25.2.3", 35 | "@types/node": "14.0.5", 36 | "@types/ramda": "0.27.6", 37 | "@types/uuid": "8.0.0", 38 | "@types/ws": "7.2.4", 39 | "@typescript-eslint/eslint-plugin": "3.0.1", 40 | "@typescript-eslint/parser": "3.0.1", 41 | "concurrently": "5.2.0", 42 | "cross-os": "1.3.0", 43 | "eslint": "7.1.0", 44 | "eslint-plugin-jest": "23.13.2", 45 | "google-protobuf": "3.12.1", 46 | "jest": "26.6.3", 47 | "nodemon": "2.0.4", 48 | "prettier": "2.0.5", 49 | "ts-jest": "26.4.4", 50 | "typescript": "3.9.3" 51 | }, 52 | "dependencies": { 53 | "@grpc/grpc-js": "1.1.7", 54 | "bignumber.js": "9.0.0", 55 | "ccxt": "1.30.51", 56 | "dotenv": "8.2.0", 57 | "moment": "2.26.0", 58 | "ramda": "0.27.0", 59 | "rxjs": "6.5.5", 60 | "uuid": "8.1.0", 61 | "winston": "3.2.1", 62 | "ws": "7.3.0" 63 | }, 64 | "cross-os": { 65 | "postcompile": { 66 | "linux": "rsync -am --include '*/' --include '*.js*' --exclude '*' src/proto/ dist/proto", 67 | "darwin": "rsync -am --include '*/' --include '*.js*' --exclude '*' src/proto/ dist/proto", 68 | "win32": "xcopy /s src\\proto\\*.js* dist\\proto\\* >nul" 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/__snapshots__/config.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`checkConfigOptions TEST_MODE disabled requires CEX_API_KEY 1`] = `"Incomplete configuration. Please add the following options to .env or as environment variables: CEX_API_KEY"`; 4 | 5 | exports[`checkConfigOptions TEST_MODE disabled requires CEX_API_SECRET 1`] = `"Incomplete configuration. Please add the following options to .env or as environment variables: CEX_API_SECRET"`; 6 | 7 | exports[`checkConfigOptions TEST_MODE enabled requires TEST_CENTRALIZED_EXCHANGE_BASEASSET_BALANCE 1`] = `"Incomplete configuration. Please add the following options to .env or as environment variables: TEST_CENTRALIZED_EXCHANGE_BASEASSET_BALANCE"`; 8 | 9 | exports[`checkConfigOptions TEST_MODE enabled requires TEST_CENTRALIZED_EXCHANGE_QUOTEASSET_BALANCE 1`] = `"Incomplete configuration. Please add the following options to .env or as environment variables: TEST_CENTRALIZED_EXCHANGE_QUOTEASSET_BALANCE"`; 10 | -------------------------------------------------------------------------------- /src/arby.spec.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { TestScheduler } from 'rxjs/testing'; 3 | import { startArby } from '../src/arby'; 4 | import { Config } from '../src/config'; 5 | import { InitCEXResponse } from './centralized/ccxt/init'; 6 | import { getLoggers } from './test-utils'; 7 | 8 | let testScheduler: TestScheduler; 9 | 10 | type AssertStartArbyParams = { 11 | expected: string; 12 | inputEvents: { 13 | config$: string; 14 | getTrade$: string; 15 | shutdown$: string; 16 | cleanup$: string; 17 | initCEX$: string; 18 | }; 19 | verifyMarkets?: () => boolean; 20 | }; 21 | 22 | const assertStartArby = ({ 23 | expected, 24 | inputEvents, 25 | verifyMarkets, 26 | }: AssertStartArbyParams) => { 27 | testScheduler.run(helpers => { 28 | const { cold, expectObservable } = helpers; 29 | const config$ = cold(inputEvents.config$) as Observable; 30 | const getTrade$ = () => { 31 | return (cold(inputEvents.getTrade$) as unknown) as Observable; 32 | }; 33 | const shutdown$ = cold(inputEvents.shutdown$); 34 | const cleanup$ = () => { 35 | return cold(inputEvents.cleanup$); 36 | }; 37 | const initCEX$ = () => { 38 | return (cold(inputEvents.initCEX$) as unknown) as Observable< 39 | InitCEXResponse 40 | >; 41 | }; 42 | const arby$ = startArby({ 43 | config$, 44 | getLoggers, 45 | shutdown$, 46 | trade$: getTrade$, 47 | cleanup$, 48 | initCEX$, 49 | verifyMarkets: verifyMarkets ? verifyMarkets : () => true, 50 | }); 51 | expectObservable(arby$).toBe(expected, undefined, { message: 'error' }); 52 | }); 53 | }; 54 | 55 | describe('startArby', () => { 56 | beforeEach(() => { 57 | testScheduler = new TestScheduler((actual, expected) => { 58 | expect(actual).toEqual(expected); 59 | }); 60 | }); 61 | 62 | it('waits for valid configuration before starting', () => { 63 | const inputEvents = { 64 | config$: '1000ms a', 65 | initCEX$: '1s a', 66 | getTrade$: 'b', 67 | shutdown$: '', 68 | cleanup$: '', 69 | }; 70 | const expected = '2s b'; 71 | assertStartArby({ 72 | inputEvents, 73 | expected, 74 | }); 75 | }); 76 | 77 | it('errors when verifyMarkets fails', () => { 78 | const inputEvents = { 79 | config$: '1000ms a', 80 | initCEX$: '1s a', 81 | getTrade$: 'b', 82 | shutdown$: '', 83 | cleanup$: '', 84 | }; 85 | const expected = '2s #'; 86 | assertStartArby({ 87 | inputEvents, 88 | expected, 89 | verifyMarkets: () => { 90 | throw { message: 'error' }; 91 | }, 92 | }); 93 | }); 94 | 95 | it('performs cleanup when shutting down gracefully', () => { 96 | const inputEvents = { 97 | config$: 'a', 98 | initCEX$: '1s a', 99 | getTrade$: '500ms b', 100 | shutdown$: '10s c', 101 | cleanup$: '2s a', 102 | }; 103 | const expected = '1500ms b 11499ms a'; 104 | assertStartArby({ 105 | inputEvents, 106 | expected, 107 | }); 108 | }); 109 | 110 | it('performs cleanup when getTrade$ errors', () => { 111 | const inputEvents = { 112 | config$: 'a', 113 | initCEX$: '1s a', 114 | getTrade$: '500ms #', 115 | shutdown$: '10s c', 116 | cleanup$: '2s a', 117 | }; 118 | const expected = '3500ms a'; 119 | assertStartArby({ 120 | inputEvents, 121 | expected, 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /src/arby.ts: -------------------------------------------------------------------------------- 1 | import { concat, Observable } from 'rxjs'; 2 | import { catchError, mergeMap, takeUntil } from 'rxjs/operators'; 3 | import { getExchange } from './centralized/ccxt/exchange'; 4 | import { 5 | initCEX$, 6 | InitCEXparams, 7 | InitCEXResponse, 8 | } from './centralized/ccxt/init'; 9 | import { loadMarkets$ } from './centralized/ccxt/load-markets'; 10 | import { getCentralizedExchangePrice$ } from './centralized/exchange-price'; 11 | import { getCentralizedExchangeOrder$ } from './centralized/order'; 12 | import { removeCEXorders$ } from './centralized/remove-orders'; 13 | import { Config, getConfig$ } from './config'; 14 | import { Logger, Loggers } from './logger'; 15 | import { catchOpenDEXerror } from './opendex/catch-error'; 16 | import { getOpenDEXcomplete$ } from './opendex/complete'; 17 | import { removeOpenDEXorders$ } from './opendex/remove-orders'; 18 | import { getArbyStore } from './store'; 19 | import { getCleanup$, GetCleanupParams } from './trade/cleanup'; 20 | import { getNewTrade$, GetTradeParams } from './trade/trade'; 21 | import { getStartShutdown$ } from './utils'; 22 | import { Dictionary, Market } from 'ccxt'; 23 | import { verifyMarkets } from './centralized/verify-markets'; 24 | 25 | type StartArbyParams = { 26 | config$: Observable; 27 | getLoggers: (config: Config) => Loggers; 28 | shutdown$: Observable; 29 | trade$: ({ 30 | config, 31 | loggers, 32 | getCentralizedExchangeOrder$, 33 | getOpenDEXcomplete$, 34 | shutdown$, 35 | }: GetTradeParams) => Observable; 36 | cleanup$: ({ 37 | config, 38 | removeOpenDEXorders$, 39 | }: GetCleanupParams) => Observable; 40 | initCEX$: ({ 41 | getExchange, 42 | config, 43 | loadMarkets$, 44 | }: InitCEXparams) => Observable; 45 | verifyMarkets: (config: Config, CEXmarkets: Dictionary) => boolean; 46 | }; 47 | 48 | const logConfig = (config: Config, logger: Logger) => { 49 | const { 50 | LOG_LEVEL, 51 | DATA_DIR, 52 | OPENDEX_CERT_PATH, 53 | OPENDEX_RPC_HOST, 54 | OPENDEX_RPC_PORT, 55 | MARGIN, 56 | CEX_BASEASSET, 57 | CEX_QUOTEASSET, 58 | BASEASSET, 59 | QUOTEASSET, 60 | TEST_CENTRALIZED_EXCHANGE_QUOTEASSET_BALANCE, 61 | TEST_CENTRALIZED_EXCHANGE_BASEASSET_BALANCE, 62 | TEST_MODE, 63 | CEX, 64 | } = config; 65 | logger.info(`Running with config: 66 | TEST_MODE: ${TEST_MODE} 67 | CEX: ${CEX} 68 | LOG_LEVEL: ${LOG_LEVEL} 69 | DATA_DIR: ${DATA_DIR} 70 | OPENDEX_CERT_PATH: ${OPENDEX_CERT_PATH} 71 | OPENDEX_RPC_HOST: ${OPENDEX_RPC_HOST} 72 | OPENDEX_RPC_PORT: ${OPENDEX_RPC_PORT} 73 | MARGIN: ${MARGIN} 74 | BASEASSET: ${BASEASSET} 75 | QUOTEASSET: ${QUOTEASSET} 76 | CEX_BASEASSET: ${CEX_BASEASSET} 77 | CEX_QUOTEASSET: ${CEX_QUOTEASSET} 78 | TEST_CENTRALIZED_EXCHANGE_BASEASSET_BALANCE: ${TEST_CENTRALIZED_EXCHANGE_BASEASSET_BALANCE} 79 | TEST_CENTRALIZED_EXCHANGE_QUOTEASSET_BALANCE: ${TEST_CENTRALIZED_EXCHANGE_QUOTEASSET_BALANCE}`); 80 | }; 81 | 82 | export const startArby = ({ 83 | config$, 84 | getLoggers, 85 | shutdown$, 86 | trade$, 87 | cleanup$, 88 | initCEX$, 89 | verifyMarkets, 90 | }: StartArbyParams): Observable => { 91 | const store = getArbyStore(); 92 | return config$.pipe( 93 | mergeMap(config => { 94 | const CEX$ = initCEX$({ 95 | config, 96 | loadMarkets$, 97 | getExchange, 98 | }); 99 | return CEX$.pipe( 100 | mergeMap(({ markets: CEXmarkets, exchange: CEX }) => { 101 | const loggers = getLoggers(config); 102 | loggers.global.info('Starting. Hello, Arby.'); 103 | logConfig(config, loggers.global); 104 | verifyMarkets(config, CEXmarkets); 105 | const tradeComplete$ = trade$({ 106 | config, 107 | loggers, 108 | getOpenDEXcomplete$, 109 | shutdown$, 110 | getCentralizedExchangeOrder$, 111 | catchOpenDEXerror, 112 | getCentralizedExchangePrice$, 113 | CEX, 114 | store, 115 | }).pipe(takeUntil(shutdown$)); 116 | return concat( 117 | tradeComplete$, 118 | cleanup$({ 119 | config, 120 | loggers, 121 | removeOpenDEXorders$, 122 | removeCEXorders$, 123 | CEX, 124 | }) 125 | ).pipe( 126 | catchError(e => { 127 | loggers.global.info( 128 | `Unrecoverable error: ${JSON.stringify(e)} - cleaning up.` 129 | ); 130 | return cleanup$({ 131 | config, 132 | loggers, 133 | removeOpenDEXorders$, 134 | removeCEXorders$, 135 | CEX, 136 | }); 137 | }) 138 | ); 139 | }) 140 | ); 141 | }) 142 | ); 143 | }; 144 | 145 | const getLoggers = (config: Config) => { 146 | return Logger.createLoggers(config.LOG_LEVEL, `${config.DATA_DIR}/arby.log`); 147 | }; 148 | 149 | if (!module.parent) { 150 | startArby({ 151 | trade$: getNewTrade$, 152 | config$: getConfig$(), 153 | getLoggers, 154 | shutdown$: getStartShutdown$(), 155 | cleanup$: getCleanup$, 156 | initCEX$, 157 | verifyMarkets, 158 | }).subscribe({ 159 | error: error => { 160 | if (error.message) { 161 | console.log(`Error: ${error.message}`); 162 | } else { 163 | console.log(error); 164 | } 165 | process.exit(1); 166 | }, 167 | complete: () => console.log('Shutdown complete. Goodbye, Arby.'), 168 | }); 169 | } 170 | -------------------------------------------------------------------------------- /src/centralized/__snapshots__/exchange-price.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getCentralizedExchangePrice$ errors for unknown exchange 1`] = `"Could not get price feed for unknown exchange: ASDF"`; 4 | -------------------------------------------------------------------------------- /src/centralized/__snapshots__/minimum-order-quantity-filter.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`quantityAboveMinimum throws for unknown asset KILRAU 1`] = `"Could not retrieve minimum order quantity for KILRAU"`; 4 | -------------------------------------------------------------------------------- /src/centralized/__snapshots__/verify-markets.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`verifyMarkets throws when BTC/USD trading pair is inactive 1`] = `"The configured trading pair BTC/USD does not exist on Binance"`; 4 | 5 | exports[`verifyMarkets throws when BTC/USD trading pair not exist 1`] = `"The configured trading pair BTC/USD does not exist on Binance"`; 6 | -------------------------------------------------------------------------------- /src/centralized/assets.spec.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Balances, Exchange } from 'ccxt'; 3 | import { Observable } from 'rxjs'; 4 | import { TestScheduler } from 'rxjs/testing'; 5 | import { Config } from '../config'; 6 | import { getLoggers, testConfig } from '../test-utils'; 7 | import { ExchangeAssetAllocation } from '../trade/info'; 8 | import { getCentralizedExchangeAssets$ } from './assets'; 9 | 10 | let testScheduler: TestScheduler; 11 | 12 | type AssertCEXassetsParams = { 13 | config: Config; 14 | inputEvents: { 15 | unsubscribe: string; 16 | CEXfetchBalance$: string; 17 | }; 18 | expected: string; 19 | expectedValues?: { 20 | a: ExchangeAssetAllocation; 21 | }; 22 | }; 23 | 24 | const assertCEXassets = ({ 25 | config, 26 | inputEvents, 27 | expected, 28 | expectedValues, 29 | }: AssertCEXassetsParams) => { 30 | testScheduler.run(helpers => { 31 | const { cold, expectObservable } = helpers; 32 | const CEX = (null as unknown) as Exchange; 33 | const CEXfetchBalance$ = () => { 34 | return (cold(inputEvents.CEXfetchBalance$) as unknown) as Observable< 35 | Balances 36 | >; 37 | }; 38 | const convertBalances = (_a: any, v: any) => v; 39 | const logAssetAllocation = (_a: any, v: any) => v; 40 | const CEXassets$ = getCentralizedExchangeAssets$({ 41 | logger: getLoggers().centralized, 42 | config, 43 | CEX, 44 | CEXfetchBalance$, 45 | convertBalances, 46 | logAssetAllocation, 47 | }); 48 | expectObservable(CEXassets$, inputEvents.unsubscribe).toBe( 49 | expected, 50 | expectedValues 51 | ); 52 | }); 53 | }; 54 | 55 | describe('CEXassets$', () => { 56 | beforeEach(() => { 57 | testScheduler = new TestScheduler((actual, expected) => { 58 | expect(actual).toEqual(expected); 59 | }); 60 | }); 61 | 62 | it('emits test config values every 30 seconds', () => { 63 | const config = testConfig(); 64 | assertCEXassets({ 65 | config, 66 | inputEvents: { 67 | CEXfetchBalance$: '', 68 | unsubscribe: '70s !', 69 | }, 70 | expected: 'a 29999ms a 29999ms a', 71 | expectedValues: { 72 | a: { 73 | baseAssetBalance: new BigNumber( 74 | config.TEST_CENTRALIZED_EXCHANGE_BASEASSET_BALANCE 75 | ), 76 | quoteAssetBalance: new BigNumber( 77 | config.TEST_CENTRALIZED_EXCHANGE_QUOTEASSET_BALANCE 78 | ), 79 | }, 80 | }, 81 | }); 82 | }); 83 | 84 | it('fetches balances from CEX every 30 seconds', () => { 85 | const config = { 86 | ...testConfig(), 87 | TEST_MODE: false, 88 | }; 89 | assertCEXassets({ 90 | config, 91 | inputEvents: { 92 | CEXfetchBalance$: '1s a', 93 | unsubscribe: '70s !', 94 | }, 95 | expected: '1s a 30999ms a 29999ms a', 96 | }); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/centralized/assets.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from 'bignumber.js'; 2 | import { Balances, Exchange } from 'ccxt'; 3 | import { curry } from 'ramda'; 4 | import { interval, Observable } from 'rxjs'; 5 | import { map, mapTo, repeatWhen, startWith, take, tap } from 'rxjs/operators'; 6 | import { Config } from '../config'; 7 | import { Logger } from '../logger'; 8 | import { ExchangeAssetAllocation } from '../trade/info'; 9 | 10 | const logAssetAllocation = ( 11 | logger: Logger, 12 | source: Observable 13 | ) => { 14 | return source.pipe( 15 | tap(({ baseAssetBalance, quoteAssetBalance }) => { 16 | logger.info( 17 | `Base asset balance ${baseAssetBalance.toString()} and quote asset balance ${quoteAssetBalance.toString()}` 18 | ); 19 | }) 20 | ); 21 | }; 22 | 23 | type GetCentralizedExchangeAssetsParams = { 24 | config: Config; 25 | logger: Logger; 26 | CEX: Exchange; 27 | CEXfetchBalance$: (exchange: Exchange) => Observable; 28 | convertBalances: ( 29 | config: Config, 30 | balances: Balances 31 | ) => ExchangeAssetAllocation; 32 | logAssetAllocation: ( 33 | logger: Logger, 34 | source: Observable 35 | ) => Observable; 36 | }; 37 | 38 | const getCentralizedExchangeAssets$ = ({ 39 | config, 40 | logger, 41 | CEX, 42 | CEXfetchBalance$, 43 | convertBalances, 44 | logAssetAllocation, 45 | }: GetCentralizedExchangeAssetsParams): Observable => { 46 | const logAssetAllocationWithLogger = curry(logAssetAllocation)(logger); 47 | if (!config.TEST_MODE) { 48 | const convertBalancesWithConfig = curry(convertBalances)(config); 49 | return CEXfetchBalance$(CEX).pipe( 50 | map(convertBalancesWithConfig), 51 | logAssetAllocationWithLogger, 52 | take(1), 53 | // refetch assets every 30 seconds 54 | repeatWhen(() => { 55 | return interval(30000); 56 | }) 57 | ); 58 | } else { 59 | const testCentralizedBalances = { 60 | baseAssetBalance: new BigNumber( 61 | config.TEST_CENTRALIZED_EXCHANGE_BASEASSET_BALANCE 62 | ), 63 | quoteAssetBalance: new BigNumber( 64 | config.TEST_CENTRALIZED_EXCHANGE_QUOTEASSET_BALANCE 65 | ), 66 | }; 67 | return interval(30000).pipe( 68 | startWith(testCentralizedBalances), 69 | mapTo(testCentralizedBalances), 70 | logAssetAllocationWithLogger 71 | ); 72 | } 73 | }; 74 | 75 | export { 76 | getCentralizedExchangeAssets$, 77 | GetCentralizedExchangeAssetsParams, 78 | logAssetAllocation, 79 | }; 80 | -------------------------------------------------------------------------------- /src/centralized/binance-price.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Observable, throwError } from 'rxjs'; 3 | import { catchError, share, distinctUntilChanged } from 'rxjs/operators'; 4 | import WebSocket from 'ws'; 5 | import { errors } from '../opendex/errors'; 6 | import { GetPriceParams } from './exchange-price'; 7 | 8 | const getBinancePrice$ = ({ 9 | config, 10 | logger, 11 | }: GetPriceParams): Observable => { 12 | const priceObservable: Observable = new Observable(observer => { 13 | const tradingPair = `${config.CEX_BASEASSET}${config.CEX_QUOTEASSET}`; 14 | const url = `wss://stream.binance.com:9443/ws/${tradingPair.toLowerCase()}@aggTrade`; 15 | const socket = new WebSocket(url); 16 | socket.onopen = () => { 17 | logger.trace(`${tradingPair} established connection to ${url}`); 18 | }; 19 | socket.on('error', e => { 20 | observer.error(e); 21 | }); 22 | const heartbeat = () => { 23 | logger.trace(`heartbeat from ${tradingPair} socket`); 24 | }; 25 | socket.onclose = (event: WebSocket.CloseEvent) => { 26 | if (event.reason) { 27 | logger.trace( 28 | `${tradingPair} stream closed with reason: ${event.reason}` 29 | ); 30 | } else { 31 | logger.trace(`${tradingPair} stream closed`); 32 | } 33 | }; 34 | socket.on('ping', heartbeat); 35 | socket.on('open', heartbeat); 36 | socket.onmessage = (event: WebSocket.MessageEvent) => { 37 | const aggTrade = JSON.parse(event.data.toString()); 38 | const { p: priceString } = aggTrade; 39 | const price = new BigNumber(priceString); 40 | observer.next(price); 41 | }; 42 | return () => { 43 | socket.terminate(); 44 | }; 45 | }); 46 | return priceObservable.pipe( 47 | catchError(() => { 48 | return throwError(errors.CENTRALIZED_EXCHANGE_PRICE_FEED_ERROR); 49 | }), 50 | distinctUntilChanged((a, b) => a.isEqualTo(b)), 51 | share() 52 | ); 53 | }; 54 | 55 | export { getBinancePrice$ }; 56 | -------------------------------------------------------------------------------- /src/centralized/ccxt/__snapshots__/exchange.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getExchange throws for unsupported exchange 1`] = `[Error: Could not get centralized exchange. Invalide CEX configuration option]`; 4 | -------------------------------------------------------------------------------- /src/centralized/ccxt/cancel-order.spec.ts: -------------------------------------------------------------------------------- 1 | import { cancelOrder$ } from './cancel-order'; 2 | import { Exchange } from 'ccxt'; 3 | import { testConfig } from '../../test-utils'; 4 | 5 | describe('CCXT', () => { 6 | it('cancel order', done => { 7 | expect.assertions(2); 8 | const cancelOrderResponse = 'cancelResponse'; 9 | const mockCancelOrder = jest.fn(() => Promise.resolve(cancelOrderResponse)); 10 | const exchange = ({ 11 | cancelOrder: mockCancelOrder, 12 | } as unknown) as Exchange; 13 | const orderId = '123'; 14 | const config = testConfig(); 15 | const { CEX_BASEASSET, CEX_QUOTEASSET } = config; 16 | const tradingPair = `${CEX_BASEASSET}/${CEX_QUOTEASSET}`; 17 | const orders$ = cancelOrder$(exchange, config, orderId); 18 | orders$.subscribe({ 19 | next: actualResponse => { 20 | expect(actualResponse).toEqual(cancelOrderResponse); 21 | expect(mockCancelOrder).toHaveBeenCalledWith(orderId, tradingPair); 22 | }, 23 | complete: done, 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/centralized/ccxt/cancel-order.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, Order } from 'ccxt'; 2 | import { from, Observable, defer } from 'rxjs'; 3 | import { Config } from '../../config'; 4 | 5 | const cancelOrder$ = ( 6 | exchange: Exchange, 7 | config: Config, 8 | orderId: string 9 | ): Observable => { 10 | return defer(() => 11 | from( 12 | exchange.cancelOrder( 13 | orderId, 14 | `${config.CEX_BASEASSET}/${config.CEX_QUOTEASSET}` 15 | ) 16 | ) 17 | ); 18 | }; 19 | 20 | export { cancelOrder$ }; 21 | -------------------------------------------------------------------------------- /src/centralized/ccxt/create-order.spec.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Exchange } from 'ccxt'; 3 | import { Config } from '../../config'; 4 | import { OrderSide } from '../../constants'; 5 | import { testConfig } from '../../test-utils'; 6 | import { createOrder$ } from './create-order'; 7 | 8 | describe('CCXT', () => { 9 | let orderQuantity: BigNumber; 10 | beforeEach(() => { 11 | orderQuantity = new BigNumber('0.01'); 12 | }); 13 | 14 | it('Binance ETHBTC sell order', done => { 15 | expect.assertions(3); 16 | const orderResponse = 'orderResponse'; 17 | const createMarketOrder = jest.fn(() => Promise.resolve(orderResponse)); 18 | const exchange = ({ 19 | createMarketOrder, 20 | } as unknown) as Exchange; 21 | const config: Config = { 22 | ...testConfig(), 23 | ...{ 24 | CEX_BASEASSET: 'ETH', 25 | CEX_QUOTEASSET: 'BTC', 26 | CEX: 'Binance', 27 | }, 28 | }; 29 | const expectedSymbol = `${config.CEX_BASEASSET}/${config.CEX_QUOTEASSET}`; 30 | const sellOrder$ = createOrder$({ 31 | config, 32 | exchange, 33 | side: OrderSide.SELL, 34 | quantity: orderQuantity, 35 | }); 36 | sellOrder$.subscribe({ 37 | next: actualOrderResponse => { 38 | expect(actualOrderResponse).toEqual(orderResponse); 39 | }, 40 | complete: () => { 41 | expect(createMarketOrder).toHaveBeenCalledTimes(1); 42 | expect(createMarketOrder).toHaveBeenCalledWith( 43 | expectedSymbol, 44 | OrderSide.SELL, 45 | orderQuantity.toNumber(), 46 | undefined, 47 | undefined 48 | ); 49 | done(); 50 | }, 51 | }); 52 | }); 53 | 54 | it('Binance BTCUSDT buy order', done => { 55 | expect.assertions(3); 56 | const orderResponse = 'orderResponse'; 57 | const createMarketOrder = jest.fn(() => Promise.resolve(orderResponse)); 58 | const exchange = ({ 59 | createMarketOrder, 60 | } as unknown) as Exchange; 61 | const config: Config = { 62 | ...testConfig(), 63 | ...{ 64 | CEX_BASEASSET: 'BTC', 65 | CEX_QUOTEASSET: 'USDT', 66 | CEX: 'Binance', 67 | }, 68 | }; 69 | orderQuantity = new BigNumber('0.12345678'); 70 | const expectedSymbol = `${config.CEX_BASEASSET}/${config.CEX_QUOTEASSET}`; 71 | const buyOrder$ = createOrder$({ 72 | config, 73 | exchange, 74 | side: OrderSide.BUY, 75 | quantity: orderQuantity, 76 | }); 77 | buyOrder$.subscribe({ 78 | next: actualOrderResponse => { 79 | expect(actualOrderResponse).toEqual(orderResponse); 80 | }, 81 | complete: () => { 82 | expect(createMarketOrder).toHaveBeenCalledTimes(1); 83 | expect(createMarketOrder).toHaveBeenCalledWith( 84 | expectedSymbol, 85 | OrderSide.BUY, 86 | 0.1234, 87 | undefined, 88 | undefined 89 | ); 90 | done(); 91 | }, 92 | }); 93 | }); 94 | 95 | it('Kraken BTCUSDT buy order', done => { 96 | expect.assertions(3); 97 | const orderResponse = 'orderResponse'; 98 | const createMarketOrder = jest.fn(() => Promise.resolve(orderResponse)); 99 | const exchange = ({ 100 | createMarketOrder, 101 | } as unknown) as Exchange; 102 | const config: Config = { 103 | ...testConfig(), 104 | ...{ 105 | CEX_BASEASSET: 'BTC', 106 | CEX_QUOTEASSET: 'USDT', 107 | CEX: 'Kraken', 108 | }, 109 | }; 110 | const expectedSymbol = `${config.CEX_BASEASSET}/${config.CEX_QUOTEASSET}`; 111 | const buyOrder$ = createOrder$({ 112 | config, 113 | exchange, 114 | side: OrderSide.BUY, 115 | quantity: orderQuantity, 116 | }); 117 | buyOrder$.subscribe({ 118 | next: actualOrderResponse => { 119 | expect(actualOrderResponse).toEqual(orderResponse); 120 | }, 121 | complete: () => { 122 | expect(createMarketOrder).toHaveBeenCalledTimes(1); 123 | expect(createMarketOrder).toHaveBeenCalledWith( 124 | expectedSymbol, 125 | OrderSide.BUY, 126 | orderQuantity.toNumber(), 127 | undefined, 128 | expect.objectContaining({ 129 | trading_agreement: 'agree', 130 | }) 131 | ); 132 | done(); 133 | }, 134 | }); 135 | }); 136 | 137 | it('Kraken ETHBTC sell order', done => { 138 | expect.assertions(3); 139 | const orderResponse = 'orderResponse'; 140 | const createMarketOrder = jest.fn(() => Promise.resolve(orderResponse)); 141 | const exchange = ({ 142 | createMarketOrder, 143 | } as unknown) as Exchange; 144 | const config: Config = { 145 | ...testConfig(), 146 | ...{ 147 | CEX_BASEASSET: 'ETH', 148 | CEX_QUOTEASSET: 'BTC', 149 | CEX: 'Kraken', 150 | }, 151 | }; 152 | const expectedSymbol = `${config.CEX_BASEASSET}/${config.CEX_QUOTEASSET}`; 153 | const sellOrder$ = createOrder$({ 154 | config, 155 | exchange, 156 | side: OrderSide.SELL, 157 | quantity: orderQuantity, 158 | }); 159 | sellOrder$.subscribe({ 160 | next: actualOrderResponse => { 161 | expect(actualOrderResponse).toEqual(orderResponse); 162 | }, 163 | complete: () => { 164 | expect(createMarketOrder).toHaveBeenCalledTimes(1); 165 | expect(createMarketOrder).toHaveBeenCalledWith( 166 | expectedSymbol, 167 | OrderSide.SELL, 168 | orderQuantity.toNumber(), 169 | undefined, 170 | expect.objectContaining({ 171 | trading_agreement: 'agree', 172 | }) 173 | ); 174 | done(); 175 | }, 176 | }); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /src/centralized/ccxt/create-order.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, Order } from 'ccxt'; 2 | import { OrderSide } from '../../constants'; 3 | import BigNumber from 'bignumber.js'; 4 | import { Observable, from, defer } from 'rxjs'; 5 | import { Config } from '../../config'; 6 | 7 | type CreateOrderParams = { 8 | config: Config; 9 | exchange: Exchange; 10 | side: OrderSide; 11 | quantity: BigNumber; 12 | }; 13 | 14 | const createOrder$ = ({ 15 | config, 16 | exchange, 17 | side, 18 | quantity, 19 | }: CreateOrderParams): Observable => { 20 | return defer(() => { 21 | const price = undefined; 22 | const params = 23 | config.CEX === 'Kraken' ? { trading_agreement: 'agree' } : undefined; 24 | return from( 25 | exchange.createMarketOrder( 26 | `${config.CEX_BASEASSET}/${config.CEX_QUOTEASSET}`, 27 | side, 28 | parseFloat(quantity.toFixed(4, BigNumber.ROUND_DOWN)), 29 | price, 30 | params 31 | ) 32 | ); 33 | }); 34 | }; 35 | 36 | export { createOrder$, CreateOrderParams }; 37 | -------------------------------------------------------------------------------- /src/centralized/ccxt/exchange.spec.ts: -------------------------------------------------------------------------------- 1 | import ccxt from 'ccxt'; 2 | import { testConfig } from '../../test-utils'; 3 | import { getExchange } from './exchange'; 4 | 5 | jest.mock('ccxt'); 6 | 7 | describe('getExchange', () => { 8 | afterEach(() => { 9 | jest.clearAllMocks(); 10 | }); 11 | 12 | it('initializes Binance with API key and secret', () => { 13 | const config = { 14 | ...testConfig(), 15 | ...{ CEX: 'BINANCE' }, 16 | }; 17 | getExchange(config); 18 | expect(ccxt.binance).toHaveBeenCalledTimes(1); 19 | expect(ccxt.binance).toHaveBeenCalledWith( 20 | expect.objectContaining({ 21 | apiKey: config.CEX_API_KEY, 22 | secret: config.CEX_API_SECRET, 23 | }) 24 | ); 25 | }); 26 | 27 | it('initializes Kraken with API key and secret', () => { 28 | const config = { 29 | ...testConfig(), 30 | ...{ CEX: 'KRAKEN' }, 31 | }; 32 | getExchange(config); 33 | expect(ccxt.kraken).toHaveBeenCalledTimes(1); 34 | expect(ccxt.kraken).toHaveBeenCalledWith( 35 | expect.objectContaining({ 36 | apiKey: config.CEX_API_KEY, 37 | secret: config.CEX_API_SECRET, 38 | }) 39 | ); 40 | }); 41 | 42 | it('throws for unsupported exchange', () => { 43 | expect.assertions(1); 44 | const config = { 45 | ...testConfig(), 46 | ...{ CEX: 'Michael' }, 47 | }; 48 | try { 49 | getExchange(config); 50 | } catch (e) { 51 | expect(e).toMatchSnapshot(); 52 | } 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/centralized/ccxt/exchange.ts: -------------------------------------------------------------------------------- 1 | import ccxt, { Exchange } from 'ccxt'; 2 | import { Config } from '../../config'; 3 | 4 | const getExchange = (config: Config): Exchange => { 5 | switch (config.CEX) { 6 | case 'BINANCE': 7 | return new ccxt.binance({ 8 | apiKey: config.CEX_API_KEY, 9 | secret: config.CEX_API_SECRET, 10 | }); 11 | case 'KRAKEN': 12 | return new ccxt.kraken({ 13 | apiKey: config.CEX_API_KEY, 14 | secret: config.CEX_API_SECRET, 15 | }); 16 | default: 17 | throw new Error( 18 | 'Could not get centralized exchange. Invalide CEX configuration option' 19 | ); 20 | } 21 | }; 22 | 23 | export { getExchange }; 24 | -------------------------------------------------------------------------------- /src/centralized/ccxt/fetch-balance.spec.ts: -------------------------------------------------------------------------------- 1 | import { Exchange } from 'ccxt'; 2 | import { fetchBalance$ } from './fetch-balance'; 3 | 4 | describe('CCXT', () => { 5 | it('fetches balance', done => { 6 | expect.assertions(1); 7 | const balance = 'balanceResponse'; 8 | const exchange = ({ 9 | fetchBalance: () => Promise.resolve(balance), 10 | } as unknown) as Exchange; 11 | const balance$ = fetchBalance$(exchange); 12 | balance$.subscribe({ 13 | next: actualBalance => { 14 | expect(actualBalance).toEqual(balance); 15 | }, 16 | complete: done, 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/centralized/ccxt/fetch-balance.ts: -------------------------------------------------------------------------------- 1 | import { Balances, Exchange } from 'ccxt'; 2 | import { defer, from, Observable } from 'rxjs'; 3 | 4 | const fetchBalance$ = (exchange: Exchange): Observable => { 5 | return defer(() => from(exchange.fetchBalance())); 6 | }; 7 | 8 | export { fetchBalance$ }; 9 | -------------------------------------------------------------------------------- /src/centralized/ccxt/fetch-open-orders.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, Order } from 'ccxt'; 2 | import { defer, from, Observable } from 'rxjs'; 3 | import { Config } from '../../config'; 4 | 5 | const fetchOpenOrders$ = ( 6 | exchange: Exchange, 7 | config: Config 8 | ): Observable => { 9 | return defer(() => 10 | from( 11 | exchange.fetchOpenOrders( 12 | `${config.CEX_BASEASSET}/${config.CEX_QUOTEASSET}` 13 | ) 14 | ) 15 | ); 16 | }; 17 | 18 | export { fetchOpenOrders$ }; 19 | -------------------------------------------------------------------------------- /src/centralized/ccxt/fetch-orders.spec.ts: -------------------------------------------------------------------------------- 1 | import { fetchOpenOrders$ } from './fetch-open-orders'; 2 | import { Exchange } from 'ccxt'; 3 | import { testConfig } from '../../test-utils'; 4 | 5 | describe('CCXT', () => { 6 | it('fetch orders', done => { 7 | expect.assertions(3); 8 | const orders = 'fetchOrdersResponse'; 9 | const fetchOpenOrdersMock = jest.fn(() => Promise.resolve(orders)); 10 | const exchange = ({ 11 | fetchOpenOrders: fetchOpenOrdersMock, 12 | } as unknown) as Exchange; 13 | const config = testConfig(); 14 | const { CEX_BASEASSET, CEX_QUOTEASSET } = config; 15 | const tradingPair = `${CEX_BASEASSET}/${CEX_QUOTEASSET}`; 16 | const orders$ = fetchOpenOrders$(exchange, config); 17 | orders$.subscribe({ 18 | next: actualOrders => { 19 | expect(actualOrders).toEqual(orders); 20 | expect(fetchOpenOrdersMock).toHaveBeenCalledWith(tradingPair); 21 | expect(fetchOpenOrdersMock).toHaveBeenCalledTimes(1); 22 | }, 23 | complete: done, 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/centralized/ccxt/init.spec.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary, Exchange, Market } from 'ccxt'; 2 | import { Observable, of } from 'rxjs'; 3 | import { TestScheduler } from 'rxjs/testing'; 4 | import { testConfig } from '../../test-utils'; 5 | import { initCEX$ } from './init'; 6 | 7 | let testScheduler: TestScheduler; 8 | 9 | const assertInitBinance = ( 10 | inputEvents: { 11 | loadMarkets$: string; 12 | }, 13 | expected: string 14 | ) => { 15 | testScheduler.run(helpers => { 16 | const { cold, expectObservable } = helpers; 17 | const loadMarkets$ = () => { 18 | return (cold(inputEvents.loadMarkets$) as unknown) as Observable< 19 | Dictionary 20 | >; 21 | }; 22 | const config = testConfig(); 23 | const getExchange = () => ('a' as unknown) as Exchange; 24 | const centralizedExchangeOrder$ = initCEX$({ 25 | config, 26 | loadMarkets$, 27 | getExchange, 28 | }); 29 | expectObservable(centralizedExchangeOrder$).toBe(expected, { 30 | a: { exchange: 'a', markets: 'a' }, 31 | }); 32 | }); 33 | }; 34 | 35 | describe('CCXT', () => { 36 | beforeEach(() => { 37 | testScheduler = new TestScheduler((actual, expected) => { 38 | expect(actual).toEqual(expected); 39 | }); 40 | }); 41 | 42 | it('loads markets after init', () => { 43 | const inputEvents = { 44 | loadMarkets$: '1s a', 45 | }; 46 | const expected = '1s a'; 47 | assertInitBinance(inputEvents, expected); 48 | }); 49 | }); 50 | 51 | it('initializes once and loads markets', done => { 52 | expect.assertions(2); 53 | const loadMarkets$ = jest.fn(() => { 54 | return (of('markets') as unknown) as Observable>; 55 | }); 56 | const getExchange = jest.fn(() => { 57 | return (null as unknown) as Exchange; 58 | }); 59 | const CEX = initCEX$({ 60 | loadMarkets$, 61 | config: testConfig(), 62 | getExchange, 63 | }); 64 | // first subscription 65 | CEX.subscribe(undefined); 66 | setTimeout(() => { 67 | CEX.subscribe({ 68 | complete: () => { 69 | // future subscriptions will get cached value 70 | expect(getExchange).toHaveBeenCalledTimes(1); 71 | expect(loadMarkets$).toHaveBeenCalledTimes(1); 72 | done(); 73 | }, 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/centralized/ccxt/init.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary, Exchange, Market } from 'ccxt'; 2 | import { Observable } from 'rxjs'; 3 | import { map } from 'rxjs/operators'; 4 | import { Config } from '../../config'; 5 | 6 | type InitCEXparams = { 7 | config: Config; 8 | loadMarkets$: (exchange: Exchange) => Observable>; 9 | getExchange: (config: Config) => Exchange; 10 | }; 11 | 12 | type InitCEXResponse = { 13 | markets: Dictionary; 14 | exchange: Exchange; 15 | }; 16 | 17 | const initCEX$ = ({ 18 | getExchange, 19 | config, 20 | loadMarkets$, 21 | }: InitCEXparams): Observable => { 22 | const exchange = getExchange(config); 23 | return loadMarkets$(exchange).pipe( 24 | map(markets => { 25 | return { 26 | markets, 27 | exchange, 28 | }; 29 | }) 30 | ); 31 | }; 32 | 33 | export { initCEX$, InitCEXparams, InitCEXResponse }; 34 | -------------------------------------------------------------------------------- /src/centralized/ccxt/load-markets.spec.ts: -------------------------------------------------------------------------------- 1 | import { loadMarkets$ } from './load-markets'; 2 | import { Exchange } from 'ccxt'; 3 | 4 | describe('CCXT', () => { 5 | it('loads markets', done => { 6 | expect.assertions(1); 7 | const markets = 'marketsResponse'; 8 | const exchange = ({ 9 | loadMarkets: () => Promise.resolve(markets), 10 | } as unknown) as Exchange; 11 | const markets$ = loadMarkets$(exchange); 12 | markets$.subscribe({ 13 | next: actualMarkets => { 14 | expect(actualMarkets).toEqual(markets); 15 | }, 16 | complete: done, 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/centralized/ccxt/load-markets.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary, Exchange, Market } from 'ccxt'; 2 | import { defer, from, Observable } from 'rxjs'; 3 | 4 | const loadMarkets$ = (exchange: Exchange): Observable> => { 5 | return defer(() => from(exchange.loadMarkets())); 6 | }; 7 | 8 | export { loadMarkets$ }; 9 | -------------------------------------------------------------------------------- /src/centralized/convert-balances.spec.ts: -------------------------------------------------------------------------------- 1 | import { convertBalances } from './convert-balances'; 2 | import { testConfig } from '../test-utils'; 3 | import { Balances } from 'ccxt'; 4 | import BigNumber from 'bignumber.js'; 5 | 6 | describe('convertBalances', () => { 7 | it('converts CCXT Balances to ExchangeAssetAllocation', () => { 8 | const config = testConfig(); 9 | const balances = ({} as unknown) as Balances; 10 | balances[config.CEX_BASEASSET] = { 11 | free: 10, 12 | used: 10, 13 | total: 20, 14 | }; 15 | balances[config.CEX_QUOTEASSET] = { 16 | free: 1, 17 | used: 1, 18 | total: 2, 19 | }; 20 | expect(convertBalances(config, balances)).toEqual({ 21 | baseAssetBalance: new BigNumber('20'), 22 | quoteAssetBalance: new BigNumber('2'), 23 | }); 24 | }); 25 | 26 | it('returns 0 when CCXT balance does not exist', () => { 27 | const config = testConfig(); 28 | const balances = ({} as unknown) as Balances; 29 | expect(convertBalances(config, balances)).toEqual({ 30 | baseAssetBalance: new BigNumber('0'), 31 | quoteAssetBalance: new BigNumber('0'), 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/centralized/convert-balances.ts: -------------------------------------------------------------------------------- 1 | import { Balances } from 'ccxt'; 2 | import { ExchangeAssetAllocation } from '../trade/info'; 3 | import { Config } from '../config'; 4 | import BigNumber from 'bignumber.js'; 5 | 6 | const convertBalances = ( 7 | config: Config, 8 | balances: Balances 9 | ): ExchangeAssetAllocation => { 10 | const baseAssetTotal = 11 | (balances[config.CEX_BASEASSET] && balances[config.CEX_BASEASSET].total) || 12 | '0'; 13 | const baseAssetBalance = new BigNumber(baseAssetTotal); 14 | const quoteAssetTotal = 15 | (balances[config.CEX_QUOTEASSET] && 16 | balances[config.CEX_QUOTEASSET].total) || 17 | '0'; 18 | const quoteAssetBalance = new BigNumber(quoteAssetTotal); 19 | return { 20 | baseAssetBalance, 21 | quoteAssetBalance, 22 | }; 23 | }; 24 | 25 | export { convertBalances }; 26 | -------------------------------------------------------------------------------- /src/centralized/derive-order-quantity.spec.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { OrderSide } from '../constants'; 3 | import { deriveCEXorderQuantity } from './derive-order-quantity'; 4 | import { CEXorder } from './order-builder'; 5 | import { testConfig } from '../test-utils'; 6 | 7 | describe('deriveCEXorderQuantity', () => { 8 | it('derives quantity when base asset is profit asset (BTC)', () => { 9 | OrderSide; 10 | const order: CEXorder = { 11 | quantity: new BigNumber('10.94381840'), 12 | side: OrderSide.BUY, 13 | }; 14 | const expectedOrder = { 15 | quantity: new BigNumber('0.00104227'), 16 | side: OrderSide.BUY, 17 | }; 18 | const CEX_BASEASSET = 'BTC'; 19 | const CEX_QUOTEASSET = 'USDT'; 20 | const config = { 21 | ...testConfig(), 22 | CEX_BASEASSET, 23 | CEX_QUOTEASSET, 24 | }; 25 | const price = new BigNumber('10500'); 26 | expect(deriveCEXorderQuantity(order, price, config)).toEqual(expectedOrder); 27 | }); 28 | 29 | it('returns order as is when base asset is not profit asset (BTC)', () => { 30 | OrderSide; 31 | const order: CEXorder = { 32 | quantity: new BigNumber('10.94381840'), 33 | side: OrderSide.BUY, 34 | }; 35 | const CEX_BASEASSET = 'ETH'; 36 | const CEX_QUOTEASSET = 'BTC'; 37 | const config = { 38 | ...testConfig(), 39 | CEX_BASEASSET, 40 | CEX_QUOTEASSET, 41 | }; 42 | const price = new BigNumber('0.03'); 43 | expect(deriveCEXorderQuantity(order, price, config)).toEqual(order); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/centralized/derive-order-quantity.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Config } from '../config'; 3 | import { CEXorder } from './order-builder'; 4 | 5 | const deriveCEXorderQuantity = ( 6 | order: CEXorder, 7 | price: BigNumber, 8 | config: Config 9 | ): CEXorder => { 10 | if (config.CEX_BASEASSET === 'BTC') { 11 | return { 12 | ...order, 13 | quantity: new BigNumber(order.quantity.dividedBy(price).toFixed(8)), 14 | }; 15 | } else { 16 | return order; 17 | } 18 | }; 19 | 20 | export { deriveCEXorderQuantity }; 21 | -------------------------------------------------------------------------------- /src/centralized/exchange-price.spec.ts: -------------------------------------------------------------------------------- 1 | import { testConfig, getLoggers } from '../test-utils'; 2 | import { getCentralizedExchangePrice$ } from './exchange-price'; 3 | 4 | describe('getCentralizedExchangePrice$', () => { 5 | it('returns Binance price stream', () => { 6 | const config = { 7 | ...testConfig(), 8 | ...{ CEX: 'BINANCE' }, 9 | }; 10 | const getBinancePrice$ = jest.fn(); 11 | const getKrakenPrice$ = jest.fn(); 12 | const logger = getLoggers().global; 13 | getCentralizedExchangePrice$({ 14 | config, 15 | logger, 16 | getBinancePrice$, 17 | getKrakenPrice$, 18 | }); 19 | expect(getBinancePrice$).toHaveBeenCalledTimes(1); 20 | }); 21 | 22 | it('returns Kraken price stream', () => { 23 | const config = { 24 | ...testConfig(), 25 | ...{ CEX: 'KRAKEN' }, 26 | }; 27 | const getBinancePrice$ = jest.fn(); 28 | const getKrakenPrice$ = jest.fn(); 29 | const logger = getLoggers().global; 30 | getCentralizedExchangePrice$({ 31 | config, 32 | logger, 33 | getBinancePrice$, 34 | getKrakenPrice$, 35 | }); 36 | expect(getKrakenPrice$).toHaveBeenCalledTimes(1); 37 | }); 38 | 39 | it('errors for unknown exchange', () => { 40 | const config = { 41 | ...testConfig(), 42 | ...{ CEX: 'ASDF' }, 43 | }; 44 | const getBinancePrice$ = jest.fn(); 45 | const getKrakenPrice$ = jest.fn(); 46 | const logger = getLoggers().global; 47 | expect(() => { 48 | getCentralizedExchangePrice$({ 49 | config, 50 | logger, 51 | getBinancePrice$, 52 | getKrakenPrice$, 53 | }); 54 | }).toThrowErrorMatchingSnapshot(); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/centralized/exchange-price.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Observable } from 'rxjs'; 3 | import { Config } from '../config'; 4 | import { Logger } from '../logger'; 5 | 6 | type GetPriceParams = { 7 | config: Config; 8 | logger: Logger; 9 | }; 10 | 11 | type CentralizedExchangePriceParams = { 12 | config: Config; 13 | logger: Logger; 14 | getKrakenPrice$: ({ 15 | config, 16 | logger, 17 | }: GetPriceParams) => Observable; 18 | getBinancePrice$: ({ 19 | config, 20 | logger, 21 | }: GetPriceParams) => Observable; 22 | }; 23 | 24 | const getCentralizedExchangePrice$ = ({ 25 | config, 26 | logger, 27 | getKrakenPrice$, 28 | getBinancePrice$, 29 | }: CentralizedExchangePriceParams): Observable => { 30 | switch (config.CEX) { 31 | case 'BINANCE': 32 | return getBinancePrice$({ config, logger }); 33 | case 'KRAKEN': 34 | return getKrakenPrice$({ config, logger }); 35 | default: 36 | throw new Error( 37 | `Could not get price feed for unknown exchange: ${config.CEX}` 38 | ); 39 | } 40 | }; 41 | 42 | export { 43 | getCentralizedExchangePrice$, 44 | CentralizedExchangePriceParams, 45 | GetPriceParams, 46 | }; 47 | -------------------------------------------------------------------------------- /src/centralized/execute-order.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestScheduler } from 'rxjs/testing'; 2 | import { executeCEXorder$ } from './execute-order'; 3 | import { getLoggers, testConfig } from '../test-utils'; 4 | import BigNumber from 'bignumber.js'; 5 | import { CEXorder } from './order-builder'; 6 | import { OrderSide } from '../constants'; 7 | import { Config } from '../config'; 8 | import { Observable } from 'rxjs'; 9 | import { Order, Exchange } from 'ccxt'; 10 | 11 | let testScheduler: TestScheduler; 12 | 13 | const assertExecuteCEXorder = ( 14 | inputEvents: { 15 | config: Config; 16 | price: BigNumber; 17 | order: CEXorder; 18 | createOrder$: string; 19 | unsubscribe?: string; 20 | }, 21 | expected: string 22 | ) => { 23 | testScheduler.run(helpers => { 24 | const { cold, expectObservable } = helpers; 25 | const createOrder$ = () => { 26 | return (cold(inputEvents.createOrder$) as unknown) as Observable; 27 | }; 28 | const CEX = (null as unknown) as Exchange; 29 | const CEXorder$ = executeCEXorder$({ 30 | CEX, 31 | config: inputEvents.config, 32 | logger: getLoggers().centralized, 33 | price: inputEvents.price, 34 | order: inputEvents.order, 35 | createOrder$, 36 | }); 37 | expectObservable(CEXorder$, inputEvents.unsubscribe).toBe(expected, { 38 | a: null, 39 | }); 40 | }); 41 | }; 42 | 43 | describe('executeCEXorder$', () => { 44 | beforeEach(() => { 45 | testScheduler = new TestScheduler((actual, expected) => { 46 | expect(actual).toEqual(expected); 47 | }); 48 | }); 49 | 50 | it('executes a mock CEX order in test mode', () => { 51 | const inputEvents = { 52 | createOrder$: '', 53 | config: testConfig(), 54 | price: new BigNumber('123'), 55 | order: { 56 | quantity: new BigNumber('0.001'), 57 | side: OrderSide.BUY, 58 | }, 59 | }; 60 | const expected = '5s (a|)'; 61 | assertExecuteCEXorder(inputEvents, expected); 62 | }); 63 | 64 | it('executes real order in production mode', () => { 65 | const config = { 66 | ...testConfig(), 67 | TEST_MODE: false, 68 | }; 69 | const inputEvents = { 70 | createOrder$: '1s a', 71 | config, 72 | price: new BigNumber('123'), 73 | order: { 74 | quantity: new BigNumber('0.001'), 75 | side: OrderSide.BUY, 76 | }, 77 | }; 78 | const expected = '1s (a|)'; 79 | assertExecuteCEXorder(inputEvents, expected); 80 | }); 81 | 82 | it('retries to execute order upon failure', () => { 83 | const config = { 84 | ...testConfig(), 85 | TEST_MODE: false, 86 | }; 87 | const inputEvents = { 88 | createOrder$: '1s # 1s a', 89 | config, 90 | price: new BigNumber('123'), 91 | order: { 92 | quantity: new BigNumber('0.001'), 93 | side: OrderSide.BUY, 94 | }, 95 | unsubscribe: '4s !', 96 | }; 97 | const expected = ''; 98 | assertExecuteCEXorder(inputEvents, expected); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/centralized/execute-order.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Exchange, Order } from 'ccxt'; 3 | import { Observable, of, timer } from 'rxjs'; 4 | import { 5 | catchError, 6 | mapTo, 7 | mergeMap, 8 | mergeMapTo, 9 | take, 10 | tap, 11 | } from 'rxjs/operators'; 12 | import { Config } from '../config'; 13 | import { Logger } from '../logger'; 14 | import { CreateOrderParams } from './ccxt/create-order'; 15 | import { CEXorder } from './order-builder'; 16 | 17 | type ExecuteCEXorderParams = { 18 | CEX: Exchange; 19 | config: Config; 20 | logger: Logger; 21 | price: BigNumber; 22 | order: CEXorder; 23 | createOrder$: ({ 24 | config, 25 | exchange, 26 | side, 27 | quantity, 28 | }: CreateOrderParams) => Observable; 29 | }; 30 | 31 | const executeCEXorder$ = ({ 32 | CEX, 33 | config, 34 | logger, 35 | price, 36 | order, 37 | createOrder$, 38 | }: ExecuteCEXorderParams): Observable => { 39 | if (!config.TEST_MODE) { 40 | logger.info( 41 | `Starting centralized exchange ${config.CEX_BASEASSET}/${config.CEX_QUOTEASSET} market ${order.side} order (quantity: ${order.quantity})` 42 | ); 43 | return createOrder$({ 44 | exchange: CEX, 45 | config, 46 | side: order.side, 47 | quantity: order.quantity, 48 | }).pipe( 49 | tap(order => 50 | logger.info( 51 | `Centralized exchange order finished: ${JSON.stringify(order)}` 52 | ) 53 | ), 54 | catchError((e, caught) => { 55 | logger.warn(`Failed to execute CEX order: ${e}. Retrying in 1000ms`); 56 | return timer(1000).pipe(mergeMapTo(caught)); 57 | }), 58 | mapTo(null), 59 | take(1) 60 | ); 61 | } else { 62 | return of(price).pipe( 63 | mergeMap(price => { 64 | logger.info( 65 | `Starting centralized exchange ${config.CEX_BASEASSET}/${ 66 | config.CEX_QUOTEASSET 67 | } market ${order.side} order (quantity: ${ 68 | order.quantity 69 | }, price: ${price.toFixed()})` 70 | ); 71 | return timer(5000).pipe( 72 | tap(() => logger.info('Centralized exchange order finished.')) 73 | ); 74 | }), 75 | mapTo(null) 76 | ); 77 | } 78 | }; 79 | 80 | export { executeCEXorder$, ExecuteCEXorderParams }; 81 | -------------------------------------------------------------------------------- /src/centralized/kraken-price.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Observable, throwError } from 'rxjs'; 3 | import { catchError, distinctUntilChanged, share } from 'rxjs/operators'; 4 | import WebSocket from 'ws'; 5 | import { errors } from '../opendex/errors'; 6 | import { GetPriceParams } from './exchange-price'; 7 | 8 | const getKrakenPrice$ = ({ 9 | config, 10 | logger, 11 | }: GetPriceParams): Observable => { 12 | const priceObservable: Observable = new Observable(observer => { 13 | const tradingPair = `${config.CEX_BASEASSET}/${config.CEX_QUOTEASSET}`; 14 | const url = 'wss://ws.kraken.com'; 15 | const socket = new WebSocket(url); 16 | socket.onopen = () => { 17 | logger.trace(`${tradingPair} established connection to ${url}`); 18 | }; 19 | socket.on('error', e => { 20 | observer.error(e); 21 | }); 22 | const heartbeat = () => { 23 | logger.trace(`heartbeat from ${tradingPair} socket`); 24 | }; 25 | socket.onclose = (event: WebSocket.CloseEvent) => { 26 | if (event.reason) { 27 | logger.trace( 28 | `${tradingPair} stream closed with reason: ${event.reason}` 29 | ); 30 | } else { 31 | logger.trace(`${tradingPair} stream closed`); 32 | } 33 | }; 34 | socket.on('ping', heartbeat); 35 | socket.on('open', () => { 36 | const subscribeTrade = { 37 | event: 'subscribe', 38 | pair: [tradingPair], 39 | subscription: { 40 | name: 'trade', 41 | }, 42 | }; 43 | socket.send(JSON.stringify(subscribeTrade)); 44 | heartbeat(); 45 | }); 46 | let channelID = -1; 47 | socket.onmessage = (event: WebSocket.MessageEvent) => { 48 | const eventData = JSON.parse(event.data.toString()); 49 | if (eventData.channelID) { 50 | channelID = eventData.channelID; 51 | } 52 | if (eventData[0] === channelID) { 53 | const priceString = eventData[1][0][0]; 54 | const price = new BigNumber(priceString); 55 | observer.next(price); 56 | } 57 | }; 58 | return () => { 59 | socket.terminate(); 60 | }; 61 | }); 62 | return priceObservable.pipe( 63 | catchError(() => { 64 | return throwError(errors.CENTRALIZED_EXCHANGE_PRICE_FEED_ERROR); 65 | }), 66 | distinctUntilChanged((a, b) => a.isEqualTo(b)), 67 | share() 68 | ); 69 | }; 70 | 71 | export { getKrakenPrice$ }; 72 | -------------------------------------------------------------------------------- /src/centralized/minimum-order-quantity-filter.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | quantityAboveMinimum, 3 | MINIMUM_ORDER_SIZE, 4 | } from './minimum-order-quantity-filter'; 5 | import BigNumber from 'bignumber.js'; 6 | 7 | describe('quantityAboveMinimum', () => { 8 | it('returns true for minimum ETH quantity', () => { 9 | expect(quantityAboveMinimum('ETH')(MINIMUM_ORDER_SIZE.ETH)).toEqual(true); 10 | }); 11 | 12 | it('returns false less than minimum ETH quantity', () => { 13 | const quantity = MINIMUM_ORDER_SIZE.ETH.minus( 14 | new BigNumber('0.000000000000000001') 15 | ); 16 | expect(quantityAboveMinimum('ETH')(quantity)).toEqual(false); 17 | }); 18 | 19 | it('returns true for minimum DAI quantity', () => { 20 | expect(quantityAboveMinimum('DAI')(MINIMUM_ORDER_SIZE.DAI)).toEqual(true); 21 | }); 22 | 23 | it('returns false less than minimum DAI quantity', () => { 24 | const quantity = MINIMUM_ORDER_SIZE.DAI.minus( 25 | new BigNumber('0.000000000000000001') 26 | ); 27 | expect(quantityAboveMinimum('DAI')(quantity)).toEqual(false); 28 | }); 29 | 30 | it('returns true for minimum USDT quantity', () => { 31 | expect(quantityAboveMinimum('USDT')(MINIMUM_ORDER_SIZE.USDT)).toEqual(true); 32 | }); 33 | 34 | it('returns false less than minimum USDT quantity', () => { 35 | const quantity = MINIMUM_ORDER_SIZE.USDT.minus( 36 | new BigNumber('0.000000000000000001') 37 | ); 38 | expect(quantityAboveMinimum('USDT')(quantity)).toEqual(false); 39 | }); 40 | 41 | it('throws for unknown asset KILRAU', () => { 42 | expect.assertions(1); 43 | const quantity = new BigNumber('1'); 44 | expect(() => { 45 | quantityAboveMinimum('KILRAU')(quantity); 46 | }).toThrowErrorMatchingSnapshot(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/centralized/minimum-order-quantity-filter.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { errors } from '../opendex/errors'; 3 | 4 | type MinimumCEXquantities = { 5 | [key: string]: BigNumber; 6 | }; 7 | 8 | const MINIMUM_ORDER_SIZE: MinimumCEXquantities = { 9 | BTC: new BigNumber('0.001'), 10 | ETH: new BigNumber('0.05'), 11 | DAI: new BigNumber('15'), 12 | USDT: new BigNumber('15'), 13 | }; 14 | 15 | const getMinimumOrderSize = (asset: string): BigNumber => { 16 | const minimumOrderSize = MINIMUM_ORDER_SIZE[asset]; 17 | if (!minimumOrderSize) { 18 | throw errors.CEX_INVALID_MINIMUM_ORDER_QUANTITY(asset); 19 | } 20 | return minimumOrderSize; 21 | }; 22 | 23 | const quantityAboveMinimum = (asset: string) => { 24 | return (quantity: BigNumber): boolean => { 25 | return quantity.isGreaterThanOrEqualTo(getMinimumOrderSize(asset)); 26 | }; 27 | }; 28 | 29 | export { quantityAboveMinimum, MINIMUM_ORDER_SIZE }; 30 | -------------------------------------------------------------------------------- /src/centralized/order-builder.spec.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Observable } from 'rxjs'; 3 | import { TestScheduler } from 'rxjs/testing'; 4 | import { Config } from '../config'; 5 | import { OrderSide } from '../constants'; 6 | import { SwapSuccess } from '../proto/xudrpc_pb'; 7 | import { ArbyStore, getArbyStore } from '../store'; 8 | import { getLoggers, testConfig } from '../test-utils'; 9 | import { CEXorder, getOrderBuilder$ } from './order-builder'; 10 | 11 | let testScheduler: TestScheduler; 12 | 13 | const assertOrderBuilder = ( 14 | inputEvents: { 15 | receivedBaseAssetSwapSuccess$: string; 16 | receivedQuoteAssetSwapSuccess$: string; 17 | unsubscribe?: string; 18 | }, 19 | inputValues: { 20 | receivedBaseAssetSwapSuccess$: { 21 | a: BigNumber; 22 | }; 23 | receivedQuoteAssetSwapSuccess$: { 24 | b: BigNumber; 25 | }; 26 | }, 27 | expected: string, 28 | expectedValues: { 29 | a: CEXorder; 30 | }, 31 | config: Config, 32 | expectedAssetToTradeOnCEX: string, 33 | store?: ArbyStore 34 | ) => { 35 | testScheduler.run(helpers => { 36 | const { cold, expectObservable } = helpers; 37 | const getOpenDEXswapSuccess$ = () => { 38 | return { 39 | receivedBaseAssetSwapSuccess$: (cold( 40 | inputEvents.receivedBaseAssetSwapSuccess$, 41 | inputValues.receivedBaseAssetSwapSuccess$ 42 | ) as unknown) as Observable, 43 | receivedQuoteAssetSwapSuccess$: (cold( 44 | inputEvents.receivedQuoteAssetSwapSuccess$, 45 | inputValues.receivedQuoteAssetSwapSuccess$ 46 | ) as unknown) as Observable, 47 | }; 48 | }; 49 | const accumulateOrderFillsForAssetReceived = jest 50 | .fn() 51 | .mockImplementation(() => { 52 | return (v: any) => v; 53 | }); 54 | const quantityAboveMinimum = jest.fn().mockImplementation(() => { 55 | return () => true; 56 | }); 57 | const orderBuilder$ = getOrderBuilder$({ 58 | config, 59 | logger: getLoggers().centralized, 60 | getOpenDEXswapSuccess$, 61 | accumulateOrderFillsForBaseAssetReceived: accumulateOrderFillsForAssetReceived, 62 | accumulateOrderFillsForQuoteAssetReceived: accumulateOrderFillsForAssetReceived, 63 | quantityAboveMinimum, 64 | store: store ? store : getArbyStore(), 65 | }); 66 | expectObservable(orderBuilder$, inputEvents.unsubscribe).toBe( 67 | expected, 68 | expectedValues 69 | ); 70 | expect(accumulateOrderFillsForAssetReceived).toHaveBeenCalledTimes(2); 71 | expect(accumulateOrderFillsForAssetReceived).toHaveBeenCalledWith( 72 | expect.objectContaining(config) 73 | ); 74 | expect(quantityAboveMinimum).toHaveBeenCalledTimes(2); 75 | expect(quantityAboveMinimum).toHaveBeenCalledWith( 76 | expectedAssetToTradeOnCEX 77 | ); 78 | }); 79 | }; 80 | 81 | describe('getCentralizedExchangeOrder$', () => { 82 | beforeEach(() => { 83 | testScheduler = new TestScheduler((actual, expected) => { 84 | expect(actual).toEqual(expected); 85 | }); 86 | }); 87 | 88 | it('accumulates buy and sell orders for ETHBTC', () => { 89 | expect.assertions(6); 90 | const inputEvents = { 91 | receivedBaseAssetSwapSuccess$: '1s a', 92 | receivedQuoteAssetSwapSuccess$: '1400ms b', 93 | unsubscribe: '3s !', 94 | }; 95 | const receivedBaseAssetQuantity = new BigNumber('40'); 96 | const receivedQuoteAssetQuantity = new BigNumber('1'); 97 | const inputValues = { 98 | receivedBaseAssetSwapSuccess$: { 99 | a: receivedBaseAssetQuantity, 100 | }, 101 | receivedQuoteAssetSwapSuccess$: { 102 | b: receivedQuoteAssetQuantity, 103 | }, 104 | }; 105 | const expected = '1s a 399ms b 599ms a 799ms b'; 106 | const expectedValues = { 107 | a: ({ 108 | quantity: receivedBaseAssetQuantity, 109 | side: OrderSide.SELL, 110 | } as unknown) as CEXorder, 111 | b: ({ 112 | quantity: receivedQuoteAssetQuantity, 113 | side: OrderSide.BUY, 114 | } as unknown) as CEXorder, 115 | }; 116 | const BASEASSET = 'ETH'; 117 | const QUOTEASSET = 'BTC'; 118 | const config = { 119 | ...testConfig(), 120 | CEX_BASEASSET: BASEASSET, 121 | CEX_QUOTEASSET: QUOTEASSET, 122 | BASEASSET: BASEASSET, 123 | QUOTEASSET: QUOTEASSET, 124 | }; 125 | const expectedAssetToTradeOnCEX = BASEASSET; 126 | const store = { 127 | ...getArbyStore(), 128 | ...{ resetLastOrderUpdatePrice: jest.fn() }, 129 | }; 130 | assertOrderBuilder( 131 | inputEvents, 132 | inputValues, 133 | expected, 134 | expectedValues, 135 | config, 136 | expectedAssetToTradeOnCEX, 137 | store 138 | ); 139 | expect(store.resetLastOrderUpdatePrice).toHaveBeenCalledTimes(4); 140 | }); 141 | 142 | it('accumulates buy and sell orders for BTCUSDT', () => { 143 | expect.assertions(6); 144 | const inputEvents = { 145 | receivedBaseAssetSwapSuccess$: '1s a', 146 | receivedQuoteAssetSwapSuccess$: '1400ms b', 147 | unsubscribe: '3s !', 148 | }; 149 | const receivedBaseAssetQuantity = new BigNumber('1'); 150 | const receivedQuoteAssetQuantity = new BigNumber('10000'); 151 | const inputValues = { 152 | receivedBaseAssetSwapSuccess$: { 153 | a: receivedBaseAssetQuantity, 154 | }, 155 | receivedQuoteAssetSwapSuccess$: { 156 | b: receivedQuoteAssetQuantity, 157 | }, 158 | }; 159 | const expected = '1s a 399ms b 599ms a 799ms b'; 160 | const expectedValues = { 161 | a: ({ 162 | quantity: receivedBaseAssetQuantity, 163 | side: OrderSide.SELL, 164 | } as unknown) as CEXorder, 165 | b: ({ 166 | quantity: receivedQuoteAssetQuantity, 167 | side: OrderSide.BUY, 168 | } as unknown) as CEXorder, 169 | }; 170 | const BASEASSET = 'BTC'; 171 | const QUOTEASSET = 'USDT'; 172 | const config = { 173 | ...testConfig(), 174 | CEX_BASEASSET: BASEASSET, 175 | CEX_QUOTEASSET: QUOTEASSET, 176 | BASEASSET: BASEASSET, 177 | QUOTEASSET: QUOTEASSET, 178 | }; 179 | const expectedAssetToTradeOnCEX = QUOTEASSET; 180 | const store = { 181 | ...getArbyStore(), 182 | ...{ resetLastOrderUpdatePrice: jest.fn() }, 183 | }; 184 | assertOrderBuilder( 185 | inputEvents, 186 | inputValues, 187 | expected, 188 | expectedValues, 189 | config, 190 | expectedAssetToTradeOnCEX, 191 | store 192 | ); 193 | expect(store.resetLastOrderUpdatePrice).toHaveBeenCalledTimes(4); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /src/centralized/order-builder.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { merge, Observable, of } from 'rxjs'; 3 | import { filter, map, mergeMap, repeat, take } from 'rxjs/operators'; 4 | import { Config } from '../config'; 5 | import { OrderSide } from '../constants'; 6 | import { Logger } from '../logger'; 7 | import { 8 | GetOpenDEXswapSuccessParams, 9 | OpenDEXswapSuccess, 10 | } from '../opendex/swap-success'; 11 | import { getXudClient$ } from '../opendex/xud/client'; 12 | import { subscribeXudSwaps$ } from '../opendex/xud/subscribe-swaps'; 13 | import { SwapSuccess } from '../proto/xudrpc_pb'; 14 | import { ArbyStore } from '../store'; 15 | 16 | type GetOrderBuilderParams = { 17 | config: Config; 18 | logger: Logger; 19 | getOpenDEXswapSuccess$: ({ 20 | config, 21 | getXudClient$, 22 | subscribeXudSwaps$, 23 | }: GetOpenDEXswapSuccessParams) => OpenDEXswapSuccess; 24 | accumulateOrderFillsForBaseAssetReceived: ( 25 | config: Config 26 | ) => (source: Observable) => Observable; 27 | accumulateOrderFillsForQuoteAssetReceived: ( 28 | config: Config 29 | ) => (source: Observable) => Observable; 30 | quantityAboveMinimum: ( 31 | asset: string 32 | ) => (filledQuantity: BigNumber) => boolean; 33 | store: ArbyStore; 34 | }; 35 | 36 | type CEXorder = { 37 | quantity: BigNumber; 38 | side: OrderSide; 39 | }; 40 | 41 | const getOrderBuilder$ = ({ 42 | config, 43 | logger, 44 | getOpenDEXswapSuccess$, 45 | accumulateOrderFillsForBaseAssetReceived, 46 | accumulateOrderFillsForQuoteAssetReceived, 47 | quantityAboveMinimum, 48 | store, 49 | }: GetOrderBuilderParams): Observable => { 50 | const { 51 | receivedBaseAssetSwapSuccess$, 52 | receivedQuoteAssetSwapSuccess$, 53 | } = getOpenDEXswapSuccess$({ 54 | config, 55 | getXudClient$, 56 | subscribeXudSwaps$, 57 | }); 58 | const assetToTradeOnCEX: string = 59 | config.CEX_QUOTEASSET === 'BTC' 60 | ? config.CEX_BASEASSET 61 | : config.CEX_QUOTEASSET; 62 | const receivedQuoteAssetOrder$ = receivedQuoteAssetSwapSuccess$.pipe( 63 | // accumulate OpenDEX order fills when receiving 64 | // quote asset 65 | accumulateOrderFillsForQuoteAssetReceived(config), 66 | mergeMap((quantity: BigNumber) => { 67 | logger.info( 68 | `Swap success. Accumulated ${assetToTradeOnCEX} quantity: ${quantity.toFixed()}` 69 | ); 70 | store.resetLastOrderUpdatePrice(); 71 | return of(quantity); 72 | }), 73 | // filter based on minimum CEX order quantity 74 | filter(quantityAboveMinimum(assetToTradeOnCEX)), 75 | map(quantity => { 76 | return { quantity, side: OrderSide.BUY }; 77 | }), 78 | // reset the filled quantity and start from 79 | // the beginning 80 | take(1), 81 | repeat() 82 | ); 83 | const receivedBaseAssetOrder$ = receivedBaseAssetSwapSuccess$.pipe( 84 | // accumulate OpenDEX order fills when receiving 85 | // quote asset 86 | accumulateOrderFillsForBaseAssetReceived(config), 87 | mergeMap((quantity: BigNumber) => { 88 | logger.info( 89 | `Swap success. Accumulated ${assetToTradeOnCEX} quantity: ${quantity.toFixed()}` 90 | ); 91 | store.resetLastOrderUpdatePrice(); 92 | return of(quantity); 93 | }), 94 | // filter based on minimum CEX order quantity 95 | filter(quantityAboveMinimum(assetToTradeOnCEX)), 96 | map(quantity => { 97 | return { quantity, side: OrderSide.SELL }; 98 | }), 99 | // reset the filled quantity and start from 100 | // the beginning 101 | take(1), 102 | repeat() 103 | ); 104 | return merge(receivedQuoteAssetOrder$, receivedBaseAssetOrder$); 105 | }; 106 | 107 | export { getOrderBuilder$, GetOrderBuilderParams, CEXorder }; 108 | -------------------------------------------------------------------------------- /src/centralized/order.spec.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { TestScheduler } from 'rxjs/testing'; 3 | import { getLoggers, testConfig } from '../test-utils'; 4 | import { getCentralizedExchangeOrder$ } from './order'; 5 | import { CEXorder } from './order-builder'; 6 | import BigNumber from 'bignumber.js'; 7 | import { Exchange } from 'ccxt'; 8 | import { getArbyStore } from '../store'; 9 | 10 | let testScheduler: TestScheduler; 11 | 12 | const assertCentralizedExchangeOrder = ( 13 | inputEvents: { 14 | orderBuilder$: string; 15 | executeCEXorder$: string; 16 | centralizedExchangePrice$: string; 17 | unsubscribe?: string; 18 | }, 19 | expected: string 20 | ) => { 21 | testScheduler.run(helpers => { 22 | const { cold, expectObservable } = helpers; 23 | const config = testConfig(); 24 | const getOrderBuilder$ = () => { 25 | return (cold(inputEvents.orderBuilder$) as unknown) as Observable< 26 | CEXorder 27 | >; 28 | }; 29 | const executeCEXorder$ = () => { 30 | return (cold(inputEvents.executeCEXorder$) as unknown) as Observable< 31 | null 32 | >; 33 | }; 34 | const centralizedExchangePrice$ = (cold( 35 | inputEvents.centralizedExchangePrice$ 36 | ) as unknown) as Observable; 37 | const CEX = (null as unknown) as Exchange; 38 | const deriveCEXorderQuantity = (order: any) => order; 39 | const store = getArbyStore(); 40 | const centralizedExchangeOrder$ = getCentralizedExchangeOrder$({ 41 | CEX, 42 | logger: getLoggers().centralized, 43 | config, 44 | getOrderBuilder$, 45 | executeCEXorder$, 46 | centralizedExchangePrice$, 47 | deriveCEXorderQuantity, 48 | store, 49 | }); 50 | expectObservable(centralizedExchangeOrder$, inputEvents.unsubscribe).toBe( 51 | expected 52 | ); 53 | }); 54 | }; 55 | 56 | describe('getCentralizedExchangeOrder$', () => { 57 | beforeEach(() => { 58 | testScheduler = new TestScheduler((actual, expected) => { 59 | expect(actual).toEqual(expected); 60 | }); 61 | }); 62 | 63 | it('executes queued up orders with latest price', () => { 64 | const inputEvents = { 65 | orderBuilder$: '1s a', 66 | centralizedExchangePrice$: '500ms a', 67 | executeCEXorder$: '5s a', 68 | unsubscribe: '10s !', 69 | }; 70 | const expected = '6s a'; 71 | assertCentralizedExchangeOrder(inputEvents, expected); 72 | }); 73 | 74 | it('does not stop if latest price errors', () => { 75 | const inputEvents = { 76 | orderBuilder$: '1s a', 77 | centralizedExchangePrice$: '500ms #', 78 | executeCEXorder$: '5s a', 79 | unsubscribe: '10s !', 80 | }; 81 | const expected = ''; 82 | assertCentralizedExchangeOrder(inputEvents, expected); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/centralized/order.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Exchange } from 'ccxt'; 3 | import { Observable, timer } from 'rxjs'; 4 | import { 5 | catchError, 6 | mergeMap, 7 | mergeMapTo, 8 | withLatestFrom, 9 | } from 'rxjs/operators'; 10 | import { Config } from '../config'; 11 | import { Logger } from '../logger'; 12 | import { getOpenDEXswapSuccess$ } from '../opendex/swap-success'; 13 | import { 14 | accumulateOrderFillsForBaseAssetReceived, 15 | accumulateOrderFillsForQuoteAssetReceived, 16 | } from '../trade/accumulate-fills'; 17 | import { createOrder$ } from './ccxt/create-order'; 18 | import { ExecuteCEXorderParams } from './execute-order'; 19 | import { quantityAboveMinimum } from './minimum-order-quantity-filter'; 20 | import { CEXorder, GetOrderBuilderParams } from './order-builder'; 21 | import { ArbyStore } from 'src/store'; 22 | 23 | type GetCentralizedExchangeOrderParams = { 24 | CEX: Exchange; 25 | logger: Logger; 26 | config: Config; 27 | executeCEXorder$: ({ 28 | logger, 29 | price, 30 | order, 31 | }: ExecuteCEXorderParams) => Observable; 32 | getOrderBuilder$: ({ 33 | config, 34 | getOpenDEXswapSuccess$, 35 | accumulateOrderFillsForBaseAssetReceived, 36 | accumulateOrderFillsForQuoteAssetReceived, 37 | quantityAboveMinimum, 38 | }: GetOrderBuilderParams) => Observable; 39 | centralizedExchangePrice$: Observable; 40 | deriveCEXorderQuantity: ( 41 | order: CEXorder, 42 | price: BigNumber, 43 | config: Config 44 | ) => CEXorder; 45 | store: ArbyStore; 46 | }; 47 | 48 | const getCentralizedExchangeOrder$ = ({ 49 | CEX, 50 | logger, 51 | config, 52 | executeCEXorder$, 53 | getOrderBuilder$, 54 | centralizedExchangePrice$, 55 | deriveCEXorderQuantity, 56 | store, 57 | }: GetCentralizedExchangeOrderParams): Observable => { 58 | return getOrderBuilder$({ 59 | config, 60 | logger, 61 | getOpenDEXswapSuccess$, 62 | accumulateOrderFillsForBaseAssetReceived, 63 | accumulateOrderFillsForQuoteAssetReceived, 64 | quantityAboveMinimum, 65 | store, 66 | }).pipe( 67 | withLatestFrom( 68 | centralizedExchangePrice$.pipe( 69 | catchError((_e, caught) => { 70 | return timer(1000).pipe(mergeMapTo(caught)); 71 | }) 72 | ) 73 | ), 74 | mergeMap(([order, price]) => { 75 | return executeCEXorder$({ 76 | CEX, 77 | createOrder$, 78 | config, 79 | logger, 80 | price, 81 | order: deriveCEXorderQuantity(order, price, config), 82 | }); 83 | }) 84 | ); 85 | }; 86 | 87 | export { getCentralizedExchangeOrder$, GetCentralizedExchangeOrderParams }; 88 | -------------------------------------------------------------------------------- /src/centralized/remove-orders.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestScheduler } from 'rxjs/testing'; 2 | import { Config } from '../config'; 3 | import { removeCEXorders$ } from './remove-orders'; 4 | import { getLoggers, testConfig } from '../test-utils'; 5 | import { Exchange, Order } from 'ccxt'; 6 | import { Observable } from 'rxjs'; 7 | 8 | let testScheduler: TestScheduler; 9 | 10 | const assertRemoveOrders = ( 11 | inputEvents: { 12 | config: Config; 13 | fetchOpenOrders$: string; 14 | cancelOrder$: string; 15 | openOrderCount: number; 16 | }, 17 | expected: string 18 | ) => { 19 | testScheduler.run(helpers => { 20 | const { cold, expectObservable } = helpers; 21 | const exchange = (null as unknown) as Exchange; 22 | const openOrder = { id: 'a' }; 23 | const createOpenOrders = (count: number) => { 24 | return { 25 | a: Array(count) 26 | .fill(0) 27 | .map(() => openOrder), 28 | }; 29 | }; 30 | const fetchOpenOrders$ = () => { 31 | return (cold( 32 | inputEvents.fetchOpenOrders$, 33 | createOpenOrders(inputEvents.openOrderCount) 34 | ) as unknown) as Observable; 35 | }; 36 | const cancelOrder$ = () => { 37 | return (cold(inputEvents.cancelOrder$) as unknown) as Observable; 38 | }; 39 | const removeOrders$ = removeCEXorders$( 40 | getLoggers().centralized, 41 | inputEvents.config, 42 | exchange, 43 | fetchOpenOrders$, 44 | cancelOrder$ 45 | ); 46 | expectObservable(removeOrders$).toBe(expected, { 47 | a: Array(inputEvents.openOrderCount) 48 | .fill(0) 49 | .map(() => 'a'), 50 | }); 51 | }); 52 | }; 53 | 54 | describe('removeCEXorders$', () => { 55 | beforeEach(() => { 56 | testScheduler = new TestScheduler((actual, expected) => { 57 | expect(actual).toEqual(expected); 58 | }); 59 | }); 60 | 61 | it('executes a mock CEX order cancellation in test mode', () => { 62 | const config = testConfig(); 63 | const inputEvents = { 64 | config, 65 | fetchOpenOrders$: '1s a', 66 | cancelOrder$: '1s a', 67 | openOrderCount: 1, 68 | }; 69 | const expected = '1s (a|)'; 70 | assertRemoveOrders(inputEvents, expected); 71 | }); 72 | 73 | it('executes a real CEX order cancellation in live mode', () => { 74 | const config = { 75 | ...testConfig(), 76 | TEST_MODE: false, 77 | }; 78 | const inputEvents = { 79 | config, 80 | fetchOpenOrders$: '1s (a|)', 81 | cancelOrder$: '1s (a|)', 82 | openOrderCount: 5, 83 | }; 84 | const expected = '2s (a|)'; 85 | assertRemoveOrders(inputEvents, expected); 86 | }); 87 | 88 | it('does not cancel orders when no open orders exist', () => { 89 | const config = { 90 | ...testConfig(), 91 | TEST_MODE: false, 92 | }; 93 | const inputEvents = { 94 | config, 95 | fetchOpenOrders$: '1s (a|)', 96 | cancelOrder$: '1s (a|)', 97 | openOrderCount: 0, 98 | }; 99 | const expected = '1s |'; 100 | assertRemoveOrders(inputEvents, expected); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/centralized/remove-orders.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, Order } from 'ccxt'; 2 | import { empty, forkJoin, Observable, of } from 'rxjs'; 3 | import { delay, map, mergeMap, tap } from 'rxjs/operators'; 4 | import { Config } from '../config'; 5 | import { Logger } from '../logger'; 6 | 7 | const removeCEXorders$ = ( 8 | logger: Logger, 9 | config: Config, 10 | exchange: Exchange, 11 | fetchOpenOrders$: (exchange: Exchange, config: Config) => Observable, 12 | cancelOrder$: ( 13 | exchange: Exchange, 14 | config: Config, 15 | orderId: string 16 | ) => Observable 17 | ): Observable => { 18 | if (!config.TEST_MODE) { 19 | const getOrderIds = (orders: Order[]) => orders.map(order => order.id); 20 | return fetchOpenOrders$(exchange, config).pipe( 21 | map(getOrderIds), 22 | mergeMap(orderIds => { 23 | const cancelOrders$ = orderIds.map(orderId => 24 | cancelOrder$(exchange, config, orderId) 25 | ); 26 | if (cancelOrders$.length) { 27 | return forkJoin(cancelOrders$); 28 | } 29 | return empty(); 30 | }) 31 | ); 32 | } else { 33 | return of(['a']).pipe( 34 | tap(() => logger.info('Removing CEX orders')), 35 | delay(1000), 36 | tap(() => logger.info('Finished removing CEX orders')) 37 | ); 38 | } 39 | }; 40 | 41 | export { removeCEXorders$ }; 42 | -------------------------------------------------------------------------------- /src/centralized/verify-markets.spec.ts: -------------------------------------------------------------------------------- 1 | import { testConfig } from '../test-utils'; 2 | import { verifyMarkets } from './verify-markets'; 3 | import { Dictionary, Market } from 'ccxt'; 4 | 5 | describe('verifyMarkets', () => { 6 | const CEXmarkets: Dictionary = { 7 | 'BTC/USD': { 8 | active: true, 9 | } as Market, 10 | }; 11 | 12 | it('throws when BTC/USD trading pair not exist', () => { 13 | expect.assertions(1); 14 | const markets = { 15 | ...CEXmarkets, 16 | }; 17 | delete markets['BTC/USD']; 18 | const config = { 19 | ...testConfig(), 20 | ...{ CEX_BASEASSET: 'BTC', CEX_QUOTEASSET: 'USD' }, 21 | }; 22 | expect(() => { 23 | verifyMarkets(config, markets); 24 | }).toThrowErrorMatchingSnapshot(); 25 | }); 26 | 27 | it.only('throws when BTC/USD trading pair is inactive', () => { 28 | expect.assertions(1); 29 | const markets = { 30 | ...CEXmarkets, 31 | }; 32 | delete markets['BTC/USD'].active; 33 | const config = { 34 | ...testConfig(), 35 | ...{ CEX_BASEASSET: 'BTC', CEX_QUOTEASSET: 'USD' }, 36 | }; 37 | expect(() => { 38 | verifyMarkets(config, markets); 39 | }).toThrowErrorMatchingSnapshot(); 40 | }); 41 | 42 | it('returns true when BTC/USD trading pair exist', () => { 43 | expect.assertions(1); 44 | const config = { 45 | ...testConfig(), 46 | ...{ CEX_BASEASSET: 'BTC', CEX_QUOTEASSET: 'USD' }, 47 | }; 48 | expect(verifyMarkets(config, CEXmarkets)).toEqual(true); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/centralized/verify-markets.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary, Market } from 'ccxt'; 2 | import { Config } from '../config'; 3 | import { errors } from '../opendex/errors'; 4 | 5 | const verifyMarkets = (config: Config, CEXmarkets: Dictionary) => { 6 | const tradingPair = `${config.CEX_BASEASSET}/${config.CEX_QUOTEASSET}`; 7 | const market = CEXmarkets[tradingPair]; 8 | if (market && market.active) { 9 | return true; 10 | } 11 | throw errors.CEX_INVALID_TRADING_PAIR(tradingPair, config.CEX); 12 | }; 13 | 14 | export { verifyMarkets }; 15 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { DotenvParseOutput } from 'dotenv'; 2 | import { Observable, of } from 'rxjs'; 3 | import { map, pluck } from 'rxjs/operators'; 4 | import { Level } from './logger'; 5 | 6 | export type Config = { 7 | LOG_LEVEL: Level; 8 | CEX: string; 9 | CEX_API_KEY: string; 10 | CEX_API_SECRET: string; 11 | DATA_DIR: string; 12 | OPENDEX_CERT_PATH: string; 13 | OPENDEX_RPC_HOST: string; 14 | OPENDEX_RPC_PORT: string; 15 | MARGIN: string; 16 | BASEASSET: string; 17 | QUOTEASSET: string; 18 | CEX_BASEASSET: string; 19 | CEX_QUOTEASSET: string; 20 | TEST_CENTRALIZED_EXCHANGE_BASEASSET_BALANCE: string; 21 | TEST_CENTRALIZED_EXCHANGE_QUOTEASSET_BALANCE: string; 22 | TEST_MODE: boolean; 23 | }; 24 | 25 | const REQUIRED_CONFIGURATION_OPTIONS = [ 26 | 'LOG_LEVEL', 27 | 'CEX', 28 | 'DATA_DIR', 29 | 'OPENDEX_CERT_PATH', 30 | 'OPENDEX_RPC_HOST', 31 | 'OPENDEX_RPC_PORT', 32 | 'MARGIN', 33 | 'BASEASSET', 34 | 'QUOTEASSET', 35 | 'TEST_MODE', 36 | ]; 37 | 38 | const REQUIRED_CONFIGURATION_OPTIONS_TEST_MODE_ENABLED = [ 39 | 'TEST_CENTRALIZED_EXCHANGE_BASEASSET_BALANCE', 40 | 'TEST_CENTRALIZED_EXCHANGE_QUOTEASSET_BALANCE', 41 | ]; 42 | 43 | const REQUIRED_CONFIGURATION_OPTIONS_TEST_MODE_DISABLED = [ 44 | 'CEX_API_KEY', 45 | 'CEX_API_SECRET', 46 | ]; 47 | 48 | const OPTIONAL_CONFIG = ['CEX_BASEASSET', 'CEX_QUOTEASSET']; 49 | 50 | const setLogLevel = (logLevel: string): Level => { 51 | return Object.values(Level).reduce((finalLevel, level) => { 52 | if (logLevel === level) { 53 | return level; 54 | } 55 | return finalLevel; 56 | }, Level.Trace); 57 | }; 58 | 59 | const getEnvironmentConfig = (): DotenvParseOutput => { 60 | const environmentConfig = REQUIRED_CONFIGURATION_OPTIONS.concat( 61 | REQUIRED_CONFIGURATION_OPTIONS_TEST_MODE_ENABLED, 62 | REQUIRED_CONFIGURATION_OPTIONS_TEST_MODE_DISABLED, 63 | OPTIONAL_CONFIG 64 | ).reduce((envConfig: DotenvParseOutput, configOption) => { 65 | if (process.env[configOption]) { 66 | return { 67 | ...envConfig, 68 | [configOption]: process.env[configOption]!, 69 | }; 70 | } 71 | return envConfig; 72 | }, {}); 73 | return environmentConfig; 74 | }; 75 | 76 | const getMissingOptions = (config: DotenvParseOutput): string => { 77 | const ADDITIONAL_CONF_OPTIONS = 78 | config['TEST_MODE'] === 'true' 79 | ? REQUIRED_CONFIGURATION_OPTIONS_TEST_MODE_ENABLED 80 | : REQUIRED_CONFIGURATION_OPTIONS_TEST_MODE_DISABLED; 81 | return REQUIRED_CONFIGURATION_OPTIONS.concat(ADDITIONAL_CONF_OPTIONS) 82 | .reduce((missingOptions: string[], configOption) => { 83 | if (!config[configOption]) { 84 | return missingOptions.concat(configOption); 85 | } 86 | return missingOptions; 87 | }, []) 88 | .join(', '); 89 | }; 90 | 91 | const checkConfigOptions = (dotEnvConfig: DotenvParseOutput): Config => { 92 | const config = { 93 | ...dotEnvConfig, 94 | ...getEnvironmentConfig(), 95 | }; 96 | const missingOptions = getMissingOptions(config); 97 | if (missingOptions) { 98 | throw new Error( 99 | `Incomplete configuration. Please add the following options to .env or as environment variables: ${missingOptions}` 100 | ); 101 | } 102 | const BASEASSET = config.BASEASSET.toUpperCase(); 103 | const QUOTEASSET = config.QUOTEASSET.toUpperCase(); 104 | const CEX_BASEASSET = config.CEX_BASEASSET 105 | ? config.CEX_BASEASSET.toUpperCase() 106 | : BASEASSET; 107 | const CEX_QUOTEASSET = config.CEX_QUOTEASSET 108 | ? config.CEX_QUOTEASSET.toUpperCase() 109 | : QUOTEASSET; 110 | const verifiedConfig = { 111 | ...config, 112 | LOG_LEVEL: setLogLevel(config.LOG_LEVEL), 113 | TEST_MODE: config.TEST_MODE === 'true' ? true : false, 114 | CEX: config.CEX.toUpperCase(), 115 | BASEASSET, 116 | QUOTEASSET, 117 | CEX_BASEASSET, 118 | CEX_QUOTEASSET, 119 | }; 120 | return verifiedConfig as Config; 121 | }; 122 | 123 | const getConfig$ = (): Observable => { 124 | return of(require('dotenv').config()).pipe( 125 | pluck('parsed'), 126 | map(checkConfigOptions) 127 | ); 128 | }; 129 | 130 | export { getConfig$, checkConfigOptions }; 131 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | // Time in milliseconds between retry attempts from recoverable errors 2 | const RETRY_INTERVAL = 5000; 3 | const MAX_RETRY_ATTEMPS = 10; 4 | const MAX_RETRY_ATTEMPS_CLEANUP = 5; 5 | const CLEANUP_RETRY_INTERVAL = 1000; 6 | 7 | enum OrderSide { 8 | BUY = 'buy', 9 | SELL = 'sell', 10 | } 11 | 12 | export { 13 | CLEANUP_RETRY_INTERVAL, 14 | MAX_RETRY_ATTEMPS_CLEANUP, 15 | MAX_RETRY_ATTEMPS, 16 | RETRY_INTERVAL, 17 | OrderSide, 18 | }; 19 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import colors from 'colors/safe'; 2 | import winston from 'winston'; 3 | import { getTsString } from './utils'; 4 | 5 | enum Level { 6 | Error = 'error', 7 | Warn = 'warn', 8 | Info = 'info', 9 | Verbose = 'verbose', 10 | Debug = 'debug', 11 | Trace = 'trace', 12 | } 13 | 14 | const LevelPriorities = { 15 | error: 0, 16 | warn: 1, 17 | info: 2, 18 | verbose: 3, 19 | debug: 4, 20 | trace: 5, 21 | }; 22 | 23 | export enum Context { 24 | Global = 'GLOBAL', 25 | OpenDex = 'OpenDEX', 26 | Centralized = 'Centralized', 27 | } 28 | 29 | type Loggers = { 30 | global: Logger; 31 | centralized: Logger; 32 | opendex: Logger; 33 | }; 34 | 35 | class Logger { 36 | private level: string; 37 | private context: Context; 38 | private subcontext?: string; 39 | private logger?: winston.Logger; 40 | private filename?: string; 41 | private instanceId: number; 42 | private dateFormat?: string; 43 | 44 | constructor({ 45 | level = Level.Trace, 46 | filename, 47 | context = Context.Global, 48 | subcontext, 49 | instanceId = 0, 50 | disabled, 51 | dateFormat, 52 | }: { 53 | instanceId?: number; 54 | level?: string; 55 | filename?: string; 56 | context?: Context; 57 | subcontext?: string; 58 | disabled?: boolean; 59 | dateFormat?: string; 60 | }) { 61 | this.level = level; 62 | this.context = context; 63 | this.subcontext = subcontext; 64 | this.instanceId = instanceId; 65 | this.dateFormat = dateFormat; 66 | 67 | if (disabled) { 68 | return; 69 | } 70 | 71 | const transports: any[] = [ 72 | new winston.transports.Console({ 73 | level: this.level, 74 | format: this.getLogFormat(true, dateFormat), 75 | }), 76 | ]; 77 | 78 | if (filename) { 79 | this.filename = filename; 80 | transports.push( 81 | new winston.transports.File({ 82 | filename, 83 | level: this.level, 84 | format: this.getLogFormat(false, dateFormat), 85 | }) 86 | ); 87 | } 88 | 89 | this.logger = winston.createLogger({ 90 | transports, 91 | levels: LevelPriorities, 92 | }); 93 | } 94 | 95 | public static createLoggers = ( 96 | level: string, 97 | filename = '', 98 | instanceId = 0, 99 | dateFormat?: string 100 | ): Loggers => { 101 | const object = { instanceId, level, filename, dateFormat }; 102 | return { 103 | global: new Logger({ ...object, context: Context.Global }), 104 | centralized: new Logger({ ...object, context: Context.Centralized }), 105 | opendex: new Logger({ ...object, context: Context.OpenDex }), 106 | }; 107 | }; 108 | 109 | public createSubLogger = (subcontext: string) => { 110 | return new Logger({ 111 | subcontext, 112 | instanceId: this.instanceId, 113 | level: this.level, 114 | filename: this.filename, 115 | context: this.context, 116 | disabled: this.logger === undefined, 117 | dateFormat: this.dateFormat, 118 | }); 119 | }; 120 | 121 | private getLogFormat = (colorize: boolean, dateFormat?: string) => { 122 | const { format } = winston; 123 | 124 | const context = this.subcontext 125 | ? `${this.context}-${this.subcontext}` 126 | : this.context; 127 | if (this.instanceId > 0) { 128 | return format.printf( 129 | info => 130 | `${getTsString(dateFormat)} [${context}][${this.instanceId}] ` + 131 | `${this.getLevel(info.level, colorize)}: ${info.message}` 132 | ); 133 | } else { 134 | return format.printf( 135 | info => 136 | `${getTsString(dateFormat)} [${context}] ${this.getLevel( 137 | info.level, 138 | colorize 139 | )}: ${info.message}` 140 | ); 141 | } 142 | }; 143 | 144 | private getLevel = (level: string, colorize: boolean): string => { 145 | if (colorize) { 146 | switch (level) { 147 | case 'error': 148 | return colors.red(level); 149 | case 'warn': 150 | return colors.yellow(level); 151 | case 'info': 152 | return colors.green(level); 153 | case 'verbose': 154 | return colors.cyan(level); 155 | case 'debug': 156 | return colors.blue(level); 157 | case 'trace': 158 | return colors.magenta(level); 159 | } 160 | } 161 | return level; 162 | }; 163 | 164 | private log = (level: string, msg: string) => { 165 | if (this.logger) { 166 | this.logger.log(level, msg); 167 | } 168 | }; 169 | 170 | public error = (msg: Error | string, err?: any) => { 171 | let errMsg: string; 172 | if (msg instanceof Error) { 173 | // treat msg as an error object 174 | errMsg = msg.stack ? msg.stack : `${msg.name} - ${msg.message}`; 175 | } else { 176 | errMsg = msg; 177 | if (err) { 178 | errMsg += ': '; 179 | if (err instanceof Error) { 180 | errMsg += err.stack ? err.stack : `${err.name} - ${err.message}`; 181 | } else if (err.code && err.message) { 182 | errMsg += `${err.code} - ${err.message}`; 183 | } else { 184 | errMsg += JSON.stringify(err); 185 | } 186 | } 187 | } 188 | 189 | this.log(Level.Error, errMsg); 190 | }; 191 | 192 | public warn = (msg: string) => { 193 | this.log(Level.Warn, msg); 194 | }; 195 | 196 | public info = (msg: string) => { 197 | this.log(Level.Info, msg); 198 | }; 199 | 200 | public verbose = (msg: string) => { 201 | this.log(Level.Verbose, msg); 202 | }; 203 | 204 | public debug = (msg: string) => { 205 | this.log(Level.Debug, msg); 206 | }; 207 | 208 | public trace = (msg: string) => { 209 | this.log(Level.Trace, msg); 210 | }; 211 | } 212 | 213 | export { Level, Logger, Loggers }; 214 | -------------------------------------------------------------------------------- /src/opendex/__snapshots__/assets-utils.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`OpenDEX.assets-utils error baseAssetBalance 1`] = `"Could not retrieve ETH balance"`; 4 | 5 | exports[`OpenDEX.assets-utils error baseAssetTradingLimits 1`] = `"Could not retrieve ETH trading limits"`; 6 | 7 | exports[`OpenDEX.assets-utils error quoteAssetBalance 1`] = `"Could not retrieve BTC balance"`; 8 | 9 | exports[`OpenDEX.assets-utils error quoteAssetLimits 1`] = `"Could not retrieve BTC trading limits"`; 10 | -------------------------------------------------------------------------------- /src/opendex/__snapshots__/process-listorders.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`processListorders errors when orders list is missing 1`] = `"Could not retrieve orders list for ETH/BTC"`; 4 | -------------------------------------------------------------------------------- /src/opendex/assets-utils.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from 'bignumber.js'; 2 | import { Logger } from '../logger'; 3 | import { GetBalanceResponse, TradingLimitsResponse } from '../proto/xudrpc_pb'; 4 | import { OpenDEXassetAllocation } from '../trade/info'; 5 | import { satsToCoinsStr } from '../utils'; 6 | import { errors } from './errors'; 7 | 8 | type LogAssetBalanceParams = { 9 | logger: Logger; 10 | assets: OpenDEXassetAllocation; 11 | }; 12 | 13 | const logAssetBalance = ({ logger, assets }: LogAssetBalanceParams): void => { 14 | const { 15 | baseAssetBalance, 16 | baseAssetMaxInbound, 17 | baseAssetMaxOutbound, 18 | quoteAssetBalance, 19 | quoteAssetMaxInbound, 20 | quoteAssetMaxOutbound, 21 | } = assets; 22 | logger.trace( 23 | `Base asset balance ${baseAssetBalance.toFixed()} (maxinbound: ${baseAssetMaxInbound.toFixed()}, maxoutbound: ${baseAssetMaxOutbound.toFixed()}) and quote asset balance ${quoteAssetBalance.toFixed()} (maxinbound: ${quoteAssetMaxInbound.toFixed()}, maxoutbound: ${quoteAssetMaxOutbound.toFixed()}).` 24 | ); 25 | }; 26 | 27 | type ParseOpenDEXassetsParams = { 28 | balanceResponse: GetBalanceResponse; 29 | tradingLimitsResponse: TradingLimitsResponse; 30 | baseAsset: string; 31 | quoteAsset: string; 32 | }; 33 | 34 | const parseOpenDEXassets = ({ 35 | balanceResponse, 36 | tradingLimitsResponse, 37 | baseAsset, 38 | quoteAsset, 39 | }: ParseOpenDEXassetsParams): OpenDEXassetAllocation => { 40 | const balancesMap = balanceResponse.getBalancesMap(); 41 | const baseAssetBalances = balancesMap.get(baseAsset); 42 | if (!baseAssetBalances) { 43 | throw errors.BALANCE_MISSING(baseAsset); 44 | } 45 | const baseAssetBalance = new BigNumber( 46 | satsToCoinsStr(baseAssetBalances.getChannelBalance()) 47 | ); 48 | const quoteAssetBalances = balancesMap.get(quoteAsset); 49 | if (!quoteAssetBalances) { 50 | throw errors.BALANCE_MISSING(quoteAsset); 51 | } 52 | const quoteAssetBalance = new BigNumber( 53 | satsToCoinsStr(quoteAssetBalances.getChannelBalance()) 54 | ); 55 | const tradingLimitsMap = tradingLimitsResponse.getLimitsMap(); 56 | const baseAssetLimits = tradingLimitsMap.get(baseAsset); 57 | if (!baseAssetLimits) { 58 | throw errors.TRADING_LIMITS_MISSING(baseAsset); 59 | } 60 | const baseAssetMaxOutbound = new BigNumber( 61 | satsToCoinsStr(baseAssetLimits.getMaxSell()) 62 | ).plus(new BigNumber(satsToCoinsStr(baseAssetLimits.getReservedOutbound()))); 63 | const baseAssetMaxInbound = new BigNumber( 64 | satsToCoinsStr(baseAssetLimits.getMaxBuy()) 65 | ).plus(new BigNumber(satsToCoinsStr(baseAssetLimits.getReservedInbound()))); 66 | const quoteAssetLimits = tradingLimitsMap.get(quoteAsset); 67 | if (!quoteAssetLimits) { 68 | throw errors.TRADING_LIMITS_MISSING(quoteAsset); 69 | } 70 | const quoteAssetMaxOutbound = new BigNumber( 71 | satsToCoinsStr(quoteAssetLimits.getMaxSell()) 72 | ).plus(new BigNumber(satsToCoinsStr(quoteAssetLimits.getReservedOutbound()))); 73 | const quoteAssetMaxInbound = new BigNumber( 74 | satsToCoinsStr(quoteAssetLimits.getMaxBuy()) 75 | ).plus(new BigNumber(satsToCoinsStr(quoteAssetLimits.getReservedInbound()))); 76 | return { 77 | baseAssetBalance, 78 | quoteAssetBalance, 79 | baseAssetMaxOutbound, 80 | baseAssetMaxInbound, 81 | quoteAssetMaxInbound, 82 | quoteAssetMaxOutbound, 83 | }; 84 | }; 85 | 86 | export { 87 | parseOpenDEXassets, 88 | logAssetBalance, 89 | LogAssetBalanceParams, 90 | ParseOpenDEXassetsParams, 91 | }; 92 | -------------------------------------------------------------------------------- /src/opendex/assets.spec.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { TestScheduler } from 'rxjs/testing'; 3 | import { XudClient } from '../proto/xudrpc_grpc_pb'; 4 | import { GetBalanceResponse, TradingLimitsResponse } from '../proto/xudrpc_pb'; 5 | import { getLoggers, testConfig } from '../test-utils'; 6 | import { getOpenDEXassets$ } from './assets'; 7 | 8 | type OpenDEXassetsInputEvents = { 9 | xudClient$: string; 10 | xudBalance$: string; 11 | xudTradingLimits$: string; 12 | unsubscribe: string; 13 | }; 14 | 15 | let testScheduler: TestScheduler; 16 | 17 | const assertOpenDEXassets = ( 18 | inputEvents: OpenDEXassetsInputEvents, 19 | expected: string 20 | ) => { 21 | testScheduler.run(helpers => { 22 | const { cold, expectObservable } = helpers; 23 | const parseOpenDEXassets = ({ balanceResponse }: any) => balanceResponse; 24 | const getXudBalance$ = () => { 25 | return (cold(inputEvents.xudBalance$) as unknown) as Observable< 26 | GetBalanceResponse 27 | >; 28 | }; 29 | const getXudTradingLimits$ = () => { 30 | return (cold(inputEvents.xudTradingLimits$) as unknown) as Observable< 31 | TradingLimitsResponse 32 | >; 33 | }; 34 | const getXudClient$ = () => { 35 | return (cold(inputEvents.xudClient$) as unknown) as Observable; 36 | }; 37 | const logBalance = () => {}; 38 | const openDEXassets$ = getOpenDEXassets$({ 39 | logBalance, 40 | config: testConfig(), 41 | logger: getLoggers().global, 42 | xudClient$: getXudClient$, 43 | xudBalance$: getXudBalance$, 44 | xudTradingLimits$: getXudTradingLimits$, 45 | parseOpenDEXassets, 46 | }); 47 | expectObservable(openDEXassets$, inputEvents.unsubscribe).toBe(expected); 48 | }); 49 | }; 50 | 51 | describe('getOpenDEXassets$', () => { 52 | beforeEach(() => { 53 | testScheduler = new TestScheduler((actual, expected) => { 54 | expect(actual).toEqual(expected); 55 | }); 56 | }); 57 | 58 | test('waits for balance and tradinglimits before emitting', () => { 59 | const inputEvents = { 60 | xudBalance$: '1s a', 61 | xudClient$: '1s a', 62 | xudTradingLimits$: '1s a', 63 | unsubscribe: '93s !', 64 | }; 65 | const expected = '2s a 31999ms a 29999ms a'; 66 | assertOpenDEXassets(inputEvents, expected); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/opendex/assets.ts: -------------------------------------------------------------------------------- 1 | import { combineLatest, interval, Observable } from 'rxjs'; 2 | import { map, mergeMap, repeatWhen, take, tap } from 'rxjs/operators'; 3 | import { Config } from '../config'; 4 | import { Logger } from '../logger'; 5 | import { XudClient } from '../proto/xudrpc_grpc_pb'; 6 | import { GetBalanceResponse, TradingLimitsResponse } from '../proto/xudrpc_pb'; 7 | import { OpenDEXassetAllocation } from '../trade/info'; 8 | import { 9 | LogAssetBalanceParams, 10 | ParseOpenDEXassetsParams, 11 | } from './assets-utils'; 12 | 13 | type GetOpenDEXassetsParams = { 14 | config: Config; 15 | logger: Logger; 16 | logBalance: ({ logger, assets }: LogAssetBalanceParams) => void; 17 | xudClient$: (config: Config) => Observable; 18 | xudBalance$: (client: XudClient) => Observable; 19 | xudTradingLimits$: (client: XudClient) => Observable; 20 | parseOpenDEXassets: ({ 21 | balanceResponse, 22 | tradingLimitsResponse, 23 | quoteAsset, 24 | baseAsset, 25 | }: ParseOpenDEXassetsParams) => OpenDEXassetAllocation; 26 | }; 27 | 28 | const getOpenDEXassets$ = ({ 29 | config, 30 | logger, 31 | logBalance, 32 | xudClient$, 33 | xudBalance$, 34 | xudTradingLimits$, 35 | parseOpenDEXassets, 36 | }: GetOpenDEXassetsParams): Observable => { 37 | return xudClient$(config).pipe( 38 | mergeMap(client => { 39 | return combineLatest(xudBalance$(client), xudTradingLimits$(client)); 40 | }), 41 | map(([balanceResponse, tradingLimitsResponse]) => { 42 | return parseOpenDEXassets({ 43 | balanceResponse, 44 | tradingLimitsResponse, 45 | quoteAsset: config.QUOTEASSET, 46 | baseAsset: config.BASEASSET, 47 | }); 48 | }), 49 | tap(assets => logBalance({ assets, logger })), 50 | take(1), 51 | // refresh assets every 30 seconds 52 | repeatWhen(() => interval(30000)) 53 | ); 54 | }; 55 | 56 | export { getOpenDEXassets$ }; 57 | -------------------------------------------------------------------------------- /src/opendex/catch-error.ts: -------------------------------------------------------------------------------- 1 | import { status } from '@grpc/grpc-js'; 2 | import { AuthenticationError, Exchange, NetworkError } from 'ccxt'; 3 | import { concat, Observable, throwError, timer } from 'rxjs'; 4 | import { ignoreElements, mergeMap, retryWhen } from 'rxjs/operators'; 5 | import { ArbyStore } from 'src/store'; 6 | import { removeCEXorders$ } from '../centralized/remove-orders'; 7 | import { Config } from '../config'; 8 | import { MAX_RETRY_ATTEMPS, RETRY_INTERVAL } from '../constants'; 9 | import { Logger, Loggers } from '../logger'; 10 | import { errorCodes, errors } from '../opendex/errors'; 11 | import { GetCleanupParams } from '../trade/cleanup'; 12 | import { removeOpenDEXorders$ } from './remove-orders'; 13 | 14 | const catchOpenDEXerror = ( 15 | loggers: Loggers, 16 | config: Config, 17 | getCleanup$: ({ 18 | config, 19 | loggers, 20 | removeOpenDEXorders$, 21 | removeCEXorders$, 22 | }: GetCleanupParams) => Observable, 23 | CEX: Exchange, 24 | store: ArbyStore 25 | ) => { 26 | return (source: Observable) => { 27 | return source.pipe( 28 | retryWhen(attempts => { 29 | let UNAVAILABLE_ERROR_COUNT = 0; 30 | return attempts.pipe( 31 | mergeMap(e => { 32 | if (e.code === status.UNAVAILABLE) { 33 | if (UNAVAILABLE_ERROR_COUNT >= MAX_RETRY_ATTEMPS) { 34 | loggers.global.error( 35 | `UNAVAILABLE error count ${UNAVAILABLE_ERROR_COUNT} has reached threshold.` 36 | ); 37 | return throwError(e); 38 | } 39 | UNAVAILABLE_ERROR_COUNT++; 40 | } 41 | const logMessage = (logger: Logger) => { 42 | logger.warn(`${e.message}. Retrying in ${RETRY_INTERVAL}ms.`); 43 | }; 44 | // check if we're dealing with an error that 45 | // can be recovered from 46 | if ( 47 | e.code === errorCodes.BALANCE_MISSING || 48 | e.code === errorCodes.XUD_CLIENT_INVALID_CERT || 49 | e.code === errorCodes.TRADING_LIMITS_MISSING || 50 | e.code === errorCodes.INVALID_ORDERS_LIST || 51 | e.code === status.UNAVAILABLE || 52 | e.code === status.UNKNOWN || 53 | e.code === status.NOT_FOUND || 54 | e.code === status.ALREADY_EXISTS || 55 | e.code === status.FAILED_PRECONDITION || 56 | e.code === status.RESOURCE_EXHAUSTED || 57 | e.code === status.UNIMPLEMENTED || 58 | e.code === status.ABORTED || 59 | e.code === status.DEADLINE_EXCEEDED || 60 | e.code === status.INTERNAL 61 | ) { 62 | logMessage(loggers.opendex); 63 | return timer(RETRY_INTERVAL); 64 | } else if ( 65 | e.code === errorCodes.CENTRALIZED_EXCHANGE_PRICE_FEED_ERROR || 66 | e instanceof NetworkError 67 | ) { 68 | logMessage(loggers.centralized); 69 | store.resetLastOrderUpdatePrice(); 70 | return concat( 71 | getCleanup$({ 72 | config, 73 | loggers, 74 | removeOpenDEXorders$, 75 | removeCEXorders$, 76 | CEX, 77 | }).pipe(ignoreElements()), 78 | timer(RETRY_INTERVAL) 79 | ); 80 | } 81 | // unexpected or unrecoverable error should stop 82 | // the application 83 | if (e instanceof AuthenticationError) { 84 | return throwError(errors.CEX_INVALID_CREDENTIALS); 85 | } 86 | return throwError(e); 87 | }) 88 | ); 89 | }) 90 | ); 91 | }; 92 | }; 93 | 94 | export { catchOpenDEXerror }; 95 | -------------------------------------------------------------------------------- /src/opendex/complete.spec.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Exchange } from 'ccxt'; 3 | import { Observable } from 'rxjs'; 4 | import { TestScheduler } from 'rxjs/testing'; 5 | import { getArbyStore } from '../store'; 6 | import { getLoggers, testConfig } from '../test-utils'; 7 | import { TradeInfo } from '../trade/info'; 8 | import { getOpenDEXcomplete$ } from './complete'; 9 | 10 | let testScheduler: TestScheduler; 11 | const testSchedulerSetup = () => { 12 | testScheduler = new TestScheduler((actual, expected) => { 13 | expect(actual).toEqual(expected); 14 | }); 15 | }; 16 | 17 | const assertGetOpenDEXcomplete = ( 18 | inputEvents: { 19 | tradeInfo$: string; 20 | getOpenDEXorders$: string; 21 | }, 22 | expected: string, 23 | inputValues: { 24 | tradeInfo$: any; 25 | } 26 | ) => { 27 | testScheduler.run(helpers => { 28 | const { cold, hot, expectObservable } = helpers; 29 | const createOpenDEXorders$ = () => { 30 | return cold(inputEvents.getOpenDEXorders$, { 31 | a: true, 32 | }); 33 | }; 34 | const getTradeInfo$ = () => { 35 | return hot(inputEvents.tradeInfo$, inputValues.tradeInfo$) as Observable< 36 | TradeInfo 37 | >; 38 | }; 39 | const centralizedExchangePrice$ = (cold('') as unknown) as Observable< 40 | BigNumber 41 | >; 42 | const CEX = (null as unknown) as Exchange; 43 | const store = getArbyStore(); 44 | const trade$ = getOpenDEXcomplete$({ 45 | CEX, 46 | config: testConfig(), 47 | loggers: getLoggers(), 48 | tradeInfo$: getTradeInfo$, 49 | createOpenDEXorders$, 50 | centralizedExchangePrice$, 51 | store, 52 | }); 53 | expectObservable(trade$).toBe(expected, { a: true }); 54 | }); 55 | }; 56 | 57 | describe('getOpenDEXcomplete$', () => { 58 | beforeEach(testSchedulerSetup); 59 | 60 | test('when trade info present it creates new OpenDEX orders', () => { 61 | const inputEvents = { 62 | tradeInfo$: 'a ^ 1000ms a 1500ms b 500ms b', 63 | getOpenDEXorders$: '1s a|', 64 | }; 65 | const expected = '2s a 1500ms a'; 66 | const inputValues = { 67 | tradeInfo$: { 68 | a: { 69 | price: new BigNumber('10000'), 70 | }, 71 | b: { 72 | price: new BigNumber('10010.1'), 73 | }, 74 | }, 75 | }; 76 | assertGetOpenDEXcomplete(inputEvents, expected, inputValues); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/opendex/complete.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from 'bignumber.js'; 2 | import { Exchange } from 'ccxt'; 3 | import { Observable } from 'rxjs'; 4 | import { exhaustMap } from 'rxjs/operators'; 5 | import { getCentralizedExchangeAssets$ } from '../centralized/assets'; 6 | import { Config } from '../config'; 7 | import { Loggers } from '../logger'; 8 | import { ArbyStore } from '../store'; 9 | import { 10 | GetTradeInfoParams, 11 | TradeInfo, 12 | tradeInfoArrayToObject, 13 | } from '../trade/info'; 14 | import { getOpenDEXassets$ } from './assets'; 15 | import { logAssetBalance, parseOpenDEXassets } from './assets-utils'; 16 | import { CreateOpenDEXordersParams } from './create-orders'; 17 | import { tradeInfoToOpenDEXorders } from './orders'; 18 | import { shouldCreateOpenDEXorders } from './should-create-orders'; 19 | import { getXudBalance$ } from './xud/balance'; 20 | import { getXudClient$ } from './xud/client'; 21 | import { createXudOrder$ } from './xud/create-order'; 22 | import { getXudTradingLimits$ } from './xud/trading-limits'; 23 | 24 | type GetOpenDEXcompleteParams = { 25 | config: Config; 26 | loggers: Loggers; 27 | CEX: Exchange; 28 | tradeInfo$: ({ 29 | config, 30 | openDexAssets$, 31 | centralizedExchangeAssets$, 32 | centralizedExchangePrice$, 33 | }: GetTradeInfoParams) => Observable; 34 | createOpenDEXorders$: ({ 35 | config, 36 | logger, 37 | getTradeInfo, 38 | tradeInfoToOpenDEXorders, 39 | getXudClient$, 40 | createXudOrder$, 41 | store, 42 | }: CreateOpenDEXordersParams) => Observable; 43 | centralizedExchangePrice$: Observable; 44 | store: ArbyStore; 45 | }; 46 | 47 | const getOpenDEXcomplete$ = ({ 48 | config, 49 | loggers, 50 | CEX, 51 | tradeInfo$, 52 | createOpenDEXorders$, 53 | centralizedExchangePrice$, 54 | store, 55 | }: GetOpenDEXcompleteParams): Observable => { 56 | const openDEXassetsWithConfig = (config: Config) => { 57 | return getOpenDEXassets$({ 58 | config, 59 | logger: loggers.opendex, 60 | parseOpenDEXassets, 61 | logBalance: logAssetBalance, 62 | xudClient$: getXudClient$, 63 | xudBalance$: getXudBalance$, 64 | xudTradingLimits$: getXudTradingLimits$, 65 | }); 66 | }; 67 | return tradeInfo$({ 68 | config, 69 | loggers, 70 | CEX, 71 | tradeInfoArrayToObject, 72 | openDexAssets$: openDEXassetsWithConfig, 73 | centralizedExchangeAssets$: getCentralizedExchangeAssets$, 74 | centralizedExchangePrice$, 75 | }).pipe( 76 | // ignore new trade information when creating orders 77 | // is already in progress 78 | exhaustMap((tradeInfo: TradeInfo) => { 79 | const getTradeInfo = () => tradeInfo; 80 | return createOpenDEXorders$({ 81 | config, 82 | logger: loggers.opendex, 83 | getTradeInfo, 84 | getXudClient$, 85 | createXudOrder$, 86 | tradeInfoToOpenDEXorders, 87 | store, 88 | shouldCreateOpenDEXorders, 89 | }); 90 | }) 91 | ); 92 | }; 93 | 94 | export { getOpenDEXcomplete$, GetOpenDEXcompleteParams }; 95 | -------------------------------------------------------------------------------- /src/opendex/create-orders.ts: -------------------------------------------------------------------------------- 1 | import { status } from '@grpc/grpc-js'; 2 | import BigNumber from 'bignumber.js'; 3 | import { forkJoin, Observable, of, throwError } from 'rxjs'; 4 | import { catchError, mapTo, mergeMap, take } from 'rxjs/operators'; 5 | import { Config } from '../config'; 6 | import { Logger } from '../logger'; 7 | import { XudClient } from '../proto/xudrpc_grpc_pb'; 8 | import { OrderSide, PlaceOrderResponse } from '../proto/xudrpc_pb'; 9 | import { ArbyStore } from '../store'; 10 | import { TradeInfo } from '../trade/info'; 11 | import { 12 | createOrderID, 13 | OpenDEXorders, 14 | TradeInfoToOpenDEXordersParams, 15 | } from './orders'; 16 | import { CreateXudOrderParams } from './xud/create-order'; 17 | 18 | type CreateOpenDEXordersParams = { 19 | config: Config; 20 | logger: Logger; 21 | getTradeInfo: () => TradeInfo; 22 | tradeInfoToOpenDEXorders: ({ 23 | tradeInfo, 24 | config, 25 | }: TradeInfoToOpenDEXordersParams) => OpenDEXorders; 26 | getXudClient$: (config: Config) => Observable; 27 | createXudOrder$: ({ 28 | client, 29 | logger, 30 | quantity, 31 | orderSide, 32 | pairId, 33 | price, 34 | orderId, 35 | }: CreateXudOrderParams) => Observable; 36 | store: ArbyStore; 37 | shouldCreateOpenDEXorders: ( 38 | newPrice: BigNumber, 39 | lastPriceUpdate: BigNumber 40 | ) => boolean; 41 | }; 42 | 43 | const createOpenDEXorders$ = ({ 44 | config, 45 | logger, 46 | getTradeInfo, 47 | tradeInfoToOpenDEXorders, 48 | getXudClient$, 49 | createXudOrder$, 50 | store, 51 | shouldCreateOpenDEXorders, 52 | }: CreateOpenDEXordersParams): Observable => { 53 | return getXudClient$(config).pipe( 54 | // create new buy and sell orders 55 | mergeMap(client => { 56 | const tradeInfo = getTradeInfo(); 57 | return store.stateChanges().pipe( 58 | take(1), 59 | mergeMap(storeState => { 60 | // build orders based on all the available trade info 61 | const { buyOrder, sellOrder } = tradeInfoToOpenDEXorders({ 62 | config, 63 | tradeInfo, 64 | }); 65 | let buyOrder$ = (of(null) as unknown) as Observable< 66 | PlaceOrderResponse 67 | >; 68 | let sellOrder$ = (of(null) as unknown) as Observable< 69 | PlaceOrderResponse 70 | >; 71 | if ( 72 | shouldCreateOpenDEXorders( 73 | tradeInfo.price, 74 | storeState.lastBuyOrderUpdatePrice 75 | ) 76 | ) { 77 | // try replacing existing buy order 78 | buyOrder$ = createXudOrder$({ 79 | ...{ client, logger }, 80 | ...buyOrder, 81 | ...{ 82 | replaceOrderId: createOrderID(config, OrderSide.BUY), 83 | }, 84 | }).pipe( 85 | catchError(e => { 86 | if (e.code === status.NOT_FOUND) { 87 | // place order if existing one does not exist 88 | return createXudOrder$({ 89 | ...{ client, logger }, 90 | ...buyOrder, 91 | }); 92 | } 93 | return throwError(e); 94 | }), 95 | mergeMap(orderResponse => { 96 | orderResponse && 97 | store.updateLastBuyOrderUpdatePrice(tradeInfo.price); 98 | return of(orderResponse); 99 | }) 100 | ); 101 | } 102 | if ( 103 | shouldCreateOpenDEXorders( 104 | tradeInfo.price, 105 | storeState.lastSellOrderUpdatePrice 106 | ) 107 | ) { 108 | // try replacing existing sell order 109 | sellOrder$ = createXudOrder$({ 110 | ...{ client, logger }, 111 | ...sellOrder, 112 | ...{ 113 | replaceOrderId: createOrderID(config, OrderSide.SELL), 114 | }, 115 | }).pipe( 116 | catchError(e => { 117 | if (e.code === status.NOT_FOUND) { 118 | // place order if existing one does not exist 119 | return createXudOrder$({ 120 | ...{ client, logger }, 121 | ...sellOrder, 122 | }); 123 | } 124 | return throwError(e); 125 | }), 126 | mergeMap(orderResponse => { 127 | orderResponse && 128 | store.updateLastSellOrderUpdatePrice(tradeInfo.price); 129 | return of(orderResponse); 130 | }) 131 | ); 132 | } 133 | const ordersComplete$ = forkJoin(sellOrder$, buyOrder$).pipe( 134 | mapTo(true) 135 | ); 136 | return ordersComplete$; 137 | }) 138 | ); 139 | }), 140 | take(1) 141 | ); 142 | }; 143 | 144 | export { createOpenDEXorders$, CreateOpenDEXordersParams, OpenDEXorders }; 145 | -------------------------------------------------------------------------------- /src/opendex/errors.ts: -------------------------------------------------------------------------------- 1 | const errorCodePrefix = 'arby'; 2 | 3 | const errorCodes = { 4 | XUD_CLIENT_INVALID_CERT: `${errorCodePrefix}.1`, 5 | BALANCE_MISSING: `${errorCodePrefix}.2`, 6 | TRADING_LIMITS_MISSING: `${errorCodePrefix}.3`, 7 | INVALID_ORDERS_LIST: `${errorCodePrefix}.4`, 8 | CENTRALIZED_EXCHANGE_PRICE_FEED_ERROR: `${errorCodePrefix}.5`, 9 | CEX_INVALID_CREDENTIALS: `${errorCodePrefix}.6`, 10 | CEX_INVALID_MINIMUM_ORDER_QUANTITY: `${errorCodePrefix}.7`, 11 | CEX_INVALID_TRADING_PAIR: `${errorCodePrefix}.8`, 12 | }; 13 | 14 | type ArbyError = { 15 | message: string; 16 | code: string; 17 | }; 18 | 19 | const errors = { 20 | XUD_CLIENT_INVALID_CERT: (certPath: string) => ({ 21 | message: `Unable to load xud.cert from ${certPath}`, 22 | code: errorCodes.XUD_CLIENT_INVALID_CERT, 23 | }), 24 | BALANCE_MISSING: (asset: string) => ({ 25 | message: `Could not retrieve ${asset} balance`, 26 | code: errorCodes.BALANCE_MISSING, 27 | }), 28 | TRADING_LIMITS_MISSING: (asset: string) => ({ 29 | message: `Could not retrieve ${asset} trading limits`, 30 | code: errorCodes.TRADING_LIMITS_MISSING, 31 | }), 32 | INVALID_ORDERS_LIST: (tradingPair: string) => ({ 33 | message: `Could not retrieve orders list for ${tradingPair}`, 34 | code: errorCodes.INVALID_ORDERS_LIST, 35 | }), 36 | CENTRALIZED_EXCHANGE_PRICE_FEED_ERROR: { 37 | message: 'Price feed lost', 38 | code: errorCodes.CENTRALIZED_EXCHANGE_PRICE_FEED_ERROR, 39 | }, 40 | CEX_INVALID_CREDENTIALS: { 41 | message: 'Invalid CEX_API_KEY or CEX_API_SECRET', 42 | code: errorCodes.CEX_INVALID_CREDENTIALS, 43 | }, 44 | CEX_INVALID_MINIMUM_ORDER_QUANTITY: (asset: string) => ({ 45 | message: `Could not retrieve minimum order quantity for ${asset}`, 46 | code: errorCodes.CEX_INVALID_MINIMUM_ORDER_QUANTITY, 47 | }), 48 | CEX_INVALID_TRADING_PAIR: (tradingPair: string, CEX: string) => ({ 49 | message: `The configured trading pair ${tradingPair} does not exist on ${CEX}`, 50 | code: errorCodes.CEX_INVALID_TRADING_PAIR, 51 | }), 52 | }; 53 | 54 | export { errorCodes, errors, ArbyError }; 55 | -------------------------------------------------------------------------------- /src/opendex/orders.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Config } from '../config'; 3 | import { OrderSide } from '../proto/xudrpc_pb'; 4 | import { TradeInfo } from '../trade/info'; 5 | import { coinsToSats } from '../utils'; 6 | 7 | type OpenDEXorder = { 8 | quantity: number; 9 | orderSide: OrderSide; 10 | pairId: string; 11 | price: number; 12 | orderId: string; 13 | replaceOrderId?: string; 14 | }; 15 | 16 | type OpenDEXorders = { 17 | buyOrder: OpenDEXorder; 18 | sellOrder: OpenDEXorder; 19 | }; 20 | 21 | type TradeInfoToOpenDEXordersParams = { 22 | tradeInfo: TradeInfo; 23 | config: Config; 24 | }; 25 | 26 | const createOrderID = (config: Config, orderSide: OrderSide): string => { 27 | const pairId = `${config.BASEASSET}/${config.QUOTEASSET}`; 28 | return orderSide === OrderSide.BUY 29 | ? `arby-${pairId}-buy-order` 30 | : `arby-${pairId}-sell-order`; 31 | }; 32 | 33 | const tradeInfoToOpenDEXorders = ({ 34 | tradeInfo, 35 | config, 36 | }: TradeInfoToOpenDEXordersParams): OpenDEXorders => { 37 | const { price } = tradeInfo; 38 | const { centralizedExchange, openDEX } = tradeInfo.assets; 39 | const { 40 | baseAssetMaxOutbound: openDEXbaseAssetMaxOutbound, 41 | baseAssetMaxInbound: openDEXbaseAssetMaxInbound, 42 | quoteAssetMaxOutbound: openDEXquoteAssetMaxOutbound, 43 | quoteAssetMaxInbound: openDEXquoteAssetMaxInbound, 44 | } = openDEX; 45 | const { 46 | baseAssetBalance: centralizedExchangeBaseAssetBalance, 47 | quoteAssetBalance: centralizedExchangeQuoteAssetBalance, 48 | } = centralizedExchange; 49 | const pairId = `${config.BASEASSET}/${config.QUOTEASSET}`; 50 | const marginPercentage = new BigNumber(config.MARGIN); 51 | const margin = price.multipliedBy(marginPercentage); 52 | const buyPrice = price.minus(margin); 53 | const sellPrice = price.plus(margin); 54 | const CONNEXT_CURRENCIES = ['ETH', 'USDT', 'DAI']; 55 | const getOpenDEXMaxInbound = (asset: string) => { 56 | if (CONNEXT_CURRENCIES.includes(asset)) { 57 | if (asset === 'ETH') { 58 | // connext node provides us max inbound liquidity of 15 ETH 59 | return new BigNumber('14.25'); 60 | } else { 61 | // ...and 5000 for other assets 62 | return new BigNumber('4750'); 63 | } 64 | } else if (asset === config.BASEASSET) { 65 | return openDEXbaseAssetMaxInbound; 66 | } else { 67 | return openDEXquoteAssetMaxInbound; 68 | } 69 | }; 70 | const buyQuantity = BigNumber.minimum( 71 | getOpenDEXMaxInbound(config.BASEASSET), 72 | openDEXquoteAssetMaxOutbound.dividedBy(buyPrice), 73 | centralizedExchangeBaseAssetBalance 74 | ); 75 | const BUFFER = new BigNumber('0.01'); 76 | const buyQuantityWithBuffer = buyQuantity.minus( 77 | buyQuantity.multipliedBy(BUFFER) 78 | ); 79 | const sellQuantity = BigNumber.minimum( 80 | openDEXbaseAssetMaxOutbound, 81 | getOpenDEXMaxInbound(config.QUOTEASSET).dividedBy(sellPrice), 82 | centralizedExchangeQuoteAssetBalance.dividedBy(price) 83 | ); 84 | const sellQuantityWithBuffer = sellQuantity.minus( 85 | sellQuantity.multipliedBy(BUFFER) 86 | ); 87 | const buyOrder = { 88 | quantity: coinsToSats( 89 | new BigNumber(buyQuantityWithBuffer.toFixed(8, 1)).toNumber() 90 | ), 91 | orderSide: OrderSide.BUY, 92 | pairId, 93 | price: buyPrice.toNumber(), 94 | orderId: createOrderID(config, OrderSide.BUY), 95 | }; 96 | const sellOrder = { 97 | quantity: coinsToSats( 98 | new BigNumber(sellQuantityWithBuffer.toFixed(8, 1)).toNumber() 99 | ), 100 | orderSide: OrderSide.SELL, 101 | pairId, 102 | price: sellPrice.toNumber(), 103 | orderId: createOrderID(config, OrderSide.SELL), 104 | }; 105 | return { 106 | buyOrder, 107 | sellOrder, 108 | }; 109 | }; 110 | 111 | export { 112 | OpenDEXorders, 113 | OpenDEXorder, 114 | tradeInfoToOpenDEXorders, 115 | createOrderID, 116 | TradeInfoToOpenDEXordersParams, 117 | }; 118 | -------------------------------------------------------------------------------- /src/opendex/process-listorders.spec.ts: -------------------------------------------------------------------------------- 1 | import { equals } from 'ramda'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import { ListOrdersResponse } from '../proto/xudrpc_pb'; 4 | import { testConfig } from '../test-utils'; 5 | import { processListorders } from './process-listorders'; 6 | 7 | type TestOrderParams = { 8 | quantity: number; 9 | hold: number; 10 | ownOrder?: boolean; 11 | }; 12 | 13 | type TestOrder = { 14 | getLocalId: () => string; 15 | getQuantity: () => number; 16 | getHold: () => number; 17 | getIsOwnOrder: () => boolean; 18 | }; 19 | 20 | const testOrder = ({ 21 | quantity, 22 | hold, 23 | ownOrder = true, 24 | }: TestOrderParams): TestOrder => { 25 | const orderId = uuidv4(); 26 | return { 27 | getLocalId: () => orderId, 28 | getQuantity: () => quantity, 29 | getHold: () => hold, 30 | getIsOwnOrder: () => ownOrder, 31 | }; 32 | }; 33 | 34 | type AssertProcessListOrdersParams = { 35 | buyOrders: TestOrder[]; 36 | sellOrders: TestOrder[]; 37 | expectedIds: string[]; 38 | }; 39 | 40 | const assertProcessListOrders = ({ 41 | buyOrders, 42 | sellOrders, 43 | expectedIds, 44 | }: AssertProcessListOrdersParams) => { 45 | const ordersMap = new Map(); 46 | const orders = { 47 | getBuyOrdersList: () => buyOrders, 48 | getSellOrdersList: () => sellOrders, 49 | }; 50 | const { BASEASSET, QUOTEASSET } = testConfig(); 51 | ordersMap.set(`${BASEASSET}/${QUOTEASSET}`, orders); 52 | const listOrdersResponse = ({ 53 | getOrdersMap: () => ordersMap, 54 | } as unknown) as ListOrdersResponse; 55 | const orderIds = processListorders({ 56 | config: testConfig(), 57 | listOrdersResponse, 58 | }); 59 | const orderIdsMatch = equals(orderIds, expectedIds); 60 | expect(orderIdsMatch).toBeTruthy(); 61 | }; 62 | 63 | describe('processListorders', () => { 64 | it('returns list of order ids from ListOrdersResponse', () => { 65 | expect.assertions(1); 66 | const sellOrder = testOrder({ 67 | quantity: 100000, 68 | hold: 0, 69 | }); 70 | const buyOrder = testOrder({ 71 | quantity: 123000, 72 | hold: 0, 73 | }); 74 | const expectedIds = [buyOrder.getLocalId(), sellOrder.getLocalId()]; 75 | assertProcessListOrders({ 76 | sellOrders: [sellOrder], 77 | buyOrders: [buyOrder], 78 | expectedIds, 79 | }); 80 | }); 81 | 82 | it('ignores orders with all of quantity on hold', () => { 83 | expect.assertions(1); 84 | const sellOrder = testOrder({ 85 | quantity: 100000, 86 | hold: 100000, 87 | }); 88 | const buyOrder = testOrder({ 89 | quantity: 123000, 90 | hold: 123000, 91 | }); 92 | assertProcessListOrders({ 93 | sellOrders: [sellOrder], 94 | buyOrders: [buyOrder], 95 | expectedIds: [], 96 | }); 97 | }); 98 | 99 | it("ignores others' orders", () => { 100 | expect.assertions(1); 101 | const sellOrder = testOrder({ 102 | quantity: 100000, 103 | hold: 0, 104 | ownOrder: false, 105 | }); 106 | const buyOrder = testOrder({ 107 | quantity: 123000, 108 | hold: 0, 109 | ownOrder: false, 110 | }); 111 | assertProcessListOrders({ 112 | sellOrders: [sellOrder], 113 | buyOrders: [buyOrder], 114 | expectedIds: [], 115 | }); 116 | }); 117 | 118 | it('errors when orders list is missing', () => { 119 | expect.assertions(1); 120 | const ordersMap = new Map(); 121 | const listOrdersResponse = ({ 122 | getOrdersMap: () => ordersMap, 123 | } as unknown) as ListOrdersResponse; 124 | expect(() => { 125 | processListorders({ 126 | config: testConfig(), 127 | listOrdersResponse, 128 | }); 129 | }).toThrowErrorMatchingSnapshot(); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/opendex/process-listorders.ts: -------------------------------------------------------------------------------- 1 | import { ListOrdersResponse, Order } from '../proto/xudrpc_pb'; 2 | import { Config } from 'src/config'; 3 | import { errors } from './errors'; 4 | 5 | type ProcessListOrdersParams = { 6 | config: Config; 7 | listOrdersResponse: ListOrdersResponse; 8 | }; 9 | 10 | const processListorders = ({ 11 | listOrdersResponse, 12 | config, 13 | }: ProcessListOrdersParams): string[] => { 14 | const ordersMap = listOrdersResponse.getOrdersMap(); 15 | const { BASEASSET, QUOTEASSET } = config; 16 | const tradingPair = `${BASEASSET}/${QUOTEASSET}`; 17 | const tradingPairOrders = ordersMap.get(tradingPair); 18 | if (!tradingPairOrders) { 19 | throw errors.INVALID_ORDERS_LIST(tradingPair); 20 | } 21 | const buyOrders = tradingPairOrders.getBuyOrdersList(); 22 | const sellOrders = tradingPairOrders.getSellOrdersList(); 23 | const orderIds = buyOrders 24 | .concat(sellOrders) 25 | .reduce((allOrderIds: string[], currentOrder: Order) => { 26 | const ownOrder = currentOrder.getIsOwnOrder(); 27 | const quantity = currentOrder.getQuantity(); 28 | const hold = currentOrder.getHold(); 29 | const remainingQuantity = quantity - hold; 30 | if (ownOrder && remainingQuantity) { 31 | return allOrderIds.concat(currentOrder.getLocalId()); 32 | } 33 | return allOrderIds; 34 | }, []); 35 | return orderIds; 36 | }; 37 | 38 | export { processListorders, ProcessListOrdersParams }; 39 | -------------------------------------------------------------------------------- /src/opendex/remove-orders.spec.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { TestScheduler } from 'rxjs/testing'; 3 | import { XudClient } from '../proto/xudrpc_grpc_pb'; 4 | import { RemoveOrderResponse } from '../proto/xudrpc_pb'; 5 | import { testConfig } from '../test-utils'; 6 | import { removeOpenDEXorders$ } from './remove-orders'; 7 | import { ListXudOrdersResponse } from './xud/list-orders'; 8 | 9 | let testScheduler: TestScheduler; 10 | const testSchedulerSetup = () => { 11 | testScheduler = new TestScheduler((actual, expected) => { 12 | expect(actual).toEqual(expected); 13 | }); 14 | }; 15 | 16 | type RemoveOpenDEXordersInputEvents = { 17 | getXudClient$: string; 18 | listXudOrders$: string; 19 | removeXudOrder$: string; 20 | activeOrderIds: string[]; 21 | }; 22 | 23 | const assertRemoveOpenDEXorders = ( 24 | inputEvents: RemoveOpenDEXordersInputEvents, 25 | expected: string 26 | ) => { 27 | testScheduler.run(helpers => { 28 | const { cold, expectObservable } = helpers; 29 | const getXudClient$ = () => { 30 | return (cold(inputEvents.getXudClient$) as unknown) as Observable< 31 | XudClient 32 | >; 33 | }; 34 | const listXudOrders$ = () => { 35 | return (cold(inputEvents.listXudOrders$) as unknown) as Observable< 36 | ListXudOrdersResponse 37 | >; 38 | }; 39 | const removeXudOrder$ = () => { 40 | return (cold(inputEvents.removeXudOrder$) as unknown) as Observable< 41 | RemoveOrderResponse 42 | >; 43 | }; 44 | const processListorders = () => inputEvents.activeOrderIds; 45 | const removeOrders$ = removeOpenDEXorders$({ 46 | config: testConfig(), 47 | getXudClient$, 48 | listXudOrders$, 49 | removeXudOrder$, 50 | processListorders, 51 | }); 52 | expectObservable(removeOrders$).toBe(expected, { 53 | a: null, 54 | }); 55 | }); 56 | }; 57 | 58 | describe('removeOpenDEXorders$', () => { 59 | beforeEach(testSchedulerSetup); 60 | 61 | it('gets a list of all active orders and removes all of them', () => { 62 | const inputEvents = { 63 | getXudClient$: '1s a', 64 | listXudOrders$: '1s a', 65 | removeXudOrder$: '1s (a|)', 66 | activeOrderIds: ['a', 'b', 'c'], 67 | }; 68 | const expectedEvents = '3s (a|)'; 69 | assertRemoveOpenDEXorders(inputEvents, expectedEvents); 70 | }); 71 | 72 | it('emits value without existing orders', () => { 73 | const inputEvents = { 74 | getXudClient$: '1s a', 75 | listXudOrders$: '1s a', 76 | removeXudOrder$: '1s (a|)', 77 | activeOrderIds: [], 78 | }; 79 | const expectedEvents = '2s (a|)'; 80 | assertRemoveOpenDEXorders(inputEvents, expectedEvents); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/opendex/remove-orders.ts: -------------------------------------------------------------------------------- 1 | import { forkJoin, Observable, of } from 'rxjs'; 2 | import { map, mapTo, mergeMap, take } from 'rxjs/operators'; 3 | import { Config } from 'src/config'; 4 | import { XudClient } from '../proto/xudrpc_grpc_pb'; 5 | import { RemoveOrderResponse } from '../proto/xudrpc_pb'; 6 | import { ProcessListOrdersParams } from './process-listorders'; 7 | import { ListXudOrdersResponse } from './xud/list-orders'; 8 | import { RemoveXudOrderParams } from './xud/remove-order'; 9 | 10 | type RemoveOpenDEXordersParams = { 11 | config: Config; 12 | getXudClient$: (config: Config) => Observable; 13 | listXudOrders$: (client: XudClient) => Observable; 14 | processListorders: ({ 15 | config, 16 | listOrdersResponse, 17 | }: ProcessListOrdersParams) => string[]; 18 | removeXudOrder$: ({ 19 | client, 20 | orderId, 21 | }: RemoveXudOrderParams) => Observable; 22 | }; 23 | 24 | const removeOpenDEXorders$ = ({ 25 | config, 26 | getXudClient$, 27 | listXudOrders$, 28 | removeXudOrder$, 29 | processListorders, 30 | }: RemoveOpenDEXordersParams): Observable => { 31 | return getXudClient$(config).pipe( 32 | mergeMap(client => { 33 | // get a list of all orders 34 | return listXudOrders$(client).pipe( 35 | map(({ client, orders }) => { 36 | // create a list of active order ids 37 | const orderIds = processListorders({ 38 | config, 39 | listOrdersResponse: orders, 40 | }); 41 | return { client, orderIds }; 42 | }) 43 | ); 44 | }), 45 | mergeMap(({ client, orderIds }) => { 46 | if (orderIds.length) { 47 | // remove all active orders 48 | const removeOrders$ = orderIds.map(orderId => { 49 | return removeXudOrder$({ 50 | client, 51 | orderId, 52 | }); 53 | }); 54 | // wait for all remove order requests to complete 55 | return forkJoin(removeOrders$).pipe(mapTo(null)); 56 | } else { 57 | // continue without removing anything 58 | return of(null); 59 | } 60 | }), 61 | take(1) 62 | ); 63 | }; 64 | 65 | export { removeOpenDEXorders$, RemoveOpenDEXordersParams }; 66 | -------------------------------------------------------------------------------- /src/opendex/should-create-orders.spec.ts: -------------------------------------------------------------------------------- 1 | import { shouldCreateOpenDEXorders } from './should-create-orders'; 2 | import BigNumber from 'bignumber.js'; 3 | 4 | describe('shouldCreateOpenDEXorders', () => { 5 | it('returns true when price increases by 0.1%', () => { 6 | const newPrice = new BigNumber('10000'); 7 | const lastPriceUpdate = new BigNumber('10010.1'); 8 | expect(shouldCreateOpenDEXorders(newPrice, lastPriceUpdate)).toEqual(true); 9 | }); 10 | 11 | it('returns true when price decreases by 0.1%', () => { 12 | const newPrice = new BigNumber('10000'); 13 | const lastPriceUpdate = new BigNumber('9989.9'); 14 | expect(shouldCreateOpenDEXorders(newPrice, lastPriceUpdate)).toEqual(true); 15 | }); 16 | 17 | it('returns false when price increases less than 0.1%', () => { 18 | const newPrice = new BigNumber('10000'); 19 | const lastPriceUpdate = new BigNumber('10009.9'); 20 | expect(shouldCreateOpenDEXorders(newPrice, lastPriceUpdate)).toEqual(false); 21 | }); 22 | 23 | it('returns false when price decreases less than 0.1%', () => { 24 | const newPrice = new BigNumber('10000'); 25 | const lastPriceUpdate = new BigNumber('9990.1'); 26 | expect(shouldCreateOpenDEXorders(newPrice, lastPriceUpdate)).toEqual(false); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/opendex/should-create-orders.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | 3 | // decide whether to update orders based on 4 | // last price update and new price 5 | const shouldCreateOpenDEXorders = ( 6 | newPrice: BigNumber, 7 | lastPriceUpdate: BigNumber 8 | ): boolean => { 9 | const priceDiff = lastPriceUpdate.minus(newPrice).absoluteValue(); 10 | const maxPriceDiff = lastPriceUpdate.multipliedBy(new BigNumber('0.001')); 11 | if (priceDiff.isGreaterThanOrEqualTo(maxPriceDiff)) { 12 | return true; 13 | } 14 | return false; 15 | }; 16 | 17 | export { shouldCreateOpenDEXorders }; 18 | -------------------------------------------------------------------------------- /src/opendex/swap-success.spec.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { TestScheduler } from 'rxjs/testing'; 3 | import { XudClient } from '../proto/xudrpc_grpc_pb'; 4 | import { SwapSuccess } from '../proto/xudrpc_pb'; 5 | import { testConfig } from '../test-utils'; 6 | import { getOpenDEXswapSuccess$ } from './swap-success'; 7 | 8 | let testScheduler: TestScheduler; 9 | const testSchedulerSetup = () => { 10 | testScheduler = new TestScheduler((actual, expected) => { 11 | expect(actual).toEqual(expected); 12 | }); 13 | }; 14 | 15 | type AssertOpenDEXswapSuccess = { 16 | inputEvents: { 17 | xudClient$: string; 18 | subscribeXudSwaps$: string; 19 | unsubscribe: string; 20 | }; 21 | inputValues?: { 22 | subscribeXudSwaps$: { 23 | a: SwapSuccess; 24 | b: SwapSuccess; 25 | }; 26 | }; 27 | expectedEvents: { 28 | receivedBaseAssetSwapSuccess$: string; 29 | receivedQuoteAssetSwapSuccess$: string; 30 | xudSwaps$subscriptions: string | string[]; 31 | xudClien$subscriptions: string | string[]; 32 | }; 33 | expectedValues?: { 34 | receivedBaseAssetSwapSuccess$: { 35 | a: SwapSuccess; 36 | }; 37 | receivedQuoteAssetSwapSuccess$: { 38 | a: SwapSuccess; 39 | }; 40 | }; 41 | }; 42 | 43 | const config = testConfig(); 44 | 45 | const assertOpenDEXswapSuccess = ({ 46 | inputEvents, 47 | inputValues, 48 | expectedEvents, 49 | expectedValues, 50 | }: AssertOpenDEXswapSuccess) => { 51 | testScheduler.run(helpers => { 52 | const { cold, expectObservable, expectSubscriptions } = helpers; 53 | const xudClient$ = cold(inputEvents.xudClient$); 54 | const getXudClient$ = () => { 55 | return (xudClient$ as unknown) as Observable; 56 | }; 57 | const xudSwapSuccess$ = cold( 58 | inputEvents.subscribeXudSwaps$, 59 | inputValues?.subscribeXudSwaps$ 60 | ); 61 | const subscribeXudSwaps$ = () => { 62 | return (xudSwapSuccess$ as unknown) as Observable; 63 | }; 64 | const { 65 | receivedBaseAssetSwapSuccess$, 66 | receivedQuoteAssetSwapSuccess$, 67 | } = getOpenDEXswapSuccess$({ 68 | config, 69 | getXudClient$, 70 | subscribeXudSwaps$, 71 | }); 72 | expectObservable( 73 | receivedBaseAssetSwapSuccess$, 74 | inputEvents.unsubscribe 75 | ).toBe( 76 | expectedEvents.receivedBaseAssetSwapSuccess$, 77 | expectedValues?.receivedBaseAssetSwapSuccess$ 78 | ); 79 | expectObservable( 80 | receivedQuoteAssetSwapSuccess$, 81 | inputEvents.unsubscribe 82 | ).toBe( 83 | expectedEvents.receivedQuoteAssetSwapSuccess$, 84 | expectedValues?.receivedQuoteAssetSwapSuccess$ 85 | ); 86 | expectSubscriptions(xudSwapSuccess$.subscriptions).toBe( 87 | expectedEvents.xudSwaps$subscriptions 88 | ); 89 | expectSubscriptions(xudClient$.subscriptions).toBe( 90 | expectedEvents.xudClien$subscriptions 91 | ); 92 | }); 93 | }; 94 | 95 | describe('getOpenDEXswapSuccess$', () => { 96 | beforeEach(testSchedulerSetup); 97 | 98 | it('emits on swap success', () => { 99 | const inputEvents = { 100 | xudClient$: '1s a', 101 | subscribeXudSwaps$: '1s a 1s b', 102 | unsubscribe: '4s !', 103 | }; 104 | const { BASEASSET, QUOTEASSET } = config; 105 | const swapSuccessA = ({ 106 | getCurrencyReceived: () => BASEASSET, 107 | } as unknown) as SwapSuccess; 108 | const swapSuccessB = ({ 109 | getCurrencyReceived: () => QUOTEASSET, 110 | } as unknown) as SwapSuccess; 111 | const inputValues = { 112 | subscribeXudSwaps$: { 113 | a: swapSuccessA, 114 | b: swapSuccessB, 115 | }, 116 | }; 117 | const expectedEvents = { 118 | receivedBaseAssetSwapSuccess$: '2s a', 119 | receivedQuoteAssetSwapSuccess$: '3001ms a', 120 | xudSwaps$subscriptions: '1s ^ 2999ms !', 121 | xudClien$subscriptions: '^ 3999ms !', 122 | }; 123 | const expectedValues = { 124 | receivedBaseAssetSwapSuccess$: { 125 | a: swapSuccessA, 126 | }, 127 | receivedQuoteAssetSwapSuccess$: { 128 | a: swapSuccessB, 129 | }, 130 | }; 131 | assertOpenDEXswapSuccess({ 132 | inputEvents, 133 | inputValues, 134 | expectedEvents, 135 | expectedValues, 136 | }); 137 | }); 138 | 139 | it('catches xudClient$ error silently and retries', () => { 140 | const inputEvents = { 141 | xudClient$: '1s #', 142 | subscribeXudSwaps$: '', 143 | unsubscribe: '10s !', 144 | }; 145 | const expectedEvents = { 146 | receivedBaseAssetSwapSuccess$: '', 147 | receivedQuoteAssetSwapSuccess$: '', 148 | xudClien$subscriptions: ['^ 999ms !', '6s ^ 999ms !'], 149 | xudSwaps$subscriptions: [], 150 | }; 151 | assertOpenDEXswapSuccess({ 152 | inputEvents, 153 | expectedEvents, 154 | }); 155 | }); 156 | 157 | it('catches subscribeXudSwaps$ error silently and retries', () => { 158 | const inputEvents = { 159 | xudClient$: '1s a', 160 | subscribeXudSwaps$: '1s a', 161 | unsubscribe: '10s !', 162 | }; 163 | const expectedEvents = { 164 | receivedBaseAssetSwapSuccess$: '', 165 | receivedQuoteAssetSwapSuccess$: '', 166 | xudClien$subscriptions: ['^ 1999ms !', '7s ^ 1999ms !'], 167 | xudSwaps$subscriptions: ['1s ^ 999ms !', '8s ^ 999ms !'], 168 | }; 169 | assertOpenDEXswapSuccess({ 170 | inputEvents, 171 | expectedEvents, 172 | }); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /src/opendex/swap-success.ts: -------------------------------------------------------------------------------- 1 | import { empty, Observable, partition } from 'rxjs'; 2 | import { catchError, delay, mergeMap, share, repeat } from 'rxjs/operators'; 3 | import { Config } from '../config'; 4 | import { RETRY_INTERVAL } from '../constants'; 5 | import { XudClient } from '../proto/xudrpc_grpc_pb'; 6 | import { SwapSuccess } from '../proto/xudrpc_pb'; 7 | import { SubscribeSwapsParams } from './xud/subscribe-swaps'; 8 | 9 | type GetOpenDEXswapSuccessParams = { 10 | config: Config; 11 | getXudClient$: (config: Config) => Observable; 12 | subscribeXudSwaps$: ({ 13 | client, 14 | config, 15 | }: SubscribeSwapsParams) => Observable; 16 | }; 17 | 18 | type OpenDEXswapSuccess = { 19 | receivedBaseAssetSwapSuccess$: Observable; 20 | receivedQuoteAssetSwapSuccess$: Observable; 21 | }; 22 | 23 | const getOpenDEXswapSuccess$ = ({ 24 | config, 25 | getXudClient$, 26 | subscribeXudSwaps$, 27 | }: GetOpenDEXswapSuccessParams): OpenDEXswapSuccess => { 28 | const [ 29 | receivedBaseAssetSwapSuccess$, 30 | receivedQuoteAssetSwapSuccess$, 31 | ] = partition( 32 | getXudClient$(config).pipe( 33 | mergeMap(client => { 34 | return subscribeXudSwaps$({ client, config }); 35 | }), 36 | share() 37 | ), 38 | swapSuccess => { 39 | return swapSuccess.getCurrencyReceived() === config.BASEASSET; 40 | } 41 | ); 42 | const catchErrorAndRetry = (source: Observable) => { 43 | return source.pipe( 44 | catchError(() => { 45 | return empty().pipe(delay(RETRY_INTERVAL)); 46 | }), 47 | repeat() 48 | ); 49 | }; 50 | return { 51 | receivedBaseAssetSwapSuccess$: receivedBaseAssetSwapSuccess$.pipe( 52 | catchErrorAndRetry 53 | ), 54 | receivedQuoteAssetSwapSuccess$: receivedQuoteAssetSwapSuccess$.pipe( 55 | catchErrorAndRetry 56 | ), 57 | }; 58 | }; 59 | 60 | export { 61 | getOpenDEXswapSuccess$, 62 | GetOpenDEXswapSuccessParams, 63 | OpenDEXswapSuccess, 64 | }; 65 | -------------------------------------------------------------------------------- /src/opendex/xud/balance.spec.ts: -------------------------------------------------------------------------------- 1 | import { XudClient } from '../../proto/xudrpc_grpc_pb'; 2 | import { GetBalanceRequest } from '../../proto/xudrpc_pb'; 3 | import { getXudBalance$ } from './balance'; 4 | 5 | jest.mock('../../proto/xudrpc_grpc_pb'); 6 | jest.mock('../../proto/xudrpc_pb'); 7 | 8 | describe('getXudBalance$', () => { 9 | test('success', done => { 10 | expect.assertions(2); 11 | const expectedBalance = 'balanceResponse'; 12 | const client = ({ 13 | getBalance: (req: any, cb: any) => { 14 | cb(null, expectedBalance); 15 | }, 16 | } as unknown) as XudClient; 17 | const xudBalance$ = getXudBalance$(client); 18 | xudBalance$.subscribe(actualBalance => { 19 | expect(actualBalance).toEqual(expectedBalance); 20 | expect(GetBalanceRequest).toHaveBeenCalledTimes(1); 21 | done(); 22 | }); 23 | }); 24 | 25 | test('failure', done => { 26 | expect.assertions(1); 27 | const expectedError = 'balanceError'; 28 | const client = ({ 29 | getBalance: (req: any, cb: any) => { 30 | cb(expectedError); 31 | }, 32 | } as unknown) as XudClient; 33 | const xudBalance$ = getXudBalance$(client); 34 | xudBalance$.subscribe({ 35 | error: error => { 36 | expect(error).toEqual(expectedError); 37 | done(); 38 | }, 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/opendex/xud/balance.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { XudClient } from '../../proto/xudrpc_grpc_pb'; 3 | import { GetBalanceRequest, GetBalanceResponse } from '../../proto/xudrpc_pb'; 4 | import { processResponse } from './process-response'; 5 | 6 | const getXudBalance$ = (client: XudClient): Observable => { 7 | const request = new GetBalanceRequest(); 8 | const balance$ = new Observable(subscriber => { 9 | client.getBalance( 10 | request, 11 | processResponse({ 12 | subscriber, 13 | }) 14 | ); 15 | }); 16 | return balance$ as Observable; 17 | }; 18 | 19 | export { getXudBalance$ }; 20 | -------------------------------------------------------------------------------- /src/opendex/xud/client.spec.ts: -------------------------------------------------------------------------------- 1 | import { XudClient } from '../../proto/xudrpc_grpc_pb'; 2 | import { testConfig } from '../../test-utils'; 3 | import { errors } from '../errors'; 4 | import { getXudClient$ } from './client'; 5 | import { take } from 'rxjs/operators'; 6 | 7 | jest.mock('../../proto/xudrpc_grpc_pb'); 8 | jest.mock('../../proto/xudrpc_pb'); 9 | 10 | describe('getXudClient$', () => { 11 | test('success', done => { 12 | expect.assertions(3); 13 | const config = testConfig(); 14 | const xudClient$ = getXudClient$(config); 15 | 16 | xudClient$.pipe(take(1)).subscribe({ 17 | next: client => { 18 | expect(XudClient).toHaveBeenCalledTimes(1); 19 | expect(XudClient).toHaveBeenCalledWith( 20 | `${config.OPENDEX_RPC_HOST}:${config.OPENDEX_RPC_PORT}`, 21 | expect.any(Object), 22 | expect.any(Object) 23 | ); 24 | setImmediate(() => { 25 | expect(client.close).toHaveBeenCalledTimes(1); 26 | done(); 27 | }); 28 | }, 29 | }); 30 | }); 31 | 32 | test('error invalid cert', done => { 33 | expect.assertions(2); 34 | const invalidCertPath = '/invalid/cert/path/tls.cert'; 35 | const config = { 36 | ...testConfig(), 37 | OPENDEX_CERT_PATH: invalidCertPath, 38 | }; 39 | const xudClient$ = getXudClient$(config); 40 | xudClient$.subscribe({ 41 | error: error => { 42 | expect(XudClient).toHaveBeenCalledTimes(1); 43 | expect(error).toEqual(errors.XUD_CLIENT_INVALID_CERT(invalidCertPath)); 44 | done(); 45 | }, 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/opendex/xud/client.ts: -------------------------------------------------------------------------------- 1 | import { credentials } from '@grpc/grpc-js'; 2 | import fs from 'fs'; 3 | import { Observable, throwError } from 'rxjs'; 4 | import { catchError } from 'rxjs/operators'; 5 | import { Config } from '../../config'; 6 | import { XudClient } from '../../proto/xudrpc_grpc_pb'; 7 | import { errors } from '../errors'; 8 | 9 | const getXudClient$ = (config: Config): Observable => { 10 | const client$ = new Observable(subscriber => { 11 | const cert = fs.readFileSync(config.OPENDEX_CERT_PATH); 12 | const sslCredentials = credentials.createSsl(cert); 13 | const options = { 14 | 'grpc.ssl_target_name_override': 'localhost', 15 | 'grpc.default_authority': 'localhost', 16 | }; 17 | const client = new XudClient( 18 | `${config.OPENDEX_RPC_HOST}:${config.OPENDEX_RPC_PORT}`, 19 | sslCredentials, 20 | options 21 | ); 22 | subscriber.next(client); 23 | return () => { 24 | client.close(); 25 | }; 26 | }).pipe( 27 | catchError(error => { 28 | if (error.code === 'ENOENT') { 29 | return throwError( 30 | errors.XUD_CLIENT_INVALID_CERT(config.OPENDEX_CERT_PATH) 31 | ); 32 | } 33 | return throwError(error); 34 | }) 35 | ); 36 | return client$ as Observable; 37 | }; 38 | 39 | export { getXudClient$ }; 40 | -------------------------------------------------------------------------------- /src/opendex/xud/create-order.spec.ts: -------------------------------------------------------------------------------- 1 | import { XudClient } from '../../proto/xudrpc_grpc_pb'; 2 | import { OrderSide, PlaceOrderRequest } from '../../proto/xudrpc_pb'; 3 | import { createXudOrder$ } from './create-order'; 4 | import { getLoggers } from '../../test-utils'; 5 | import { OpenDEXorder } from '../orders'; 6 | 7 | jest.mock('../../proto/xudrpc_grpc_pb'); 8 | jest.mock('../../proto/xudrpc_pb'); 9 | 10 | const getTestXudOrderParams = (): OpenDEXorder => { 11 | return { 12 | quantity: 123, 13 | orderSide: OrderSide.BUY, 14 | pairId: 'ETHBTC', 15 | price: 0.0234, 16 | orderId: '123', 17 | }; 18 | }; 19 | 20 | describe('createXudOrder$', () => { 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | }); 24 | 25 | test('success', done => { 26 | expect.assertions(8); 27 | const expectedResponse = 'expectedResponse'; 28 | const client = ({ 29 | placeOrderSync: (_req: any, cb: any) => { 30 | cb(null, expectedResponse); 31 | }, 32 | } as unknown) as XudClient; 33 | const order = getTestXudOrderParams(); 34 | const createOrder$ = createXudOrder$({ 35 | ...{ client, logger: getLoggers().opendex }, 36 | ...order, 37 | }); 38 | createOrder$.subscribe({ 39 | next: actualResponse => { 40 | expect(actualResponse).toEqual(expectedResponse); 41 | expect(PlaceOrderRequest).toHaveBeenCalledTimes(1); 42 | expect(PlaceOrderRequest.prototype.setQuantity).toHaveBeenCalledWith( 43 | order.quantity 44 | ); 45 | expect(PlaceOrderRequest.prototype.setSide).toHaveBeenCalledWith( 46 | order.orderSide 47 | ); 48 | expect(PlaceOrderRequest.prototype.setPairId).toHaveBeenCalledWith( 49 | order.pairId 50 | ); 51 | expect(PlaceOrderRequest.prototype.setPrice).toHaveBeenCalledWith( 52 | order.price 53 | ); 54 | expect(PlaceOrderRequest.prototype.setOrderId).toHaveBeenCalledWith( 55 | order.orderId 56 | ); 57 | expect( 58 | PlaceOrderRequest.prototype.setReplaceOrderId 59 | ).not.toHaveBeenCalled(); 60 | }, 61 | complete: done, 62 | }); 63 | }); 64 | 65 | test('success replace order', done => { 66 | expect.assertions(8); 67 | const expectedResponse = 'expectedResponse'; 68 | const client = ({ 69 | placeOrderSync: (_req: any, cb: any) => { 70 | cb(null, expectedResponse); 71 | }, 72 | } as unknown) as XudClient; 73 | const order = { 74 | ...getTestXudOrderParams(), 75 | ...{ replaceOrderId: '123abc' }, 76 | }; 77 | const createOrder$ = createXudOrder$({ 78 | ...{ client, logger: getLoggers().opendex }, 79 | ...order, 80 | }); 81 | createOrder$.subscribe({ 82 | next: actualResponse => { 83 | expect(actualResponse).toEqual(expectedResponse); 84 | expect(PlaceOrderRequest).toHaveBeenCalledTimes(1); 85 | expect(PlaceOrderRequest.prototype.setQuantity).toHaveBeenCalledWith( 86 | order.quantity 87 | ); 88 | expect(PlaceOrderRequest.prototype.setSide).toHaveBeenCalledWith( 89 | order.orderSide 90 | ); 91 | expect(PlaceOrderRequest.prototype.setPairId).toHaveBeenCalledWith( 92 | order.pairId 93 | ); 94 | expect(PlaceOrderRequest.prototype.setPrice).toHaveBeenCalledWith( 95 | order.price 96 | ); 97 | expect(PlaceOrderRequest.prototype.setOrderId).toHaveBeenCalledWith( 98 | order.orderId 99 | ); 100 | expect( 101 | PlaceOrderRequest.prototype.setReplaceOrderId 102 | ).toHaveBeenCalledWith(order.replaceOrderId); 103 | }, 104 | complete: done, 105 | }); 106 | }); 107 | 108 | test('0 quantity will not place an order', done => { 109 | expect.assertions(2); 110 | const client = (null as unknown) as XudClient; 111 | const order = { 112 | ...getTestXudOrderParams(), 113 | quantity: 0, 114 | }; 115 | const createOrder$ = createXudOrder$({ 116 | ...{ client, logger: getLoggers().opendex }, 117 | ...order, 118 | }); 119 | createOrder$.subscribe({ 120 | next: actualResponse => { 121 | expect(actualResponse).toEqual(null); 122 | expect(PlaceOrderRequest).toHaveBeenCalledTimes(0); 123 | }, 124 | complete: done, 125 | }); 126 | }); 127 | 128 | test('failure', done => { 129 | expect.assertions(1); 130 | const expectedError = 'expectedError'; 131 | const client = ({ 132 | placeOrderSync: (req: any, cb: any) => { 133 | cb(expectedError); 134 | }, 135 | } as unknown) as XudClient; 136 | const createOrder$ = createXudOrder$({ 137 | ...{ client, logger: getLoggers().opendex }, 138 | ...getTestXudOrderParams(), 139 | }); 140 | createOrder$.subscribe({ 141 | error: error => { 142 | expect(error).toEqual(expectedError); 143 | done(); 144 | }, 145 | }); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /src/opendex/xud/create-order.ts: -------------------------------------------------------------------------------- 1 | import { Observable, of } from 'rxjs'; 2 | import { take, tap } from 'rxjs/operators'; 3 | import { Logger } from '../../logger'; 4 | import { XudClient } from '../../proto/xudrpc_grpc_pb'; 5 | import { PlaceOrderRequest, PlaceOrderResponse } from '../../proto/xudrpc_pb'; 6 | import { satsToCoinsStr } from '../../utils'; 7 | import { OpenDEXorder } from '../orders'; 8 | import { processResponse } from './process-response'; 9 | 10 | type CreateXudOrderParams = OpenDEXorder & { 11 | logger: Logger; 12 | client: XudClient; 13 | }; 14 | 15 | const orderSideMapping = { 16 | 0: 'buy', 17 | 1: 'sell', 18 | 2: 'both', 19 | }; 20 | 21 | const createXudOrder$ = ({ 22 | client, 23 | logger, 24 | quantity, 25 | orderSide, 26 | pairId, 27 | price, 28 | orderId, 29 | replaceOrderId, 30 | }: CreateXudOrderParams): Observable => { 31 | if (quantity > 0) { 32 | const CREATING_OR_REPLACING = replaceOrderId ? 'Replacing' : 'Creating'; 33 | logger.trace( 34 | `${CREATING_OR_REPLACING} ${pairId} ${ 35 | orderSideMapping[orderSide] 36 | } order with id ${orderId}, quantity ${satsToCoinsStr( 37 | quantity 38 | )} and price ${price}` 39 | ); 40 | const request = new PlaceOrderRequest(); 41 | request.setQuantity(quantity); 42 | request.setSide(orderSide); 43 | request.setPairId(pairId); 44 | request.setPrice(price); 45 | request.setOrderId(orderId); 46 | if (replaceOrderId) { 47 | request.setReplaceOrderId(replaceOrderId); 48 | } 49 | const createXudOrder$ = new Observable(subscriber => { 50 | client.placeOrderSync( 51 | request, 52 | processResponse({ 53 | subscriber, 54 | }) 55 | ); 56 | }).pipe( 57 | tap(() => { 58 | logger.trace(`Order ${orderId} created`); 59 | }), 60 | take(1) 61 | ); 62 | return createXudOrder$ as Observable; 63 | } else { 64 | logger.trace( 65 | `Did not create ${orderSideMapping[orderSide]} order because calculated quantity was 0` 66 | ); 67 | return of((null as unknown) as PlaceOrderResponse); 68 | } 69 | }; 70 | 71 | export { createXudOrder$, CreateXudOrderParams }; 72 | -------------------------------------------------------------------------------- /src/opendex/xud/list-orders.spec.ts: -------------------------------------------------------------------------------- 1 | import { XudClient } from '../../proto/xudrpc_grpc_pb'; 2 | import { ListOrdersRequest } from '../../proto/xudrpc_pb'; 3 | import { listXudOrders$ } from './list-orders'; 4 | 5 | jest.mock('../../proto/xudrpc_grpc_pb'); 6 | jest.mock('../../proto/xudrpc_pb'); 7 | 8 | describe('listXudOrders$', () => { 9 | test('success', done => { 10 | expect.assertions(3); 11 | const expectedResponse = 'expectedResponse'; 12 | const client = ({ 13 | listOrders: (req: any, cb: any) => { 14 | cb(null, expectedResponse); 15 | }, 16 | } as unknown) as XudClient; 17 | const listOrders$ = listXudOrders$(client); 18 | listOrders$.subscribe(actualResponse => { 19 | expect(actualResponse.client).toEqual(expect.any(Object)); 20 | expect(actualResponse.orders).toEqual(expectedResponse); 21 | expect(ListOrdersRequest).toHaveBeenCalledTimes(1); 22 | done(); 23 | }); 24 | }); 25 | 26 | test('failure', done => { 27 | expect.assertions(1); 28 | const expectedError = 'expectedError'; 29 | const client = ({ 30 | listOrders: (req: any, cb: any) => { 31 | cb(expectedError); 32 | }, 33 | } as unknown) as XudClient; 34 | const listOrders$ = listXudOrders$(client); 35 | listOrders$.subscribe({ 36 | error: error => { 37 | expect(error).toEqual(expectedError); 38 | done(); 39 | }, 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/opendex/xud/list-orders.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { XudClient } from '../../proto/xudrpc_grpc_pb'; 3 | import { ListOrdersRequest, ListOrdersResponse } from '../../proto/xudrpc_pb'; 4 | import { processResponse } from './process-response'; 5 | import { map } from 'rxjs/operators'; 6 | 7 | type ListXudOrdersResponse = { 8 | client: XudClient; 9 | orders: ListOrdersResponse; 10 | }; 11 | 12 | const listXudOrders$ = ( 13 | client: XudClient 14 | ): Observable => { 15 | const request = new ListOrdersRequest(); 16 | const xudOrders$ = new Observable(subscriber => { 17 | client.listOrders( 18 | request, 19 | processResponse({ 20 | subscriber, 21 | }) 22 | ); 23 | }); 24 | const orders$ = xudOrders$ as Observable; 25 | return orders$.pipe( 26 | map(orders => { 27 | return { 28 | client, 29 | orders, 30 | }; 31 | }) 32 | ); 33 | }; 34 | 35 | export { listXudOrders$, ListXudOrdersResponse }; 36 | -------------------------------------------------------------------------------- /src/opendex/xud/process-response.spec.ts: -------------------------------------------------------------------------------- 1 | import { ServiceError } from '@grpc/grpc-js'; 2 | import { Observable } from 'rxjs'; 3 | import { processResponse } from './process-response'; 4 | 5 | jest.mock('../../proto/xudrpc_grpc_pb'); 6 | jest.mock('../../proto/xudrpc_pb'); 7 | 8 | describe('processResponse', () => { 9 | test('success', done => { 10 | expect.assertions(1); 11 | const nextValue = 'next'; 12 | const source$ = new Observable(subscriber => { 13 | processResponse({ 14 | subscriber, 15 | })(null, nextValue); 16 | }); 17 | source$.subscribe({ 18 | next: actualNextValue => { 19 | expect(actualNextValue).toEqual(nextValue); 20 | done(); 21 | }, 22 | }); 23 | }); 24 | 25 | test('error', done => { 26 | expect.assertions(1); 27 | const errorValue = ('errorValue' as unknown) as ServiceError; 28 | const source$ = new Observable(subscriber => { 29 | processResponse({ 30 | subscriber, 31 | })(errorValue, null); 32 | }); 33 | source$.subscribe({ 34 | error: errorMsg => { 35 | expect(errorMsg).toEqual(errorValue); 36 | done(); 37 | }, 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/opendex/xud/process-response.ts: -------------------------------------------------------------------------------- 1 | import { ServiceError } from '@grpc/grpc-js'; 2 | import { Subscriber } from 'rxjs'; 3 | 4 | type ProcessResponseParams = { 5 | subscriber: Subscriber; 6 | }; 7 | 8 | const processResponse = ({ subscriber }: ProcessResponseParams) => { 9 | return (error: ServiceError | null, response: any) => { 10 | if (error) { 11 | subscriber.error(error); 12 | } else { 13 | subscriber.next(response); 14 | } 15 | }; 16 | }; 17 | 18 | export { processResponse }; 19 | -------------------------------------------------------------------------------- /src/opendex/xud/remove-order.spec.ts: -------------------------------------------------------------------------------- 1 | import { XudClient } from '../../proto/xudrpc_grpc_pb'; 2 | import { RemoveOrderRequest } from '../../proto/xudrpc_pb'; 3 | import { removeXudOrder$ } from './remove-order'; 4 | 5 | jest.mock('../../proto/xudrpc_grpc_pb'); 6 | jest.mock('../../proto/xudrpc_pb'); 7 | 8 | describe('removeXudOrder$', () => { 9 | test('success', done => { 10 | expect.assertions(3); 11 | const expectedResponse = 'expectedResponse'; 12 | const client = ({ 13 | removeOrder: (req: any, cb: any) => { 14 | cb(null, expectedResponse); 15 | }, 16 | } as unknown) as XudClient; 17 | const orderId = '123'; 18 | const removeOrder$ = removeXudOrder$({ 19 | ...{ client }, 20 | ...{ orderId }, 21 | }); 22 | removeOrder$.subscribe({ 23 | next: actualResponse => { 24 | expect(actualResponse).toEqual(expectedResponse); 25 | expect(RemoveOrderRequest).toHaveBeenCalledTimes(1); 26 | expect(RemoveOrderRequest.prototype.setOrderId).toHaveBeenCalledWith( 27 | orderId 28 | ); 29 | }, 30 | complete: done, 31 | }); 32 | }); 33 | 34 | test('failure', done => { 35 | expect.assertions(1); 36 | const expectedError = 'expectedError'; 37 | const client = ({ 38 | removeOrder: (req: any, cb: any) => { 39 | cb(expectedError); 40 | }, 41 | } as unknown) as XudClient; 42 | const orderId = '321'; 43 | const removeOrder$ = removeXudOrder$({ 44 | ...{ client }, 45 | ...{ orderId }, 46 | }); 47 | removeOrder$.subscribe({ 48 | error: error => { 49 | expect(error).toEqual(expectedError); 50 | done(); 51 | }, 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/opendex/xud/remove-order.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { take } from 'rxjs/operators'; 3 | import { XudClient } from '../../proto/xudrpc_grpc_pb'; 4 | import { RemoveOrderRequest, RemoveOrderResponse } from '../../proto/xudrpc_pb'; 5 | import { processResponse } from './process-response'; 6 | 7 | type RemoveXudOrderParams = { 8 | client: XudClient; 9 | orderId: string; 10 | }; 11 | 12 | const removeXudOrder$ = ({ 13 | client, 14 | orderId, 15 | }: RemoveXudOrderParams): Observable => { 16 | const request = new RemoveOrderRequest(); 17 | request.setOrderId(orderId); 18 | const removeXudOrder$ = new Observable(subscriber => { 19 | client.removeOrder( 20 | request, 21 | processResponse({ 22 | subscriber, 23 | }) 24 | ); 25 | }).pipe(take(1)); 26 | return removeXudOrder$ as Observable; 27 | }; 28 | 29 | export { removeXudOrder$, RemoveXudOrderParams }; 30 | -------------------------------------------------------------------------------- /src/opendex/xud/subscribe-swaps.spec.ts: -------------------------------------------------------------------------------- 1 | import { status } from '@grpc/grpc-js'; 2 | import { EventEmitter } from 'events'; 3 | import { Config } from '../../config'; 4 | import { XudClient } from '../../proto/xudrpc_grpc_pb'; 5 | import { SubscribeSwapsRequest } from '../../proto/xudrpc_pb'; 6 | import { testConfig } from '../../test-utils'; 7 | import { subscribeXudSwaps$ } from './subscribe-swaps'; 8 | 9 | jest.mock('../../proto/xudrpc_grpc_pb'); 10 | jest.mock('../../proto/xudrpc_pb'); 11 | 12 | const CANCELLED_ERROR = { 13 | code: status.CANCELLED, 14 | message: 'Cancelled on client', 15 | }; 16 | 17 | class MockSwapSubscription extends EventEmitter { 18 | cancel = () => { 19 | this.emit('error', CANCELLED_ERROR); 20 | }; 21 | } 22 | 23 | describe('subscribeXudSwaps$', () => { 24 | let client: XudClient; 25 | let config: Config; 26 | let swapSuccess: any; 27 | let mockSwapSubscription: MockSwapSubscription; 28 | let onSwapSubscriptionSpy: any; 29 | let offSwapSubscriptionSpy: any; 30 | let cancelSwapSubscriptionSpy: any; 31 | 32 | beforeEach(() => { 33 | config = testConfig(); 34 | mockSwapSubscription = new MockSwapSubscription(); 35 | cancelSwapSubscriptionSpy = jest.spyOn(mockSwapSubscription, 'cancel'); 36 | client = ({ 37 | subscribeSwaps: () => mockSwapSubscription, 38 | } as unknown) as XudClient; 39 | onSwapSubscriptionSpy = jest.spyOn(mockSwapSubscription, 'on'); 40 | offSwapSubscriptionSpy = jest.spyOn(mockSwapSubscription, 'off'); 41 | const { BASEASSET, QUOTEASSET } = config; 42 | swapSuccess = { 43 | getPairId: () => `${BASEASSET}/${QUOTEASSET}`, 44 | }; 45 | }); 46 | 47 | test('success', done => { 48 | expect.assertions(8); 49 | const swapsSubscription$ = subscribeXudSwaps$({ 50 | client, 51 | config, 52 | }); 53 | swapsSubscription$.subscribe({ 54 | next: actualSuccessValue => { 55 | expect(actualSuccessValue).toEqual(swapSuccess); 56 | }, 57 | }); 58 | expect(SubscribeSwapsRequest).toHaveBeenCalledTimes(1); 59 | expect( 60 | SubscribeSwapsRequest.prototype.setIncludeTaker 61 | ).toHaveBeenCalledTimes(1); 62 | expect( 63 | SubscribeSwapsRequest.prototype.setIncludeTaker 64 | ).toHaveBeenCalledWith(true); 65 | expect(onSwapSubscriptionSpy).toHaveBeenCalledTimes(3); 66 | expect(onSwapSubscriptionSpy).toHaveBeenCalledWith( 67 | 'data', 68 | expect.any(Function) 69 | ); 70 | mockSwapSubscription.emit('data', swapSuccess); 71 | const swapSuccessOtherTradingPair = { 72 | getPairId: () => 'TOK/BTC', 73 | }; 74 | mockSwapSubscription.emit('data', swapSuccessOtherTradingPair); 75 | mockSwapSubscription.emit('end'); 76 | expect(offSwapSubscriptionSpy).toHaveBeenCalledTimes(3); 77 | expect(cancelSwapSubscriptionSpy).toHaveBeenCalledTimes(1); 78 | done(); 79 | }); 80 | 81 | test('will emit error', done => { 82 | expect.assertions(1); 83 | const swapError = 'swapError HELLO'; 84 | const swapsSubscription$ = subscribeXudSwaps$({ 85 | client, 86 | config, 87 | }); 88 | swapsSubscription$.subscribe({ 89 | error: actualError => { 90 | expect(actualError).toEqual(swapError); 91 | done(); 92 | }, 93 | }); 94 | mockSwapSubscription.emit('error', swapError); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/opendex/xud/subscribe-swaps.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { Config } from '../../config'; 3 | import { XudClient } from '../../proto/xudrpc_grpc_pb'; 4 | import { SubscribeSwapsRequest, SwapSuccess } from '../../proto/xudrpc_pb'; 5 | import { ServiceError } from '@grpc/grpc-js'; 6 | 7 | type SubscribeSwapsParams = { 8 | client: XudClient; 9 | config: Config; 10 | }; 11 | 12 | const subscribeXudSwaps$ = ({ 13 | client, 14 | config, 15 | }: SubscribeSwapsParams): Observable => { 16 | const request = new SubscribeSwapsRequest(); 17 | request.setIncludeTaker(true); 18 | const subscribeSwaps$ = new Observable(subscriber => { 19 | const swapsSubscription = client.subscribeSwaps(request); 20 | const { BASEASSET, QUOTEASSET } = config; 21 | const pairIdToMonitor = `${BASEASSET}/${QUOTEASSET}`; 22 | const onData = (swapSuccess: SwapSuccess) => { 23 | if (pairIdToMonitor === swapSuccess.getPairId()) { 24 | subscriber.next(swapSuccess); 25 | } 26 | }; 27 | swapsSubscription.on('data', onData); 28 | const onError = (error: ServiceError) => { 29 | subscriber.error(error); 30 | }; 31 | swapsSubscription.on('error', onError); 32 | const onEnd = () => { 33 | subscriber.complete(); 34 | }; 35 | swapsSubscription.on('end', onEnd); 36 | return () => { 37 | // ignore the error that cancel() will emit 38 | swapsSubscription.on('error', () => {}); 39 | swapsSubscription.cancel(); 40 | swapsSubscription.off('error', onError); 41 | swapsSubscription.off('end', onEnd); 42 | swapsSubscription.off('data', onData); 43 | }; 44 | }); 45 | return subscribeSwaps$ as Observable; 46 | }; 47 | 48 | export { subscribeXudSwaps$, SubscribeSwapsParams }; 49 | -------------------------------------------------------------------------------- /src/opendex/xud/trading-limits.spec.ts: -------------------------------------------------------------------------------- 1 | import { XudClient } from '../../proto/xudrpc_grpc_pb'; 2 | import { TradingLimitsRequest } from '../../proto/xudrpc_pb'; 3 | import { getXudTradingLimits$ } from './trading-limits'; 4 | 5 | jest.mock('../../proto/xudrpc_grpc_pb'); 6 | jest.mock('../../proto/xudrpc_pb'); 7 | 8 | describe('getXudTradingLimits$', () => { 9 | test('success', done => { 10 | expect.assertions(2); 11 | const expectedTradingLimits = 'tradingLimitsResponse'; 12 | const client = ({ 13 | tradingLimits: (req: any, cb: any) => { 14 | cb(null, expectedTradingLimits); 15 | }, 16 | } as unknown) as XudClient; 17 | const tradingLimits$ = getXudTradingLimits$(client); 18 | tradingLimits$.subscribe(actualLimits => { 19 | expect(actualLimits).toEqual(expectedTradingLimits); 20 | expect(TradingLimitsRequest).toHaveBeenCalledTimes(1); 21 | done(); 22 | }); 23 | }); 24 | 25 | test('failure', done => { 26 | expect.assertions(1); 27 | const expectedError = 'tradingLimitsError'; 28 | const client = ({ 29 | tradingLimits: (req: any, cb: any) => { 30 | cb(expectedError); 31 | }, 32 | } as unknown) as XudClient; 33 | const tradingLimits$ = getXudTradingLimits$(client); 34 | tradingLimits$.subscribe({ 35 | error: error => { 36 | expect(error).toEqual(expectedError); 37 | done(); 38 | }, 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/opendex/xud/trading-limits.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { XudClient } from '../../proto/xudrpc_grpc_pb'; 3 | import { 4 | TradingLimitsRequest, 5 | TradingLimitsResponse, 6 | } from '../../proto/xudrpc_pb'; 7 | import { processResponse } from './process-response'; 8 | 9 | const getXudTradingLimits$ = ( 10 | client: XudClient 11 | ): Observable => { 12 | const request = new TradingLimitsRequest(); 13 | const tradingLimits$ = new Observable(subscriber => { 14 | client.tradingLimits( 15 | request, 16 | processResponse({ 17 | subscriber, 18 | }) 19 | ); 20 | }); 21 | return tradingLimits$ as Observable; 22 | }; 23 | 24 | export { getXudTradingLimits$ }; 25 | -------------------------------------------------------------------------------- /src/proto/annotations_grpc_pb.js: -------------------------------------------------------------------------------- 1 | // GENERATED CODE -- NO SERVICES IN PROTO -------------------------------------------------------------------------------- /src/proto/annotations_pb.d.ts: -------------------------------------------------------------------------------- 1 | // package: google.api 2 | // file: annotations.proto 3 | 4 | /* tslint:disable */ 5 | 6 | import * as jspb from "google-protobuf"; 7 | import * as google_api_http_pb from "./google/api/http_pb"; 8 | import * as google_protobuf_descriptor_pb from "google-protobuf/google/protobuf/descriptor_pb"; 9 | 10 | export const http: jspb.ExtensionFieldInfo; 11 | -------------------------------------------------------------------------------- /src/proto/annotations_pb.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview 3 | * @enhanceable 4 | * @suppress {messageConventions} JS Compiler reports an error if a variable or 5 | * field starts with 'MSG_' and isn't a translatable message. 6 | * @public 7 | */ 8 | // GENERATED CODE -- DO NOT EDIT! 9 | 10 | var jspb = require('google-protobuf'); 11 | var goog = jspb; 12 | var global = Function('return this')(); 13 | 14 | var google_api_http_pb = require('./google/api/http_pb.js'); 15 | goog.object.extend(proto, google_api_http_pb); 16 | var google_protobuf_descriptor_pb = require('google-protobuf/google/protobuf/descriptor_pb.js'); 17 | goog.object.extend(proto, google_protobuf_descriptor_pb); 18 | goog.exportSymbol('proto.google.api.http', null, global); 19 | 20 | /** 21 | * A tuple of {field number, class constructor} for the extension 22 | * field named `http`. 23 | * @type {!jspb.ExtensionFieldInfo} 24 | */ 25 | proto.google.api.http = new jspb.ExtensionFieldInfo( 26 | 72295728, 27 | {http: 0}, 28 | google_api_http_pb.HttpRule, 29 | /** @type {?function((boolean|undefined),!jspb.Message=): !Object} */ ( 30 | google_api_http_pb.HttpRule.toObject), 31 | 0); 32 | 33 | google_protobuf_descriptor_pb.MethodOptions.extensionsBinary[72295728] = new jspb.ExtensionFieldBinaryInfo( 34 | proto.google.api.http, 35 | jspb.BinaryReader.prototype.readMessage, 36 | jspb.BinaryWriter.prototype.writeMessage, 37 | google_api_http_pb.HttpRule.serializeBinaryToWriter, 38 | google_api_http_pb.HttpRule.deserializeBinaryFromReader, 39 | false); 40 | // This registers the extension field with the extended class, so that 41 | // toObject() will function correctly. 42 | google_protobuf_descriptor_pb.MethodOptions.extensions[72295728] = proto.google.api.http; 43 | 44 | goog.object.extend(exports, proto.google.api); 45 | -------------------------------------------------------------------------------- /src/proto/google/api/http_grpc_pb.js: -------------------------------------------------------------------------------- 1 | // GENERATED CODE -- NO SERVICES IN PROTO -------------------------------------------------------------------------------- /src/proto/google/api/http_pb.d.ts: -------------------------------------------------------------------------------- 1 | // package: google.api 2 | // file: google/api/http.proto 3 | 4 | /* tslint:disable */ 5 | 6 | import * as jspb from "google-protobuf"; 7 | 8 | export class Http extends jspb.Message { 9 | clearRulesList(): void; 10 | getRulesList(): Array; 11 | setRulesList(value: Array): void; 12 | addRules(value?: HttpRule, index?: number): HttpRule; 13 | 14 | 15 | serializeBinary(): Uint8Array; 16 | toObject(includeInstance?: boolean): Http.AsObject; 17 | static toObject(includeInstance: boolean, msg: Http): Http.AsObject; 18 | static extensions: {[key: number]: jspb.ExtensionFieldInfo}; 19 | static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; 20 | static serializeBinaryToWriter(message: Http, writer: jspb.BinaryWriter): void; 21 | static deserializeBinary(bytes: Uint8Array): Http; 22 | static deserializeBinaryFromReader(message: Http, reader: jspb.BinaryReader): Http; 23 | } 24 | 25 | export namespace Http { 26 | export type AsObject = { 27 | rulesList: Array, 28 | } 29 | } 30 | 31 | export class HttpRule extends jspb.Message { 32 | getSelector(): string; 33 | setSelector(value: string): void; 34 | 35 | 36 | hasGet(): boolean; 37 | clearGet(): void; 38 | getGet(): string; 39 | setGet(value: string): void; 40 | 41 | 42 | hasPut(): boolean; 43 | clearPut(): void; 44 | getPut(): string; 45 | setPut(value: string): void; 46 | 47 | 48 | hasPost(): boolean; 49 | clearPost(): void; 50 | getPost(): string; 51 | setPost(value: string): void; 52 | 53 | 54 | hasDelete(): boolean; 55 | clearDelete(): void; 56 | getDelete(): string; 57 | setDelete(value: string): void; 58 | 59 | 60 | hasPatch(): boolean; 61 | clearPatch(): void; 62 | getPatch(): string; 63 | setPatch(value: string): void; 64 | 65 | 66 | hasCustom(): boolean; 67 | clearCustom(): void; 68 | getCustom(): CustomHttpPattern | undefined; 69 | setCustom(value?: CustomHttpPattern): void; 70 | 71 | getBody(): string; 72 | setBody(value: string): void; 73 | 74 | clearAdditionalBindingsList(): void; 75 | getAdditionalBindingsList(): Array; 76 | setAdditionalBindingsList(value: Array): void; 77 | addAdditionalBindings(value?: HttpRule, index?: number): HttpRule; 78 | 79 | 80 | getPatternCase(): HttpRule.PatternCase; 81 | 82 | serializeBinary(): Uint8Array; 83 | toObject(includeInstance?: boolean): HttpRule.AsObject; 84 | static toObject(includeInstance: boolean, msg: HttpRule): HttpRule.AsObject; 85 | static extensions: {[key: number]: jspb.ExtensionFieldInfo}; 86 | static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; 87 | static serializeBinaryToWriter(message: HttpRule, writer: jspb.BinaryWriter): void; 88 | static deserializeBinary(bytes: Uint8Array): HttpRule; 89 | static deserializeBinaryFromReader(message: HttpRule, reader: jspb.BinaryReader): HttpRule; 90 | } 91 | 92 | export namespace HttpRule { 93 | export type AsObject = { 94 | selector: string, 95 | get: string, 96 | put: string, 97 | post: string, 98 | pb_delete: string, 99 | patch: string, 100 | custom?: CustomHttpPattern.AsObject, 101 | body: string, 102 | additionalBindingsList: Array, 103 | } 104 | 105 | export enum PatternCase { 106 | PATTERN_NOT_SET = 0, 107 | 108 | GET = 2, 109 | 110 | PUT = 3, 111 | 112 | POST = 4, 113 | 114 | DELETE = 5, 115 | 116 | PATCH = 6, 117 | 118 | CUSTOM = 8, 119 | 120 | } 121 | 122 | } 123 | 124 | export class CustomHttpPattern extends jspb.Message { 125 | getKind(): string; 126 | setKind(value: string): void; 127 | 128 | getPath(): string; 129 | setPath(value: string): void; 130 | 131 | 132 | serializeBinary(): Uint8Array; 133 | toObject(includeInstance?: boolean): CustomHttpPattern.AsObject; 134 | static toObject(includeInstance: boolean, msg: CustomHttpPattern): CustomHttpPattern.AsObject; 135 | static extensions: {[key: number]: jspb.ExtensionFieldInfo}; 136 | static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; 137 | static serializeBinaryToWriter(message: CustomHttpPattern, writer: jspb.BinaryWriter): void; 138 | static deserializeBinary(bytes: Uint8Array): CustomHttpPattern; 139 | static deserializeBinaryFromReader(message: CustomHttpPattern, reader: jspb.BinaryReader): CustomHttpPattern; 140 | } 141 | 142 | export namespace CustomHttpPattern { 143 | export type AsObject = { 144 | kind: string, 145 | path: string, 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/proto/google/protobuf/descriptor_grpc_pb.js: -------------------------------------------------------------------------------- 1 | // GENERATED CODE -- NO SERVICES IN PROTO -------------------------------------------------------------------------------- /src/store.spec.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { getArbyStore } from './store'; 3 | 4 | describe('ArbyStore', () => { 5 | it('selectState returns 0 as initial lastSellOrderUpdatePrice', done => { 6 | const { selectState } = getArbyStore(); 7 | selectState('lastSellOrderUpdatePrice').subscribe(price => { 8 | expect(price).toEqual(new BigNumber('0')); 9 | done(); 10 | }); 11 | }); 12 | 13 | it('selectState returns 0 as initial lastBuyOrderUpdatePrice', done => { 14 | const { selectState } = getArbyStore(); 15 | selectState('lastBuyOrderUpdatePrice').subscribe(price => { 16 | expect(price).toEqual(new BigNumber('0')); 17 | done(); 18 | }); 19 | }); 20 | 21 | it('selectState returns updated last sell order price', done => { 22 | const { selectState, updateLastSellOrderUpdatePrice } = getArbyStore(); 23 | const updatedPrice = new BigNumber('123'); 24 | updateLastSellOrderUpdatePrice(updatedPrice); 25 | selectState('lastSellOrderUpdatePrice').subscribe(price => { 26 | expect(price).toEqual(updatedPrice); 27 | done(); 28 | }); 29 | }); 30 | 31 | it('selectState returns updated last buy order price', done => { 32 | const { selectState, updateLastBuyOrderUpdatePrice } = getArbyStore(); 33 | const updatedPrice = new BigNumber('123'); 34 | updateLastBuyOrderUpdatePrice(updatedPrice); 35 | selectState('lastBuyOrderUpdatePrice').subscribe(price => { 36 | expect(price).toEqual(updatedPrice); 37 | done(); 38 | }); 39 | }); 40 | 41 | it('reset lastOrderUpdatePrice', done => { 42 | const { 43 | stateChanges, 44 | updateLastSellOrderUpdatePrice, 45 | updateLastBuyOrderUpdatePrice, 46 | resetLastOrderUpdatePrice, 47 | } = getArbyStore(); 48 | const updatedPrice = new BigNumber('123'); 49 | updateLastSellOrderUpdatePrice(updatedPrice); 50 | updateLastBuyOrderUpdatePrice(updatedPrice); 51 | resetLastOrderUpdatePrice(); 52 | stateChanges().subscribe(state => { 53 | expect(state.lastSellOrderUpdatePrice).toEqual(new BigNumber('0')); 54 | expect(state.lastBuyOrderUpdatePrice).toEqual(new BigNumber('0')); 55 | done(); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { BehaviorSubject, Subject, Observable } from 'rxjs'; 3 | import { scan, pluck, distinctUntilKeyChanged } from 'rxjs/operators'; 4 | 5 | type ArbyStore = { 6 | updateLastSellOrderUpdatePrice: (price: BigNumber) => void; 7 | updateLastBuyOrderUpdatePrice: (price: BigNumber) => void; 8 | resetLastOrderUpdatePrice: () => void; 9 | selectState: (stateKey: ArbyStoreDataKeys) => Observable; 10 | stateChanges: () => Observable; 11 | }; 12 | 13 | type ArbyStoreData = { 14 | lastSellOrderUpdatePrice: BigNumber; 15 | lastBuyOrderUpdatePrice: BigNumber; 16 | }; 17 | 18 | type ArbyStoreDataKeys = keyof ArbyStoreData; 19 | 20 | const getArbyStore = (): ArbyStore => { 21 | const initialState: ArbyStoreData = { 22 | lastSellOrderUpdatePrice: new BigNumber('0'), 23 | lastBuyOrderUpdatePrice: new BigNumber('0'), 24 | }; 25 | const store = new BehaviorSubject(initialState); 26 | const stateUpdates = new Subject() as Subject>; 27 | stateUpdates 28 | .pipe( 29 | scan((acc, curr) => { 30 | return { ...acc, ...curr }; 31 | }, initialState) 32 | ) 33 | .subscribe(store); 34 | const updateLastSellOrderUpdatePrice = (price: BigNumber) => { 35 | stateUpdates.next({ 36 | lastSellOrderUpdatePrice: price, 37 | }); 38 | }; 39 | const updateLastBuyOrderUpdatePrice = (price: BigNumber) => { 40 | stateUpdates.next({ 41 | lastBuyOrderUpdatePrice: price, 42 | }); 43 | }; 44 | const resetLastOrderUpdatePrice = () => { 45 | stateUpdates.next({ 46 | lastSellOrderUpdatePrice: new BigNumber('0'), 47 | lastBuyOrderUpdatePrice: new BigNumber('0'), 48 | }); 49 | }; 50 | const selectState = (stateKey: ArbyStoreDataKeys) => { 51 | return store.pipe(distinctUntilKeyChanged(stateKey), pluck(stateKey)); 52 | }; 53 | const stateChanges = () => { 54 | return store.asObservable(); 55 | }; 56 | return { 57 | updateLastSellOrderUpdatePrice, 58 | updateLastBuyOrderUpdatePrice, 59 | selectState, 60 | resetLastOrderUpdatePrice, 61 | stateChanges, 62 | }; 63 | }; 64 | 65 | export { getArbyStore, ArbyStore }; 66 | -------------------------------------------------------------------------------- /src/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { Config } from './config'; 2 | import { Level, Loggers } from './logger'; 3 | 4 | const testConfig = (): Config => { 5 | return { 6 | LOG_LEVEL: Level.Trace, 7 | CEX: 'Binance', 8 | CEX_API_KEY: '123', 9 | CEX_API_SECRET: 'abc', 10 | DATA_DIR: '', 11 | OPENDEX_CERT_PATH: `${__dirname}/../mock-data/tls.cert`, 12 | OPENDEX_RPC_HOST: 'localhost', 13 | OPENDEX_RPC_PORT: '1234', 14 | MARGIN: '0.06', 15 | BASEASSET: 'ETH', 16 | QUOTEASSET: 'BTC', 17 | CEX_BASEASSET: 'ETH', 18 | CEX_QUOTEASSET: 'BTC', 19 | TEST_CENTRALIZED_EXCHANGE_BASEASSET_BALANCE: '321', 20 | TEST_CENTRALIZED_EXCHANGE_QUOTEASSET_BALANCE: '123', 21 | TEST_MODE: true, 22 | }; 23 | }; 24 | 25 | const getLoggers = (): Loggers => { 26 | const mockLogger = { 27 | warn: () => {}, 28 | info: () => {}, 29 | verbose: () => {}, 30 | debug: () => {}, 31 | trace: () => {}, 32 | error: () => {}, 33 | }; 34 | return ({ 35 | global: mockLogger, 36 | centralized: mockLogger, 37 | opendex: mockLogger, 38 | } as unknown) as Loggers; 39 | }; 40 | 41 | type TestError = { 42 | code: string | number; 43 | message: string; 44 | }; 45 | 46 | export { getLoggers, testConfig, TestError }; 47 | -------------------------------------------------------------------------------- /src/trade/accumulate-fills.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Observable } from 'rxjs'; 3 | import { scan } from 'rxjs/operators'; 4 | import { Config } from '../config'; 5 | import { SwapSuccess } from '../proto/xudrpc_pb'; 6 | import { satsToCoinsStr } from '../utils'; 7 | 8 | const accumulateOrderFillsForBaseAssetReceived = (config: Config) => { 9 | return (source: Observable) => { 10 | const SEED_VALUE = new BigNumber('0'); 11 | return source.pipe( 12 | scan((acc: BigNumber, curr: SwapSuccess) => { 13 | const PROFIT_ASSET = 'BTC'; 14 | if (config.BASEASSET === PROFIT_ASSET) { 15 | // accumulate quote asset sent when profit asset is the base asset 16 | const quantitySent = new BigNumber( 17 | satsToCoinsStr(curr.getAmountSent()) 18 | ); 19 | return acc.plus(quantitySent); 20 | } else { 21 | // accumulate base asset received when profit asset is not the base asset 22 | const quantityReceived = new BigNumber( 23 | satsToCoinsStr(curr.getAmountReceived()) 24 | ); 25 | return acc.plus(quantityReceived); 26 | } 27 | }, SEED_VALUE) 28 | ); 29 | }; 30 | }; 31 | 32 | const accumulateOrderFillsForQuoteAssetReceived = (config: Config) => { 33 | return (source: Observable) => { 34 | const SEED_VALUE = new BigNumber('0'); 35 | return source.pipe( 36 | scan((acc: BigNumber, curr: SwapSuccess) => { 37 | const PROFIT_ASSET = 'BTC'; 38 | if (config.BASEASSET === PROFIT_ASSET) { 39 | // accumulate quote asset received when profit asset is the base asset 40 | const quantityReceived = new BigNumber( 41 | satsToCoinsStr(curr.getAmountReceived()) 42 | ); 43 | return acc.plus(quantityReceived); 44 | } else { 45 | // accumulate base asset sent when profit asset is not base asset 46 | const quantitySent = new BigNumber( 47 | satsToCoinsStr(curr.getAmountSent()) 48 | ); 49 | return acc.plus(quantitySent); 50 | } 51 | }, SEED_VALUE) 52 | ); 53 | }; 54 | }; 55 | 56 | export { 57 | accumulateOrderFillsForBaseAssetReceived, 58 | accumulateOrderFillsForQuoteAssetReceived, 59 | }; 60 | -------------------------------------------------------------------------------- /src/trade/cleanup.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestScheduler } from 'rxjs/testing'; 2 | import { getCleanup$ } from './cleanup'; 3 | import { testConfig, getLoggers } from '../test-utils'; 4 | import { Observable } from 'rxjs'; 5 | import { Exchange } from 'ccxt'; 6 | 7 | let testScheduler: TestScheduler; 8 | const testSchedulerSetup = () => { 9 | testScheduler = new TestScheduler((actual, expected) => { 10 | expect(actual).toEqual(expected); 11 | }); 12 | }; 13 | 14 | type AssertCleanupParams = { 15 | expected: string; 16 | expectedSubscriptions: { 17 | removeCEXorders$: string | string[]; 18 | removeOpenDEXorders$: string | string[]; 19 | }; 20 | inputEvents: { 21 | removeOpenDEXorders$: string; 22 | removeCEXorders$: string; 23 | unsubscribe?: string; 24 | }; 25 | }; 26 | 27 | const assertGetTrade = ({ 28 | expected, 29 | expectedSubscriptions, 30 | inputEvents, 31 | }: AssertCleanupParams) => { 32 | testScheduler.run(helpers => { 33 | const { cold, expectObservable, expectSubscriptions } = helpers; 34 | const openDEXorders$ = cold(inputEvents.removeOpenDEXorders$); 35 | const removeOpenDEXorders$ = () => { 36 | return (openDEXorders$ as unknown) as Observable; 37 | }; 38 | const CEXorders$ = cold(inputEvents.removeCEXorders$); 39 | const removeCEXorders$ = () => CEXorders$; 40 | const CEX = (null as unknown) as Exchange; 41 | const cleanup$ = getCleanup$({ 42 | loggers: getLoggers(), 43 | config: testConfig(), 44 | removeOpenDEXorders$, 45 | removeCEXorders$, 46 | CEX, 47 | }); 48 | expectObservable(cleanup$, inputEvents.unsubscribe).toBe(expected); 49 | expectSubscriptions(CEXorders$.subscriptions).toBe( 50 | expectedSubscriptions.removeCEXorders$ 51 | ); 52 | expectSubscriptions(openDEXorders$.subscriptions).toBe( 53 | expectedSubscriptions.removeOpenDEXorders$ 54 | ); 55 | }); 56 | }; 57 | 58 | describe('getCleanup$$', () => { 59 | beforeEach(testSchedulerSetup); 60 | 61 | it('removes all orders on OpenDEX and CEX', () => { 62 | expect.assertions(3); 63 | const inputEvents = { 64 | removeOpenDEXorders$: '1s a', 65 | removeCEXorders$: '2s a', 66 | }; 67 | const expected = '2s |'; 68 | const expectedSubscriptions = { 69 | removeCEXorders$: '^ 1999ms !', 70 | removeOpenDEXorders$: '^ 1999ms !', 71 | }; 72 | assertGetTrade({ 73 | inputEvents, 74 | expected, 75 | expectedSubscriptions, 76 | }); 77 | }); 78 | 79 | it('retries when OpenDEX fails up to 5 times', () => { 80 | expect.assertions(3); 81 | const inputEvents = { 82 | removeOpenDEXorders$: '1s #', 83 | removeCEXorders$: '2s a', 84 | unsubscribe: '15s !', 85 | }; 86 | const expected = '11s #'; 87 | const expectedSubscriptions = { 88 | removeCEXorders$: '^ 10999ms !', 89 | removeOpenDEXorders$: [ 90 | '^ 999ms !', 91 | '2s ^ 999ms !', 92 | '4s ^ 999ms !', 93 | '6s ^ 999ms !', 94 | '8s ^ 999ms !', 95 | '10s ^ 999ms !', 96 | ], 97 | }; 98 | assertGetTrade({ 99 | inputEvents, 100 | expected, 101 | expectedSubscriptions, 102 | }); 103 | }); 104 | 105 | it('retries when CEX fails up to 5 times', () => { 106 | expect.assertions(3); 107 | const inputEvents = { 108 | removeOpenDEXorders$: '1s a', 109 | removeCEXorders$: '2s #', 110 | unsubscribe: '20s !', 111 | }; 112 | const expected = '17s #'; 113 | const expectedSubscriptions = { 114 | removeCEXorders$: [ 115 | '^ 1999ms !', 116 | '3s ^ 1999ms !', 117 | '6s ^ 1999ms !', 118 | '9s ^ 1999ms !', 119 | '12s ^ 1999ms !', 120 | '15s ^ 1999ms !', 121 | ], 122 | removeOpenDEXorders$: '^ 16999ms !', 123 | }; 124 | assertGetTrade({ 125 | inputEvents, 126 | expected, 127 | expectedSubscriptions, 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/trade/cleanup.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, Order } from 'ccxt'; 2 | import { curry } from 'ramda'; 3 | import { combineLatest, Observable, of, throwError, timer } from 'rxjs'; 4 | import { 5 | ignoreElements, 6 | mergeMap, 7 | mergeMapTo, 8 | retryWhen, 9 | take, 10 | tap, 11 | } from 'rxjs/operators'; 12 | import { cancelOrder$ } from '../centralized/ccxt/cancel-order'; 13 | import { fetchOpenOrders$ } from '../centralized/ccxt/fetch-open-orders'; 14 | import { Config } from '../config'; 15 | import { 16 | CLEANUP_RETRY_INTERVAL, 17 | MAX_RETRY_ATTEMPS_CLEANUP, 18 | } from '../constants'; 19 | import { Logger, Loggers } from '../logger'; 20 | import { processListorders } from '../opendex/process-listorders'; 21 | import { RemoveOpenDEXordersParams } from '../opendex/remove-orders'; 22 | import { getXudClient$ } from '../opendex/xud/client'; 23 | import { listXudOrders$ } from '../opendex/xud/list-orders'; 24 | import { removeXudOrder$ } from '../opendex/xud/remove-order'; 25 | 26 | type GetCleanupParams = { 27 | loggers: Loggers; 28 | config: Config; 29 | removeOpenDEXorders$: ({ 30 | config, 31 | getXudClient$, 32 | listXudOrders$, 33 | removeXudOrder$, 34 | processListorders, 35 | }: RemoveOpenDEXordersParams) => Observable; 36 | removeCEXorders$: ( 37 | logger: Logger, 38 | config: Config, 39 | exchange: Exchange, 40 | fetchOpenOrders$: ( 41 | exchange: Exchange, 42 | config: Config 43 | ) => Observable, 44 | cancelOrder$: ( 45 | exchange: Exchange, 46 | config: Config, 47 | orderId: string 48 | ) => Observable 49 | ) => Observable; 50 | CEX: Exchange; 51 | }; 52 | 53 | const getCleanup$ = ({ 54 | config, 55 | loggers, 56 | removeOpenDEXorders$, 57 | removeCEXorders$, 58 | CEX, 59 | }: GetCleanupParams): Observable => { 60 | const retryOnError = (logger: Logger, source: Observable) => { 61 | return source.pipe( 62 | retryWhen(attempts => { 63 | return attempts.pipe( 64 | mergeMap((e, index) => { 65 | if (index + 1 > MAX_RETRY_ATTEMPS_CLEANUP) { 66 | return throwError(e); 67 | } 68 | const msg = e.message || e; 69 | logger.warn(`Failed to remove orders: ${msg} - retrying in 1000ms`); 70 | return timer(CLEANUP_RETRY_INTERVAL).pipe(mergeMapTo(of(e))); 71 | }) 72 | ); 73 | }) 74 | ); 75 | }; 76 | const curriedRetryOnError = curry(retryOnError); 77 | const retryOnErrorOpenDEX = curriedRetryOnError(loggers.opendex); 78 | const retryonErrorCEX = curriedRetryOnError(loggers.centralized); 79 | return combineLatest( 80 | removeOpenDEXorders$({ 81 | config, 82 | getXudClient$, 83 | listXudOrders$, 84 | removeXudOrder$, 85 | processListorders, 86 | }).pipe( 87 | retryOnErrorOpenDEX, 88 | tap(() => { 89 | loggers.opendex.info('All OpenDEX orders have been removed'); 90 | }) 91 | ), 92 | removeCEXorders$( 93 | loggers.centralized, 94 | config, 95 | CEX, 96 | fetchOpenOrders$, 97 | cancelOrder$ 98 | ).pipe( 99 | retryonErrorCEX, 100 | tap({ 101 | complete: () => { 102 | loggers.centralized.info('All CEX orders have been removed'); 103 | }, 104 | }) 105 | ) 106 | ).pipe(take(1), ignoreElements()); 107 | }; 108 | 109 | export { getCleanup$, GetCleanupParams }; 110 | -------------------------------------------------------------------------------- /src/trade/info.spec.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from 'bignumber.js'; 2 | import { Exchange } from 'ccxt'; 3 | import { Observable } from 'rxjs'; 4 | import { TestScheduler } from 'rxjs/testing'; 5 | import { getLoggers, testConfig } from '../test-utils'; 6 | import { 7 | ExchangeAssetAllocation, 8 | getTradeInfo$, 9 | OpenDEXassetAllocation, 10 | TradeInfo, 11 | tradeInfoArrayToObject, 12 | } from './info'; 13 | 14 | let testScheduler: TestScheduler; 15 | const testSchedulerSetup = () => { 16 | testScheduler = new TestScheduler((actual, expected) => { 17 | expect(actual).toEqual(expected); 18 | }); 19 | }; 20 | 21 | type TradeInfoInputEvents = { 22 | getOpenDEXassets$: string; 23 | getCentralizedExchangeAssets$: string; 24 | getCentralizedExchangePrice$: string; 25 | }; 26 | 27 | type TradeInfoOutputValues = { 28 | [event: string]: string; 29 | }; 30 | 31 | const assertTradeInfo = ( 32 | inputEvents: TradeInfoInputEvents, 33 | expected: string, 34 | expectedValues: TradeInfoOutputValues 35 | ) => { 36 | testScheduler.run(helpers => { 37 | const { cold, expectObservable } = helpers; 38 | const getOpenDEXassets$ = () => { 39 | return cold(inputEvents.getOpenDEXassets$) as Observable< 40 | OpenDEXassetAllocation 41 | >; 42 | }; 43 | const getCentralizedExchangeAssets$ = () => { 44 | return cold(inputEvents.getCentralizedExchangeAssets$) as Observable< 45 | ExchangeAssetAllocation 46 | >; 47 | }; 48 | const centralizedExchangePrice$ = cold( 49 | inputEvents.getCentralizedExchangePrice$ 50 | ) as Observable; 51 | const tradeInfoArrayToObject = (v: any) => { 52 | return (v.join('') as unknown) as TradeInfo; 53 | }; 54 | const CEX = (null as unknown) as Exchange; 55 | const tradeInfo$ = getTradeInfo$({ 56 | CEX, 57 | config: testConfig(), 58 | loggers: getLoggers(), 59 | openDexAssets$: getOpenDEXassets$, 60 | centralizedExchangeAssets$: getCentralizedExchangeAssets$, 61 | centralizedExchangePrice$, 62 | tradeInfoArrayToObject, 63 | }); 64 | expectObservable(tradeInfo$).toBe(expected, expectedValues); 65 | }); 66 | }; 67 | 68 | describe('tradeInfo$', () => { 69 | beforeEach(testSchedulerSetup); 70 | 71 | it('emits TradeInfo events', () => { 72 | const inputEvents = { 73 | getOpenDEXassets$: '5s a', 74 | getCentralizedExchangeAssets$: '6s a', 75 | getCentralizedExchangePrice$: '7s a 1s b 1s c', 76 | }; 77 | const expectedEvents = '7s a 1s b 1s c'; 78 | const expectedValues = { 79 | a: 'aaa', 80 | b: 'aab', 81 | c: 'aac', 82 | }; 83 | assertTradeInfo(inputEvents, expectedEvents, expectedValues); 84 | }); 85 | 86 | it('ignores duplicate values', () => { 87 | const inputEvents = { 88 | getOpenDEXassets$: '5s a', 89 | getCentralizedExchangeAssets$: '6s a', 90 | getCentralizedExchangePrice$: '7s a 1s a', 91 | }; 92 | const expectedEvents = '7s a'; 93 | const expectedValues = { 94 | a: 'aaa', 95 | }; 96 | assertTradeInfo(inputEvents, expectedEvents, expectedValues); 97 | }); 98 | 99 | it('does not emit anything without OpenDEX assets', () => { 100 | const inputEvents = { 101 | getOpenDEXassets$: '', 102 | getCentralizedExchangeAssets$: '6s a', 103 | getCentralizedExchangePrice$: '7s a', 104 | }; 105 | const expectedEvents = ''; 106 | const expectedValues = {}; 107 | assertTradeInfo(inputEvents, expectedEvents, expectedValues); 108 | }); 109 | 110 | it('does not emit anything without centralized exchange price', () => { 111 | const inputEvents = { 112 | getOpenDEXassets$: '5s a', 113 | getCentralizedExchangeAssets$: '6s a', 114 | getCentralizedExchangePrice$: '', 115 | }; 116 | const expectedEvents = ''; 117 | const expectedValues = {}; 118 | assertTradeInfo(inputEvents, expectedEvents, expectedValues); 119 | }); 120 | 121 | it('does not emit anything without centralized exchange assets', () => { 122 | const inputEvents = { 123 | getOpenDEXassets$: '5s a', 124 | getCentralizedExchangeAssets$: '', 125 | getCentralizedExchangePrice$: '7s a', 126 | }; 127 | const expectedEvents = ''; 128 | const expectedValues = {}; 129 | assertTradeInfo(inputEvents, expectedEvents, expectedValues); 130 | }); 131 | 132 | it('errors if OpenDEX assets error', () => { 133 | const inputEvents = { 134 | getOpenDEXassets$: '5s a 5s #', 135 | getCentralizedExchangeAssets$: '6s a', 136 | getCentralizedExchangePrice$: '7s a', 137 | }; 138 | const expectedEvents = '7s a 3s #'; 139 | const expectedValues = { 140 | a: 'aaa', 141 | }; 142 | assertTradeInfo(inputEvents, expectedEvents, expectedValues); 143 | }); 144 | 145 | it('errors if centralized assets error', () => { 146 | const inputEvents = { 147 | getOpenDEXassets$: '5s a', 148 | getCentralizedExchangeAssets$: '6s a 4s #', 149 | getCentralizedExchangePrice$: '7s a', 150 | }; 151 | const expectedEvents = '7s a 3s #'; 152 | const expectedValues = { 153 | a: 'aaa', 154 | }; 155 | assertTradeInfo(inputEvents, expectedEvents, expectedValues); 156 | }); 157 | 158 | it('errors if centralized exchange price errors', () => { 159 | const inputEvents = { 160 | getOpenDEXassets$: '5s a', 161 | getCentralizedExchangeAssets$: '6s a', 162 | getCentralizedExchangePrice$: '7s a 3s #', 163 | }; 164 | const expectedEvents = '7s a 3s #'; 165 | const expectedValues = { 166 | a: 'aaa', 167 | }; 168 | assertTradeInfo(inputEvents, expectedEvents, expectedValues); 169 | }); 170 | }); 171 | 172 | describe('tradeInfoArrayToObject', () => { 173 | it('converts trade info array to object', () => { 174 | const openDEXassets = { 175 | baseAssetBalance: new BigNumber('1.23'), 176 | baseAssetMaxInbound: new BigNumber('0.615'), 177 | baseAssetMaxOutbound: new BigNumber('0.615'), 178 | quoteAssetBalance: new BigNumber('3.33'), 179 | quoteAssetMaxInbound: new BigNumber('1.665'), 180 | quoteAssetMaxOutbound: new BigNumber('1.665'), 181 | }; 182 | const centralizedExchangeAssets = { 183 | baseAssetBalance: new BigNumber('7.65'), 184 | quoteAssetBalance: new BigNumber('13.37'), 185 | }; 186 | const centralizedExchangePrice = new BigNumber('10000'); 187 | const tradeInfo = tradeInfoArrayToObject([ 188 | openDEXassets, 189 | centralizedExchangeAssets, 190 | centralizedExchangePrice, 191 | ]); 192 | expect(tradeInfo.price).toEqual(centralizedExchangePrice); 193 | expect(tradeInfo.assets.openDEX).toEqual(openDEXassets); 194 | expect(tradeInfo.assets.centralizedExchange).toEqual( 195 | centralizedExchangeAssets 196 | ); 197 | }); 198 | }); 199 | -------------------------------------------------------------------------------- /src/trade/info.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from 'bignumber.js'; 2 | import { equals } from 'ramda'; 3 | import { combineLatest, Observable } from 'rxjs'; 4 | import { distinctUntilChanged, map, tap } from 'rxjs/operators'; 5 | import { Loggers } from 'src/logger'; 6 | import { Config } from '../config'; 7 | import { 8 | GetCentralizedExchangeAssetsParams, 9 | logAssetAllocation, 10 | } from '../centralized/assets'; 11 | import { Exchange } from 'ccxt'; 12 | import { fetchBalance$ } from '../centralized/ccxt/fetch-balance'; 13 | import { convertBalances } from '../centralized/convert-balances'; 14 | 15 | type GetTradeInfoParams = { 16 | config: Config; 17 | loggers: Loggers; 18 | CEX: Exchange; 19 | openDexAssets$: (config: Config) => Observable; 20 | centralizedExchangeAssets$: ({ 21 | config, 22 | logger, 23 | }: GetCentralizedExchangeAssetsParams) => Observable; 24 | centralizedExchangePrice$: Observable; 25 | tradeInfoArrayToObject: ([ 26 | openDexAssets, 27 | centralizedExchangeAssets, 28 | centralizedExchangePrice, 29 | ]: TradeInfoArrayToObjectParams) => TradeInfo; 30 | }; 31 | 32 | type TradeInfo = { 33 | price: BigNumber; 34 | assets: AssetAllocation; 35 | }; 36 | 37 | type AssetAllocation = { 38 | openDEX: OpenDEXassetAllocation; 39 | centralizedExchange: ExchangeAssetAllocation; 40 | }; 41 | 42 | type OpenDEXassetAllocation = ExchangeAssetAllocation & { 43 | baseAssetMaxOutbound: BigNumber; 44 | baseAssetMaxInbound: BigNumber; 45 | quoteAssetMaxOutbound: BigNumber; 46 | quoteAssetMaxInbound: BigNumber; 47 | }; 48 | 49 | type ExchangeAssetAllocation = { 50 | baseAssetBalance: BigNumber; 51 | quoteAssetBalance: BigNumber; 52 | }; 53 | 54 | type TradeInfoArrayToObjectParams = [ 55 | OpenDEXassetAllocation, 56 | ExchangeAssetAllocation, 57 | BigNumber 58 | ]; 59 | 60 | const tradeInfoArrayToObject = ([ 61 | openDEXassets, 62 | centralizedExchangeAssets, 63 | centralizedExchangePrice, 64 | ]: TradeInfoArrayToObjectParams): TradeInfo => { 65 | return { 66 | price: centralizedExchangePrice, 67 | assets: { 68 | openDEX: openDEXassets, 69 | centralizedExchange: centralizedExchangeAssets, 70 | }, 71 | }; 72 | }; 73 | 74 | const getTradeInfo$ = ({ 75 | config, 76 | loggers, 77 | openDexAssets$, 78 | centralizedExchangeAssets$, 79 | centralizedExchangePrice$, 80 | tradeInfoArrayToObject, 81 | CEX, 82 | }: GetTradeInfoParams): Observable => { 83 | return combineLatest( 84 | // wait for all the necessary tradeInfo 85 | openDexAssets$(config), 86 | centralizedExchangeAssets$({ 87 | config, 88 | CEX, 89 | logger: loggers.centralized, 90 | CEXfetchBalance$: fetchBalance$, 91 | convertBalances, 92 | logAssetAllocation, 93 | }), 94 | centralizedExchangePrice$.pipe( 95 | tap(price => loggers.centralized.trace(`New price: ${price}`)) 96 | ) 97 | ).pipe( 98 | // map it to an object 99 | map(tradeInfoArrayToObject), 100 | // ignore duplicate values 101 | distinctUntilChanged((a, b) => equals(a, b)) 102 | ); 103 | }; 104 | 105 | export { 106 | getTradeInfo$, 107 | tradeInfoArrayToObject, 108 | GetTradeInfoParams, 109 | TradeInfo, 110 | ExchangeAssetAllocation, 111 | OpenDEXassetAllocation, 112 | }; 113 | -------------------------------------------------------------------------------- /src/trade/trade.spec.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Exchange } from 'ccxt'; 3 | import { Observable } from 'rxjs'; 4 | import { catchError } from 'rxjs/operators'; 5 | import { TestScheduler } from 'rxjs/testing'; 6 | import { getArbyStore } from '../store'; 7 | import { getLoggers, testConfig, TestError } from '../test-utils'; 8 | import { getNewTrade$ } from './trade'; 9 | 10 | let testScheduler: TestScheduler; 11 | const testSchedulerSetup = () => { 12 | testScheduler = new TestScheduler((actual, expected) => { 13 | expect(actual).toEqual(expected); 14 | }); 15 | }; 16 | 17 | type AssertGetTradeParams = { 18 | expected: string; 19 | expectedError?: TestError; 20 | inputEvents: { 21 | openDEXcomplete$: string; 22 | getCentralizedExchangeOrder$: string; 23 | shutdown$: string; 24 | catchOpenDEXerror$: string; 25 | }; 26 | errorValues?: { 27 | openDEXcomplete$?: TestError; 28 | getCentralizedExchangeOrder$?: TestError; 29 | shutdown$?: TestError; 30 | catchOpenDEXerror$?: TestError; 31 | }; 32 | }; 33 | 34 | const assertGetTrade = ({ 35 | expected, 36 | expectedError, 37 | inputEvents, 38 | errorValues, 39 | }: AssertGetTradeParams) => { 40 | const inputValues = { 41 | openDEXcomplete$: { 42 | a: true, 43 | }, 44 | }; 45 | testScheduler.run(helpers => { 46 | const { cold, expectObservable } = helpers; 47 | const getCentralizedExchangeOrder$ = () => { 48 | return (cold( 49 | inputEvents.getCentralizedExchangeOrder$, 50 | undefined, 51 | errorValues?.getCentralizedExchangeOrder$ 52 | ) as unknown) as Observable; 53 | }; 54 | const shutdown$ = cold(inputEvents.shutdown$); 55 | const getOpenDEXcomplete$ = () => { 56 | return cold( 57 | inputEvents.openDEXcomplete$, 58 | inputValues.openDEXcomplete$, 59 | errorValues?.openDEXcomplete$ 60 | ); 61 | }; 62 | const catchOpenDEXerror = () => (source: Observable) => { 63 | return source.pipe( 64 | catchError(() => { 65 | return cold( 66 | inputEvents.catchOpenDEXerror$, 67 | undefined, 68 | errorValues?.catchOpenDEXerror$ 69 | ); 70 | }) 71 | ); 72 | }; 73 | const getCentralizedExchangePrice$ = () => { 74 | return (cold('') as unknown) as Observable; 75 | }; 76 | const CEX = (null as unknown) as Exchange; 77 | const store = getArbyStore(); 78 | const trade$ = getNewTrade$({ 79 | CEX, 80 | shutdown$, 81 | loggers: getLoggers(), 82 | getOpenDEXcomplete$, 83 | config: testConfig(), 84 | getCentralizedExchangeOrder$, 85 | catchOpenDEXerror, 86 | getCentralizedExchangePrice$, 87 | store, 88 | }); 89 | expectObservable(trade$).toBe(expected, { a: true }, expectedError); 90 | }); 91 | }; 92 | 93 | describe('getTrade$', () => { 94 | beforeEach(testSchedulerSetup); 95 | 96 | it('emits when arbitrage trade complete', () => { 97 | expect.assertions(1); 98 | const inputEvents = { 99 | openDEXcomplete$: '1s (a|)', 100 | getCentralizedExchangeOrder$: '1s (a|)', 101 | shutdown$: '3s a', 102 | catchOpenDEXerror$: '', 103 | }; 104 | const expected = '1s a 999ms a 999ms |'; 105 | assertGetTrade({ 106 | inputEvents, 107 | expected, 108 | }); 109 | }); 110 | 111 | it('retries when recoverable OpenDEX error happens', () => { 112 | expect.assertions(1); 113 | const inputEvents = { 114 | openDEXcomplete$: '1s #', 115 | getCentralizedExchangeOrder$: '2s (a|)', 116 | shutdown$: '5s a', 117 | catchOpenDEXerror$: '500ms (a|)', 118 | }; 119 | const expected = '2s a 1999ms a 999ms |'; 120 | assertGetTrade({ 121 | inputEvents, 122 | expected, 123 | }); 124 | }); 125 | 126 | it('stops when unexpected OpenDEX error happens', () => { 127 | expect.assertions(1); 128 | const inputEvents = { 129 | openDEXcomplete$: '1s #', 130 | getCentralizedExchangeOrder$: '2s a', 131 | shutdown$: '5s a', 132 | catchOpenDEXerror$: '1500ms #', 133 | }; 134 | const unexpectedError = { 135 | code: '1234', 136 | message: 'some unexpected OpenDEX error', 137 | }; 138 | const errorValues = { 139 | openDEXcomplete$: unexpectedError, 140 | catchOpenDEXerror$: unexpectedError, 141 | }; 142 | const expected = '2s a 499ms #'; 143 | assertGetTrade({ 144 | inputEvents, 145 | expected, 146 | expectedError: unexpectedError, 147 | errorValues, 148 | }); 149 | }); 150 | 151 | it('stops when centralized exchange error happens', () => { 152 | expect.assertions(1); 153 | const inputEvents = { 154 | openDEXcomplete$: '1s a', 155 | getCentralizedExchangeOrder$: '1s #', 156 | shutdown$: '5s a', 157 | catchOpenDEXerror$: '', 158 | }; 159 | const expected = '1s #'; 160 | assertGetTrade({ 161 | inputEvents, 162 | expected, 163 | }); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /src/trade/trade.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js'; 2 | import { Exchange } from 'ccxt'; 3 | import { merge, Observable } from 'rxjs'; 4 | import { ignoreElements, mapTo, repeat, takeUntil, tap } from 'rxjs/operators'; 5 | import { deriveCEXorderQuantity } from '../centralized/derive-order-quantity'; 6 | import { CentralizedExchangePriceParams } from '../centralized/exchange-price'; 7 | import { executeCEXorder$ } from '../centralized/execute-order'; 8 | import { GetCentralizedExchangeOrderParams } from '../centralized/order'; 9 | import { getOrderBuilder$ } from '../centralized/order-builder'; 10 | import { Config } from '../config'; 11 | import { Loggers } from '../logger'; 12 | import { GetOpenDEXcompleteParams } from '../opendex/complete'; 13 | import { createOpenDEXorders$ } from '../opendex/create-orders'; 14 | import { ArbyStore } from '../store'; 15 | import { getCleanup$, GetCleanupParams } from './cleanup'; 16 | import { getTradeInfo$ } from './info'; 17 | import { getKrakenPrice$ } from '../centralized/kraken-price'; 18 | import { getBinancePrice$ } from '../centralized/binance-price'; 19 | 20 | type GetTradeParams = { 21 | config: Config; 22 | loggers: Loggers; 23 | getOpenDEXcomplete$: ({ 24 | config, 25 | loggers, 26 | }: GetOpenDEXcompleteParams) => Observable; 27 | getCentralizedExchangeOrder$: ({ 28 | logger, 29 | config, 30 | getOrderBuilder$, 31 | executeCEXorder$, 32 | }: GetCentralizedExchangeOrderParams) => Observable; 33 | shutdown$: Observable; 34 | catchOpenDEXerror: ( 35 | loggers: Loggers, 36 | config: Config, 37 | getCleanup$: ({ 38 | config, 39 | loggers, 40 | removeOpenDEXorders$, 41 | removeCEXorders$, 42 | }: GetCleanupParams) => Observable, 43 | CEX: Exchange, 44 | store: ArbyStore 45 | ) => (source: Observable) => Observable; 46 | getCentralizedExchangePrice$: ({ 47 | logger, 48 | config, 49 | }: CentralizedExchangePriceParams) => Observable; 50 | CEX: Exchange; 51 | store: ArbyStore; 52 | }; 53 | 54 | const getNewTrade$ = ({ 55 | config, 56 | loggers, 57 | getCentralizedExchangeOrder$, 58 | getOpenDEXcomplete$, 59 | shutdown$, 60 | catchOpenDEXerror, 61 | getCentralizedExchangePrice$, 62 | CEX, 63 | store, 64 | }: GetTradeParams): Observable => { 65 | const centralizedExchangePrice$ = getCentralizedExchangePrice$({ 66 | config, 67 | logger: loggers.centralized, 68 | getBinancePrice$, 69 | getKrakenPrice$, 70 | }); 71 | return merge( 72 | getOpenDEXcomplete$({ 73 | config, 74 | CEX, 75 | createOpenDEXorders$, 76 | loggers, 77 | tradeInfo$: getTradeInfo$, 78 | centralizedExchangePrice$, 79 | store, 80 | }).pipe( 81 | catchOpenDEXerror(loggers, config, getCleanup$, CEX, store), 82 | ignoreElements() 83 | ), 84 | getCentralizedExchangeOrder$({ 85 | CEX, 86 | logger: loggers.centralized, 87 | config, 88 | getOrderBuilder$, 89 | executeCEXorder$, 90 | centralizedExchangePrice$, 91 | deriveCEXorderQuantity, 92 | store, 93 | }) 94 | ).pipe( 95 | tap(() => { 96 | loggers.global.info('Trade complete'); 97 | }), 98 | mapTo(true), 99 | repeat(), 100 | takeUntil(shutdown$) 101 | ); 102 | }; 103 | 104 | export { getNewTrade$, GetTradeParams }; 105 | -------------------------------------------------------------------------------- /src/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { getStartShutdown$ } from '../src/utils'; 2 | 3 | describe('Utils', () => { 4 | describe('getStartShutdown$', () => { 5 | it('completes on SIGINT', done => { 6 | getStartShutdown$().subscribe({ 7 | complete: done, 8 | }); 9 | process.emit('SIGINT', 'SIGINT'); 10 | }); 11 | 12 | it('completes on SIGTERM', done => { 13 | getStartShutdown$().subscribe({ 14 | complete: done, 15 | }); 16 | process.emit('SIGTERM', 'SIGTERM'); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import { Observable, Subject } from 'rxjs'; 3 | import { take, tap } from 'rxjs/operators'; 4 | import { curry } from 'ramda'; 5 | 6 | /** Get the current date in the given dateFormat, if not provided formats with `YYYY-MM-DD hh:mm:ss.sss`. 7 | */ 8 | export const getTsString = (dateFormat?: string): string => 9 | moment().format(dateFormat || 'YYYY-MM-DD hh:mm:ss.sss'); 10 | 11 | const SATOSHIS_PER_COIN = 10 ** 8; 12 | 13 | /** Returns a number of satoshis as a string representation of coins with up to 8 decimal places. */ 14 | export const satsToCoinsStr = (satsQuantity: number): string => { 15 | return (satsQuantity / SATOSHIS_PER_COIN).toFixed(8).replace(/\.?0+$/, ''); 16 | }; 17 | 18 | /** Returns a number of coins as an integer number of satoshis. */ 19 | export const coinsToSats = (coinsQuantity: number): number => { 20 | return Math.round(coinsQuantity * SATOSHIS_PER_COIN); 21 | }; 22 | 23 | export const getStartShutdown$ = (): Observable => { 24 | const shutdown$ = new Subject(); 25 | process.on('SIGINT', () => shutdown$.next()); 26 | process.on('SIGTERM', () => shutdown$.next()); 27 | return shutdown$.asObservable().pipe(take(1)); 28 | }; 29 | 30 | const debugObservable = (prefix: string, source: Observable) => { 31 | return source.pipe( 32 | tap({ 33 | next: v => console.log(`${prefix} next: ${v}`), 34 | error: e => console.log(`${prefix} error: ${e}`), 35 | complete: () => console.log(`${prefix} complete`), 36 | }) 37 | ); 38 | }; 39 | 40 | const debugLog = curry(debugObservable); 41 | 42 | export { debugLog }; 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ "src" ], 3 | "exclude": [ 4 | "**/*.spec.ts" 5 | ], 6 | "compilerOptions": { 7 | "target": "ES2016", 8 | "module": "commonjs", 9 | "allowJs": false, 10 | "outDir": "./dist", 11 | "strict": true, 12 | "skipLibCheck": true, 13 | "noEmitOnError": false, 14 | "lib": ["dom","ES2017"], 15 | "declaration": true, 16 | "incremental": true, 17 | "tsBuildInfoFile": "dist/.tsbuildinfo", 18 | "noUnusedLocals": false, 19 | "noUnusedParameters": false, 20 | "noImplicitReturns": true, 21 | "moduleResolution": "node", 22 | "baseUrl": "./", 23 | "esModuleInterop": true, 24 | "sourceMap": true 25 | } 26 | } 27 | --------------------------------------------------------------------------------