├── .dockerignore ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .yarnrc ├── Dockerfile ├── README.md ├── config ├── default.json ├── development.json └── test.json ├── doc ├── build_docker.md ├── debug_app.md ├── res │ ├── auth_bearer.png │ ├── auth_execute.png │ ├── auth_tryout.png │ ├── authorize.png │ ├── authorize_bearer.png │ └── vscode_debug.png ├── run_docker.md └── swagger_login.md ├── docker-compose.yml ├── jest.config.js ├── jesthtmlreporter.config.json ├── openapitools.json ├── package.json ├── scripts └── run_delayed.sh ├── src ├── __tests__ │ ├── acceptance │ │ ├── account.controller.test.ts │ │ ├── app.test.ts │ │ ├── authentication.controller.test.ts │ │ ├── contracts.controller.test.ts │ │ ├── realtime-accountSummary.test.ts │ │ ├── realtime-data.controller.test.ts │ │ ├── realtime-marketdata.test.ts │ │ └── realtime-positions.test.ts │ ├── helper │ │ └── test.helper.ts │ ├── ib-api-test-app.ts │ ├── mock │ │ └── ib-api-next.mock.ts │ ├── nodb-test-environment.ts │ ├── setup.ts │ └── unit │ │ ├── ib.helper.test.ts │ │ └── security.utils.test.ts ├── app.ts ├── config.ts ├── controllers │ ├── account.controller.ts │ ├── authentication.controller.ts │ ├── contracts.controller.ts │ └── realtime-data.controller.ts ├── export-openapi.ts ├── models │ ├── account-list.model.ts │ ├── account-summary.model.ts │ ├── contract-description.model.ts │ ├── contract-details.model.ts │ ├── contract.model.ts │ ├── historic-data-request.model.ts │ ├── market-data.model.ts │ ├── ohlc-bar.model.ts │ ├── pnl.model.ts │ ├── position-list.model.ts │ ├── position.model.ts │ ├── realtime-data-message.model.ts │ └── username-password.model.ts ├── run.ts ├── services │ ├── authentication.service.ts │ ├── ib-api-factory.service.ts │ └── ib-api.service.ts └── utils │ ├── ib-api-logger-proxy.ts │ ├── ib.helper.ts │ └── security.utils.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/charts 16 | **/docker-compose* 17 | **/compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | README.md 25 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | __tests__/ 2 | node_modules/ 3 | dist/ 4 | api/ 5 | coverage/ 6 | .eslintrc.js 7 | *.test.ts 8 | jest.config.js 9 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "parser": "@typescript-eslint/parser", 6 | "plugins": ["rxjs"], 7 | "extends": [ 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": 2020, 12 | "project": ["tsconfig.json"], 13 | "sourceType": "module" 14 | }, 15 | "rules": { 16 | "strict": "error", 17 | "semi": ["error", "always"], 18 | "quotes": ["error", "double"], 19 | "@typescript-eslint/explicit-function-return-type": "error", 20 | "@typescript-eslint/no-explicit-any": 1, 21 | "@typescript-eslint/no-inferrable-types": [ 22 | "warn", { 23 | "ignoreParameters": true 24 | } 25 | ], 26 | "rxjs/no-async-subscribe": "error", 27 | "rxjs/no-ignored-observable": "error", 28 | "rxjs/no-ignored-subscription": "error", 29 | "rxjs/no-unbound-methods": "error", 30 | "rxjs/throw-error": "error" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: "Test and Publish" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | name: "Build and Test" 14 | runs-on: ubuntu-latest 15 | container: ghcr.io/waytrade/microservice-core/build:latest 16 | 17 | steps: 18 | - name: "Setup node.js environment" 19 | uses: "actions/setup-node@v2" 20 | with: 21 | node-version: "16.x" 22 | registry-url: https://npm.pkg.github.com/ 23 | scope: "@waytrade" 24 | always-auth: true 25 | 26 | - name: "Configure cache" 27 | uses: actions/cache@v2 28 | with: 29 | path: "**/node_modules" 30 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 31 | 32 | - name: "Checkout source code" 33 | uses: "actions/checkout@v2" 34 | with: 35 | ref: ${{ github.ref }} 36 | 37 | - name: Prepare .npmrc 38 | run: echo //npm.pkg.github.com/:_authToken=$GITHUB_TOKEN >> .npmrc 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | 42 | - name: "Install dependencies" 43 | run: yarn install 44 | 45 | - name: "Build App" 46 | run: yarn build 47 | 48 | - name: "Run Tests" 49 | run: yarn test:ci 50 | 51 | - name: "Run validate OpenAPI" 52 | run: yarn validate-openapi 53 | 54 | - name: "Bump Version" 55 | if: github.ref == 'refs/heads/master' 56 | uses: "phips28/gh-action-bump-version@master" 57 | with: 58 | tag-prefix: "v" 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | 62 | - name: "Upload Test-Report Artifact" 63 | uses: actions/upload-artifact@v2 64 | with: 65 | name: test-report 66 | path: test-report/ 67 | retention-days: 5 68 | 69 | - name: "Deploy Test-Report" 70 | if: github.ref == 'refs/heads/master' 71 | uses: peaceiris/actions-gh-pages@v3 72 | with: 73 | github_token: ${{ secrets.GITHUB_TOKEN }} 74 | publish_dir: ./test-report 75 | 76 | - name: "Upload Build Artifact" 77 | uses: actions/upload-artifact@v2 78 | with: 79 | name: dist-files 80 | retention-days: 1 81 | path: | 82 | ./openapi.json 83 | ./package.json 84 | ./dist 85 | 86 | publish-npm: 87 | name: "Publish NPM" 88 | if: github.ref == 'refs/heads/master' 89 | needs: test 90 | runs-on: ubuntu-latest 91 | steps: 92 | - name: "Setup node.js environment" 93 | uses: "actions/setup-node@v2" 94 | with: 95 | node-version: "16.x" 96 | registry-url: https://npm.pkg.github.com/ 97 | scope: "@waytrade" 98 | always-auth: true 99 | 100 | - name: "Download Build Artifact" 101 | uses: actions/download-artifact@v2 102 | with: 103 | name: dist-files 104 | 105 | - name: "Publish NPM Package" 106 | run: | 107 | echo registry=https://npm.pkg.github.com/waytrade >> .npmrc 108 | npm config set //npm.pkg.github.com//:_authToken=$NODE_AUTH_TOKEN 109 | npm publish 110 | env: 111 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 112 | 113 | publish-docker: 114 | name: "Publish Docker Image" 115 | if: github.ref == 'refs/heads/master' 116 | needs: test 117 | runs-on: ubuntu-latest 118 | steps: 119 | - name: "Checkout source code" 120 | uses: "actions/checkout@v2" 121 | with: 122 | ref: ${{ github.ref }} 123 | 124 | - name: Prepare .npmrc 125 | run: echo //npm.pkg.github.com/:_authToken=$GITHUB_TOKEN >> .npmrc 126 | env: 127 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 128 | 129 | - name: "Download Build Artifact" 130 | uses: actions/download-artifact@v2 131 | with: 132 | name: dist-files 133 | 134 | - name: Log into registry 135 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin 136 | 137 | - name: Build image 138 | run: docker build . --file Dockerfile --tag production 139 | 140 | - name: Push image 141 | run: | 142 | IMAGE_NAME=production 143 | IMAGE_ID=ghcr.io/${{ github.repository }}/$IMAGE_NAME 144 | # Change all uppercase to lowercase 145 | IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') 146 | # Strip git ref prefix from version 147 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 148 | # Strip "v" prefix from tag name 149 | [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') 150 | # Use Docker `latest` tag convention 151 | [ "$VERSION" == "master" ] && VERSION=latest 152 | echo IMAGE_ID=$IMAGE_ID 153 | echo VERSION=$VERSION 154 | docker tag $IMAGE_NAME $IMAGE_ID:$VERSION 155 | docker push $IMAGE_ID:$VERSION 156 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # Transpiled JavaScript files from Typescript 61 | /dist 62 | 63 | # Cache used by TypeScript's incremental build 64 | *.tsbuildinfo 65 | 66 | /api 67 | openapi.json 68 | yarn-error.log 69 | /.npmrc 70 | /*.env2 71 | /junit.xml 72 | /test-report 73 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | api 3 | *.json 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": false, 3 | "trailingComma": "all", 4 | "arrowParens": "avoid" 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "node", 5 | "request": "launch", 6 | "name": "Launch Program", 7 | "skipFiles": [ 8 | "/**" 9 | ], 10 | "runtimeArgs": [ 11 | "--trace-warnings" 12 | ], 13 | "program": "${workspaceFolder}/dist/run.js", 14 | "env": { 15 | "NODE_ENV": "development" 16 | }, 17 | "outputCapture": "std", 18 | "resolveSourceMapLocations": [ 19 | "${workspaceFolder}/**", 20 | "!**/node_modules/**" 21 | ] 22 | }, 23 | { 24 | "type": "node", 25 | "request": "launch", 26 | "name": "Jest watch current file", 27 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 28 | "args": [ 29 | "${fileBasename}", 30 | "--verbose", 31 | "--maxConcurrency=1", 32 | "--no-cache", 33 | "--detectOpenHandles", 34 | "-i" 35 | ], 36 | "console": "internalConsole", 37 | "internalConsoleOptions": "neverOpen", 38 | "outputCapture": "std", 39 | "envFile": "${workspaceRoot}/.env", 40 | "resolveSourceMapLocations": [ 41 | "${workspaceFolder}/**", 42 | "!**/node_modules/**" 43 | ] 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [80], 3 | "editor.tabCompletion": "on", 4 | "editor.tabSize": 2, 5 | "editor.trimAutoWhitespace": true, 6 | "editor.formatOnSave": true, 7 | "editor.codeActionsOnSave": { 8 | "source.organizeImports": true, 9 | "source.fixAll.eslint": true 10 | }, 11 | 12 | "files.exclude": { 13 | "**/.DS_Store": true, 14 | "**/.git": true, 15 | "**/.hg": true, 16 | "**/.svn": true, 17 | "**/CVS": true, 18 | "dist": false, 19 | "node_modules": true, 20 | }, 21 | "files.insertFinalNewline": true, 22 | "files.trimTrailingWhitespace": true, 23 | 24 | "typescript.tsdk": "./node_modules/typescript/lib", 25 | "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": false, 26 | "typescript.preferences.quoteStyle": "single", 27 | "eslint.run": "onSave", 28 | "eslint.nodePath": "./node_modules", 29 | "eslint.validate": [ 30 | "javascript", 31 | "typescript" 32 | ], 33 | "cSpell.words": [ 34 | "Booter", 35 | "CREDITMAN", 36 | "ETF's", 37 | "ETFs", 38 | "Frener", 39 | "IBKR", 40 | "IDEALPRO", 41 | "LIBUS", 42 | "Microservice", 43 | "Microservices", 44 | "Pino", 45 | "Reuter's", 46 | "SHORTABLE", 47 | "Sauron's", 48 | "Saurons", 49 | "Unreportable", 50 | "bodyparser", 51 | "bootstrapper", 52 | "conid", 53 | "eslintcache", 54 | "forex", 55 | "maint", 56 | "mfrener", 57 | "openapi", 58 | "openapitools", 59 | "paramtypes", 60 | "postpublish", 61 | "posttest", 62 | "preexport", 63 | "pregenerate", 64 | "premigrate", 65 | "preopenapi", 66 | "prestart", 67 | "prevalidate", 68 | "printf", 69 | "returntype", 70 | "sauron", 71 | "stoqey", 72 | "testlab", 73 | "tsbuildinfo", 74 | "waytrade" 75 | ], 76 | "god.tsconfig": "./tsconfig.json" 77 | } 78 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Watch and Compile Project", 8 | "type": "shell", 9 | "command": "npm", 10 | "args": [ 11 | "--silent", 12 | "run", 13 | "build:watch" 14 | ], 15 | "group": { 16 | "kind": "build", 17 | "isDefault": true 18 | }, 19 | "problemMatcher": "$tsc-watch" 20 | }, 21 | { 22 | "label": "Build, Test and Lint", 23 | "type": "shell", 24 | "command": "npm", 25 | "args": [ 26 | "--silent", 27 | "run", 28 | "test:dev" 29 | ], 30 | "group": { 31 | "kind": "test", 32 | "isDefault": true 33 | }, 34 | "problemMatcher": [ 35 | "$tsc", 36 | "$eslint-compact", 37 | "$eslint-stylish" 38 | ] 39 | }, 40 | { 41 | "type": "docker-build", 42 | "label": "docker-build", 43 | "platform": "node", 44 | "dockerBuild": { 45 | "dockerfile": "${workspaceFolder}/Dockerfile", 46 | "context": "${workspaceFolder}", 47 | "pull": true 48 | } 49 | }, 50 | { 51 | "type": "docker-run", 52 | "label": "docker-run: release", 53 | "dependsOn": [ 54 | "docker-build" 55 | ], 56 | "platform": "node" 57 | }, 58 | { 59 | "type": "docker-run", 60 | "label": "docker-run: debug", 61 | "dependsOn": [ 62 | "docker-build" 63 | ], 64 | "dockerRun": { 65 | "env": { 66 | "DEBUG": "*", 67 | "NODE_ENV": "development" 68 | } 69 | }, 70 | "node": { 71 | "enableDebugging": true 72 | } 73 | } 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | "@waytrade:registry" "https://npm.pkg.github.com" 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # Docker image for production 3 | # 4 | 5 | # 6 | # Stage 1: App build 7 | # 8 | # This is a dedicated stage so that files required for app build do not 9 | # end up on produciton image. 10 | # 11 | 12 | FROM ghcr.io/waytrade/microservice-core/build:latest as build 13 | 14 | WORKDIR /usr/src/app 15 | 16 | COPY . . 17 | 18 | # Build App 19 | RUN yarn install 20 | RUN yarn build 21 | 22 | # Delete build and install production dependencies 23 | RUN rm -f -r ./node_modules 24 | RUN yarn install --production 25 | 26 | 27 | # 28 | # Stage 2: Image creation 29 | # 30 | 31 | # We use waytrade/ib-gateway as base image instead of waytrade/microservice-core 32 | # and install microservice deps manually 33 | FROM waytrade/ib-gateway:1010 34 | # Install node.js and yarn 35 | RUN apt-get update -y 36 | RUN apt-get install -y curl ca-certificates 37 | RUN curl --silent --location https://deb.nodesource.com/setup_14.x | bash - 38 | RUN apt-get install -y nodejs 39 | RUN npm install --global yarn 40 | # ld-linux-x86-64 is required by uWebsocket 41 | RUN ln -s /lib64/ld-linux-x86-64.so.2 /lib/ld-linux-x86-64.so.2 42 | 43 | WORKDIR /usr/src/app 44 | 45 | # Copy files 46 | COPY --from=build /usr/src/app/package.json . 47 | COPY --from=build /usr/src/app/config/ ./config 48 | COPY --from=build /usr/src/app/dist/ ./dist 49 | COPY --from=build /usr/src/app/node_modules ./node_modules 50 | COPY --from=build /usr/src/app/scripts/run_delayed.sh . 51 | RUN chmod a+x ./run_delayed.sh 52 | 53 | # Run app 54 | ENV NODE_ENV=production 55 | CMD ["./run_delayed.sh"] 56 | 57 | EXPOSE 3000 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Interactive Brokers REST/Websock Server (ib-api-service) 2 | 3 | ![GitHub package.json version](https://img.shields.io/github/package-json/v/waytrade/ib-api-service) 4 | [![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/waytrade/ib-api-service.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/waytrade/ib-api-service/context:javascript) 5 | [![Test and Publish](https://github.com/waytrade/ib-api-service/actions/workflows/publish.yml/badge.svg)](https://github.com/waytrade/ib-api-service/actions/workflows/publish.yml) 6 | [![Test Report](https://raw.githubusercontent.com/waytrade/microservice-core/master/assets/test-results.svg)](https://waytrade.github.io/ib-api-service/jest/) 7 | [![Core Coverage](https://raw.githubusercontent.com/waytrade/ib-api-service/gh-pages/coverage/coverage.svg)](https://waytrade.github.io/ib-api-service/coverage/lcov-report) 8 | 9 | A REST/Websocket API Server (with OpenAPI & SwaggerUI) on top of [@stoqey/ib](https://github.com/stoqey/ib) IBApiNext. 10 | 11 | --- 12 | 13 | We use this app as part of our microservice ecosystem and it is OSS so you don't need to write it again. No questions will be answered, no support will be given, no feature-request will be accepted. Use it - or fork it and roll your own :) 14 | 15 | --- 16 | 17 | ## How to use 18 | 19 | Running the server: 20 | 21 | - [by using the prebuilt docker image](doc/run_docker.md) (for quick-start) 22 | - [by building the docker image](doc/build_docker.md) (if you want to build/host your own image) 23 | - [by building App and running it on debugger](doc/debug_app.md) (if you want develop on the App) 24 | 25 | Building a client: 26 | 27 | The ib-api-service interface is completely described via [OpenAPI](https://swagger.io/specification/). It provides a SwaggerUI at '/' and a openapi.json on `/openapi.json` or from [Packages](https://github.com/waytrade/ib-api-service/packages/770607): 28 | 29 | yarn add @waytrade/ib-api-service 30 | 31 | After getting the openapi.json, use your favorite openapi-generator to generate code binding for your client application.\ 32 | Example: to create bindings for Typescript language, using [axios](https://github.com/axios/axios) framework, run: 33 | 34 | openapi-generator-cli generate -i ./node_modules/@waytrade/ib-api-service/openapi.json -g typescript-axios -o ./src/apis/ib-api-service 35 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "SERVER_PORT": 3000, 3 | "SERVER_HOST": "localhost", 4 | "IB_GATEWAY_PORT": 4000, 5 | "IB_GATEWAY_HOST": "localhost", 6 | "IB_GATEWAY_RECONNECT_TRIES": 5, 7 | "LOG_LEVEL": "info", 8 | "BASE_CURRENCY": "EUR" 9 | } 10 | -------------------------------------------------------------------------------- /config/development.json: -------------------------------------------------------------------------------- 1 | { 2 | "SERVER_PORT": 3002, 3 | "REST_API_USERNAME": "test", 4 | "REST_API_PASSWORD": "test", 5 | "IB_GATEWAY_PORT": 4002, 6 | "LOG_LEVEL": "debug" 7 | } 8 | -------------------------------------------------------------------------------- /config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "REST_API_USERNAME": "test", 3 | "REST_API_PASSWORD": "test", 4 | "LOG_LEVEL": "silent" 5 | } 6 | -------------------------------------------------------------------------------- /doc/build_docker.md: -------------------------------------------------------------------------------- 1 | # How to build the docker image 2 | 3 | ## Prepare the system 4 | 5 | - Install [Docker](https://docs.docker.com/get-docker/) 6 | 7 | ## Build 8 | 9 | 1. Clone this repo 10 | 11 | git clone https://github.com/waytrade/ib-api-service.git 12 | cd ib-api-service 13 | 14 | 2. Create a `.npmrc` file\ 15 | `@waytrade` packages are not hosted on **npm.js but on github**.\ 16 | Since Github does not allow unauthenticated access, you first need to create 17 | a personal access token on https://github.com/settings/tokens (make sure 18 | `read:packages` is checked!) and paste it into a .npmrc file with the 19 | following content: 20 | 21 | @waytrade:registry=https://npm.pkg.github.com 22 | //npm.pkg.github.com/:_authToken= 23 | 24 | 3. Create a .env file, containing: 25 | 26 | TWS_USERID= 27 | TWS_PASSWORD= 28 | TRADING_MODE= 29 | VNC_SERVER_PASSWORD= 30 | REST_API_USERNAME= 31 | REST_API_PASSWORD= 32 | 33 | VNC_SERVER_PASSWORD is optional (if not specified, no VNC server for access to IBGateway UI will be started). 34 | 35 | 4. Build the docker container: 36 | 37 | docker-compose up --build 38 | 39 | 5. Wait (~30s) until you see: 40 | 41 | Starting ib-api-service in 5s... 42 | Starting ib-api-service... 43 | INFO: Starting App... 44 | INFO: Booting ib-api-service at port 3000 45 | INFO: [IBApiAutoConnection] Connecting to TWS with client id 0 46 | INFO: IB Gateway host: localhost 47 | INFO: IB Gateway port: 4000 48 | INFO: ib-api-service is running at port 3000 49 | 50 | on console output, open http://localhost:3000 and continue with [How to authenticate on SwaggerUI](swagger_login.md). 51 | -------------------------------------------------------------------------------- /doc/debug_app.md: -------------------------------------------------------------------------------- 1 | # Building and debugging the App 2 | 3 | ## Prepare the system 4 | 5 | - Install [VSCode](https://code.visualstudio.com/) 6 | - Install [node.js](https://nodejs.org/en/download/) 7 | - Install yarn: 8 | 9 | npm install --global yarn 10 | 11 | ## Build 12 | 13 | 1. Clone this repo: 14 | 15 | git clone https://github.com/waytrade/ib-api-service.git 16 | cd ib-api-service 17 | 18 | 2. Create a `.npmrc` file on root folder.\ 19 | `@waytrade` packages are not hosted on **npm.js but on github**.\ 20 | Since Github does not allow unauthenticated access, you first need to create personal access on https://github.com/settings/tokens (make sure `read:packages` is checked!) and paste it into a .npmrc file with the following content: 21 | 22 | @waytrade:registry=https://npm.pkg.github.com 23 | //npm.pkg.github.com/:_authToken= 24 | 25 | 3. Install dependencies: 26 | 27 | yarn install 28 | 29 | 4. Start IBGateway App locally and adapt IB_GATEWAY_PORT on `config/development.json` if needed. 30 | 31 | 5. Open the root folder on on VSCode: 32 | 33 | code . 34 | 35 | 6. Switch to `Run and Debug` tab, select `Launch Program` 36 | 37 | drawing 38 | 39 | 7. Press Ctrl+Shit+B to start the auto-build (will automatically rebuild if code changes) 40 | 41 | 8. Press F5 to start the App on debugger. 42 | -------------------------------------------------------------------------------- /doc/res/auth_bearer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waytrade/ib-api-service/6baef2d6f2b6ded3d2e46293cec224fc7f40801b/doc/res/auth_bearer.png -------------------------------------------------------------------------------- /doc/res/auth_execute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waytrade/ib-api-service/6baef2d6f2b6ded3d2e46293cec224fc7f40801b/doc/res/auth_execute.png -------------------------------------------------------------------------------- /doc/res/auth_tryout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waytrade/ib-api-service/6baef2d6f2b6ded3d2e46293cec224fc7f40801b/doc/res/auth_tryout.png -------------------------------------------------------------------------------- /doc/res/authorize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waytrade/ib-api-service/6baef2d6f2b6ded3d2e46293cec224fc7f40801b/doc/res/authorize.png -------------------------------------------------------------------------------- /doc/res/authorize_bearer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waytrade/ib-api-service/6baef2d6f2b6ded3d2e46293cec224fc7f40801b/doc/res/authorize_bearer.png -------------------------------------------------------------------------------- /doc/res/vscode_debug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waytrade/ib-api-service/6baef2d6f2b6ded3d2e46293cec224fc7f40801b/doc/res/vscode_debug.png -------------------------------------------------------------------------------- /doc/run_docker.md: -------------------------------------------------------------------------------- 1 | # How to run the prebuilt docker image 2 | 3 | ## Prepare the system 4 | 5 | - Install [Docker](https://docs.docker.com/get-docker/) 6 | 7 | ## Run container 8 | 9 | 1. Create a new folder 10 | 11 | mkdir ib-api-service 12 | cd ib-api-service 13 | 14 | 2. Create a .env file, containing: 15 | 16 | TWS_USERID= 17 | TWS_PASSWORD= 18 | TRADING_MODE= 19 | VNC_SERVER_PASSWORD= 20 | REST_API_USERNAME= 21 | REST_API_PASSWORD= 22 | 23 | VNC_SERVER_PASSWORD is optional (if not specified, no VNC server for access to IBGateway UI will be started). 24 | 25 | 3. Create a docker-compose.yml file: 26 | 27 | version: "3.4" 28 | services: 29 | ib-api-service: 30 | image: ghcr.io/waytrade/ib-api-service/production:latest 31 | environment: 32 | SERVER_PORT: 3000 33 | REST_API_USERNAME: ${REST_API_USERNAME} 34 | REST_API_PASSWORD: ${REST_API_PASSWORD} 35 | TWS_USERID: ${TWS_USERID} 36 | TWS_PASSWORD: ${TWS_PASSWORD} 37 | TRADING_MODE: ${TRADING_MODE:-live} 38 | VNC_SERVER_PASSWORD: ${VNC_SERVER_PASSWORD:-} 39 | ports: 40 | - "3000:3000" 41 | - "5900:5900" 42 | 43 | 4. Run the docker container: 44 | 45 | docker-compose up 46 | 47 | 5. Wait (~30s) until you see: 48 | 49 | Starting ib-api-service in 5s... 50 | Starting ib-api-service... 51 | INFO: Starting App... 52 | INFO: Booting ib-api-service at port 3000 53 | INFO: [IBApiAutoConnection] Connecting to TWS with client id 0 54 | INFO: IB Gateway host: localhost 55 | INFO: IB Gateway port: 4000 56 | INFO: ib-api-service is running at port 3000 57 | 58 | on console output, open http://localhost:3000 and continue with [How to authenticate on SwaggerUI](swagger_login.md). 59 | -------------------------------------------------------------------------------- /doc/swagger_login.md: -------------------------------------------------------------------------------- 1 | # How to authenticate on SwaggerUI 2 | 3 | 1. Open SwaggerUI on your browser (e.g. http://localhost:3000), expand the Authentication endpoint and click `Try it out`: 4 | 5 | drawing 6 | 7 | 2. Edit the request body to contain the REST_API_USERNAME / REST_API_PASSWORD credentials from the .env file and click on `Execute`. 8 | 9 | drawing 10 | 11 | 3. Find the authorization header on response and copy the JWT token (that is everything after 'Bearer '): 12 | 13 | drawing 14 | 15 | 4. Scroll to top of page and click the `Authorize` button: 16 | 17 | drawing 18 | 19 | 5. Paste the JWT token from step 3, click `Authorize` and `Close`. 20 | 21 | drawing 22 | 23 | \ 24 | You are now authenticated and can use other API endpoints without getting error 401. 25 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.4" 2 | 3 | services: 4 | ib-api-service: 5 | build: 6 | context: . 7 | dockerfile: ./Dockerfile 8 | environment: 9 | SERVER_PORT: 3000 10 | REST_API_USERNAME: ${REST_API_USERNAME} 11 | REST_API_PASSWORD: ${REST_API_PASSWORD} 12 | TWS_USERID: ${TWS_USERID} 13 | TWS_PASSWORD: ${TWS_PASSWORD} 14 | TRADING_MODE: ${TRADING_MODE:-live} 15 | VNC_SERVER_PASSWORD: ${VNC_SERVER_PASSWORD:-} 16 | ports: 17 | - "3000:3000" 18 | - "5900:5900" 19 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // Eslint max-len is disabled because this file is automatically generated. 2 | // For a detailed explanation regarding each configuration property, visit: 3 | // https://jestjs.io/docs/en/configuration.html 4 | 5 | module.exports = { 6 | // Reporters for CircleCI 7 | // This causes jest to always output JUnit XML. To override this functionality 8 | // we add --reporters=default to Jest commands. 9 | reporters: [ 10 | "default", 11 | "jest-junit", 12 | ["./node_modules/jest-html-reporter", { 13 | "pageTitle": "Test Report" 14 | }], 15 | ], 16 | 17 | // All imported modules in your tests should be mocked automatically 18 | // automock: false, 19 | 20 | // Stop running tests after the first failure 21 | // bail: false, 22 | 23 | // Respect "browser" field in package.json when resolving modules 24 | // browser: false, 25 | 26 | // The directory where Jest should store its cached dependency information 27 | // cacheDirectory: "C:\\Users\\marco\\AppData\\Local\\Temp\\jest", 28 | 29 | // Automatically clear mock calls and instances between every test 30 | // clearMocks: false, 31 | 32 | // Indicates whether the coverage information should be collected while executing the test 33 | collectCoverage: true, 34 | 35 | // An array of glob patterns indicating a set of files for which coverage information should be collected 36 | // collectCoverageFrom: null, 37 | 38 | // The directory where Jest should output its coverage files 39 | coverageDirectory: "test-report/coverage", 40 | 41 | // An array of regexp pattern strings used to skip coverage collection 42 | coveragePathIgnorePatterns: [ 43 | "/src/apis/", 44 | "src/__tests__/" 45 | ], 46 | 47 | // A list of reporter names that Jest uses when writing coverage reports 48 | coverageReporters: [ 49 | //"json", 50 | //"json-summary", 51 | "lcov", 52 | //"html", 53 | //"text", 54 | "text-summary" 55 | ], 56 | 57 | // Report coverage in lcov for Code Climate 58 | // coverageReporters: ["lcov"], 59 | 60 | // An object that configures minimum threshold enforcement for coverage results 61 | // coverageThreshold: null, 62 | 63 | // Make calling deprecated APIs throw helpful error messages 64 | errorOnDeprecated: true, 65 | 66 | // Force coverage collection from ignored files usin a array of glob patterns 67 | // forceCoverageMatch: [], 68 | 69 | // A path to a module which exports an async function that is triggered once before all test suites 70 | // globalSetup: "/src/tests/global-setup.ts", 71 | 72 | // A path to a module which exports an async function that is triggered once after all test suites 73 | // globalTeardown: "/src/tests/global-teardown.ts", 74 | 75 | // A set of global variables that need to be available in all test environments 76 | globals: { 77 | "ts-jest": { 78 | tsconfig: "tsconfig.json" 79 | } 80 | }, 81 | 82 | // An array of directory names to be searched recursively up from the requiring module's location 83 | // moduleDirectories: [ 84 | // "node_modules" 85 | // ], 86 | 87 | // An array of file extensions your modules use 88 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"], 89 | 90 | // A map from regular expressions to module names that allow to stub out resources with a single module 91 | // moduleNameMapper: {}, 92 | 93 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 94 | modulePathIgnorePatterns: ["/dist/"], 95 | 96 | // Activates notifications for test results 97 | // notify: false, 98 | 99 | // An enum that specifies notification mode. Requires { notify: true } 100 | // notifyMode: "always", 101 | 102 | // A preset that is used as a base for Jest's configuration 103 | // preset: null, 104 | 105 | // Run tests from one or more projects 106 | // projects: null, 107 | 108 | // Use this configuration option to add custom reporters to Jest 109 | // reporters: undefined, 110 | 111 | // Automatically reset mock state between every test 112 | // resetMocks: false, 113 | 114 | // Reset the module registry before running each individual test 115 | // resetModules: false, 116 | 117 | // A path to a custom resolver 118 | // resolver: null, 119 | 120 | // Automatically restore mock state between every test 121 | // restoreMocks: false, 122 | 123 | // The root directory that Jest should scan for tests and modules within 124 | // rootDir: null, 125 | 126 | // A list of paths to directories that Jest should use to search for files in 127 | // roots: [ 128 | // "" 129 | // ], 130 | 131 | // Allows you to use a custom runner instead of Jest's default test runner 132 | // runner: "jest-runner", 133 | 134 | // The paths to modules that run some code to configure or set up the testing environment before each test 135 | // setupFiles: [], 136 | 137 | // The path to a module that runs some code to configure or set up the testing framework before each test 138 | // setupTestFrameworkScriptFile: "/src/__tests__/nodb-test-environment.js", 139 | setupFilesAfterEnv: ["/src/__tests__/setup.ts"], 140 | 141 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 142 | // snapshotSerializers: [], 143 | 144 | // The test environment that will be used for testing 145 | testEnvironment: "/src/__tests__/nodb-test-environment.ts", 146 | 147 | // Options that will be passed to the testEnvironment 148 | // testEnvironmentOptions: {}, 149 | 150 | // Adds a location field to test results 151 | // testLocationInResults: false, 152 | 153 | // The glob patterns Jest uses to detect test files 154 | testMatch: [ 155 | // "**/__tests__/**/*.js?(x)", 156 | // "**/?(*.)+(spec|test).ts?(x)", 157 | // "**/__tests__/**/*.+(ts|tsx|js|jsx)" 158 | "**/__tests__/**/?(*.)+(spec|test).(ts|tsx|js|jsx)" 159 | ], 160 | 161 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 162 | // testPathIgnorePatterns: [ 163 | // "\\\\node_modules\\\\" 164 | // ], 165 | 166 | // The regexp pattern Jest uses to detect test files 167 | // testRegex: "", 168 | 169 | // This option allows the use of a custom results processor 170 | testResultsProcessor: "./node_modules/jest-html-reporter", 171 | 172 | // This option allows use of a custom test runner 173 | // testRunner: "jasmine2", 174 | 175 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 176 | // testURL: "http://localhost", 177 | 178 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 179 | // timers: "real", 180 | 181 | // A map from regular expressions to paths to transformers 182 | transform: { 183 | "^.+\\.(js|jsx|ts|tsx)$": "ts-jest" 184 | } 185 | 186 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 187 | // transformIgnorePatterns: [ 188 | // "\\\\node_modules\\\\" 189 | // ], 190 | 191 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 192 | // unmockedModulePathPatterns: undefined, 193 | 194 | // Indicates whether each individual test should be reported during the run 195 | // verbose: null, 196 | 197 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 198 | // watchPathIgnorePatterns: [], 199 | 200 | // Whether to use watchman for file crawling 201 | // watchman: true, 202 | }; 203 | -------------------------------------------------------------------------------- /jesthtmlreporter.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "outputPath": "test-report/jest/index.html" 3 | } 4 | -------------------------------------------------------------------------------- /openapitools.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json", 3 | "spaces": 2, 4 | "generator-cli": { 5 | "version": "5.1.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@waytrade/ib-api-service", 3 | "version": "0.9.32", 4 | "description": "Interactive Brokers API Service", 5 | "author": "Matthias Frener ", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/waytrade/ib-api-service.git" 10 | }, 11 | "files": [ 12 | "openapi.json" 13 | ], 14 | "scripts": { 15 | "clean": "rm -rf dist *.tsbuildinfo .eslintcache", 16 | "build": "yarn clean && yarn tsc", 17 | "build:watch": "tsc --watch", 18 | "lint": "yarn eslint", 19 | "lint:fix": "yarn eslint:fix", 20 | "jest": "rm -rf test-report && jest --maxConcurrency=1 --reporters=default --useStderr --detectOpenHandles --runInBand --verbose --coverage --no-cache", 21 | "jest:ci": "rm -rf test-report && jest --maxConcurrency=1 --reporters=default --useStderr --runInBand --verbose --coverage --no-cache", 22 | "create-lcov-badge": "lcov-badge2 -o ./test-report/coverage/coverage.svg -l \"Code Coverage\" ./test-report/coverage/lcov.info", 23 | "test": "yarn jest && yarn create-lcov-badge", 24 | "test:ci": "yarn jest:ci && yarn create-lcov-badge", 25 | "eslint": "eslint --report-unused-disable-directives .", 26 | "eslint:fix": "yarn eslint --fix", 27 | "start": "node ./dist/run.js", 28 | "preexport-openapi": "yarn build && rm -f openapi.json", 29 | "export-openapi": "node ./dist/export-openapi.js", 30 | "prevalidate-openapi": "yarn export-openapi", 31 | "validate-openapi": "openapi-generator-cli validate -i ./openapi.json", 32 | "release": "yarn lint && yarn validate-openapi && yarn test", 33 | "docker:up": "docker-compose up" 34 | }, 35 | "engines": { 36 | "node": ">=16.13.0" 37 | }, 38 | "dependencies": { 39 | "@waytrade/microservice-core": "^0.9.84", 40 | "@stoqey/ib": "^1.2.17", 41 | "cookie": "^0.4.1", 42 | "jsonwebtoken": "^8.5.1", 43 | "lru-cache": "^6.0.0", 44 | "rxjs": "^7.4.0", 45 | "ws": "^8.3.0" 46 | }, 47 | "devDependencies": { 48 | "@types/cookie": "^0.4.1", 49 | "@types/jest": "^27.0.3", 50 | "@types/node": "^14.15.4", 51 | "@typescript-eslint/eslint-plugin": "^5.6.0", 52 | "@typescript-eslint/parser": "^5.6.0", 53 | "@types/jsonwebtoken": "^8.5.6", 54 | "@types/lru-cache": "^5.1.1", 55 | "@types/bcrypt": "^5.0.0", 56 | "@types/ws": "^8.2.0", 57 | "eslint": "^8.4.1", 58 | "eslint-plugin-rxjs": "^4.0.3", 59 | "jest": "^27.4.3", 60 | "jest-html-reporter": "^3.4.2", 61 | "jest-junit": "^13.0.0", 62 | "lcov-badge2": "^1.0.2", 63 | "source-map-support": "^0.5.21", 64 | "ts-jest": "^27.1.1", 65 | "typescript": "4.5.3" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /scripts/run_delayed.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # start IBGateway 4 | /root/scripts/run.sh & 5 | 6 | echo "Starting ib-api-service in 40s..." 7 | sleep 20 8 | 9 | echo "Starting ib-api-service in 20s..." 10 | sleep 10 11 | 12 | echo "Starting ib-api-service in 10s..." 13 | sleep 5 14 | 15 | echo "Starting ib-api-service in 5s..." 16 | sleep 5 17 | 18 | echo "Starting ib-api-service..." 19 | node ./dist/run.js 20 | -------------------------------------------------------------------------------- /src/__tests__/acceptance/account.controller.test.ts: -------------------------------------------------------------------------------- 1 | import {AccountSummaryTagValues, AccountSummaryValue, AccountSummaryValues, PnLSingle, Position} from '@stoqey/ib'; 2 | import {HttpStatus} from "@waytrade/microservice-core"; 3 | import axios, {AxiosError} from "axios"; 4 | import {BehaviorSubject} from 'rxjs'; 5 | import {AccountList} from "../../models/account-list.model"; 6 | import { 7 | AccountSummary, 8 | AccountSummaryList 9 | } from "../../models/account-summary.model"; 10 | import {PositionList} from '../../models/position-list.model'; 11 | import {wait_ms} from "../helper/test.helper"; 12 | import {IBApiApp} from "../ib-api-test-app"; 13 | 14 | describe("Test Account Controller", () => { 15 | const TEST_USERNAME = "User" + Math.random(); 16 | const TEST_PASSWORD = "Password" + Math.random(); 17 | const TEST_ACCOUNT_ID = "Account" + Math.random(); 18 | const TEST_TOTAL_CASH = Math.random(); 19 | 20 | const app = new IBApiApp(); 21 | 22 | let authToken = ""; 23 | let baseUrl = ""; 24 | 25 | app.ibApiMock.managedAccounts.add(TEST_ACCOUNT_ID); 26 | 27 | const POSITION0: Position = { 28 | account: TEST_ACCOUNT_ID, 29 | pos: Math.random(), 30 | avgCost: Math.random(), 31 | contract: { 32 | conId: Math.random(), 33 | }, 34 | }; 35 | const POSITION0_ID = POSITION0.account + ":" + POSITION0.contract.conId; 36 | 37 | const POSITION1: Position = { 38 | account: TEST_ACCOUNT_ID, 39 | pos: Math.random() + 10, 40 | avgCost: Math.random() + 10, 41 | contract: { 42 | conId: Math.random(), 43 | }, 44 | }; 45 | const POSITION1_ID = POSITION1.account + ":" + POSITION1.contract.conId; 46 | 47 | const POSITIONS: Position[] = [ 48 | POSITION0, 49 | POSITION1 50 | ]; 51 | 52 | const positionsMap = new Map(); 53 | positionsMap.set(TEST_ACCOUNT_ID, POSITIONS); 54 | app.ibApiMock.currentPositionsUpdate.next({ 55 | all: positionsMap, 56 | added: positionsMap, 57 | }); 58 | 59 | let PNL0: PnLSingle = { 60 | position: POSITION0.pos, 61 | marketValue: Math.random(), 62 | dailyPnL: Math.random(), 63 | unrealizedPnL: Math.random(), 64 | realizedPnL: Math.random(), 65 | }; 66 | let PNL1: PnLSingle = { 67 | position: POSITION1.pos, 68 | marketValue: Math.random(), 69 | dailyPnL: Math.random(), 70 | unrealizedPnL: Math.random(), 71 | realizedPnL: Math.random(), 72 | }; 73 | 74 | app.ibApiMock.currentPnLSingle.set( 75 | POSITION0.contract.conId??0, 76 | new BehaviorSubject(PNL0) 77 | ); 78 | app.ibApiMock.currentPnLSingle.set( 79 | POSITION1.contract.conId??0, 80 | new BehaviorSubject(PNL1) 81 | ); 82 | 83 | beforeAll(async () => { 84 | await app.start({ 85 | SERVER_PORT: undefined, 86 | REST_API_USERNAME: TEST_USERNAME, 87 | REST_API_PASSWORD: TEST_PASSWORD, 88 | }); 89 | 90 | baseUrl = `http://127.0.0.1:${app.apiServerPort}/account`; 91 | 92 | const values = new Map([ 93 | [ 94 | "TotalCashValue", 95 | new Map([ 96 | [ 97 | app.config.BASE_CURRENCY ?? "", 98 | { 99 | value: "" + TEST_TOTAL_CASH, 100 | ingressTm: Math.random(), 101 | }, 102 | ], 103 | ]), 104 | ], 105 | ]); 106 | 107 | app.ibApiMock.accountSummaryUpdate.next({ 108 | all: new Map([ 109 | [TEST_ACCOUNT_ID, values], 110 | ]), 111 | changed: new Map([ 112 | [TEST_ACCOUNT_ID, values], 113 | ]), 114 | }); 115 | 116 | authToken = ( 117 | await axios.post( 118 | `http://127.0.0.1:${app.apiServerPort}/auth/password`, 119 | { 120 | username: TEST_USERNAME, 121 | password: TEST_PASSWORD, 122 | }, 123 | ) 124 | ).headers["authorization"] as string; 125 | 126 | await wait_ms(100); 127 | }); 128 | 129 | afterAll(async () => { 130 | app.stop(); 131 | }); 132 | 133 | test("GET /managedAccounts", async () => { 134 | const res = await axios.get(baseUrl + "/managedAccounts", { 135 | headers: { 136 | authorization: authToken, 137 | }, 138 | }); 139 | expect(res.status).toEqual(HttpStatus.OK); 140 | expect(res.data.accounts).toEqual([TEST_ACCOUNT_ID]); 141 | }); 142 | 143 | test("GET /managedAccounts (no authorization)", async () => { 144 | try { 145 | await axios.get(baseUrl + "/managedAccounts", {}); 146 | throw "This must fail"; 147 | } catch (e) { 148 | expect((e).response?.status).toEqual(HttpStatus.UNAUTHORIZED); 149 | } 150 | }); 151 | 152 | test("GET /accountSummaries", async () => { 153 | const res = await axios.get( 154 | baseUrl + "/accountSummaries", 155 | { 156 | headers: { 157 | authorization: authToken, 158 | }, 159 | }, 160 | ); 161 | expect(res.status).toEqual(HttpStatus.OK); 162 | expect(res.data.summaries?.length).toEqual(1); 163 | if (res.data.summaries) { 164 | expect(res.data.summaries[0]?.account).toEqual(TEST_ACCOUNT_ID); 165 | expect(res.data.summaries[0]?.baseCurrency).toEqual(app.config.BASE_CURRENCY); 166 | expect(res.data.summaries[0]?.totalCashValue).toEqual(TEST_TOTAL_CASH); 167 | } 168 | }); 169 | 170 | test("GET /accountSummaries (no authorization)", async () => { 171 | try { 172 | await axios.get(baseUrl + "/accountSummaries", {}); 173 | throw "This must fail"; 174 | } catch (e) { 175 | expect((e).response?.status).toEqual(HttpStatus.UNAUTHORIZED); 176 | } 177 | }); 178 | 179 | test("GET /accountSummary/account", async () => { 180 | const res = await axios.get( 181 | baseUrl + "/accountSummary/" + TEST_ACCOUNT_ID, 182 | { 183 | headers: { 184 | authorization: authToken, 185 | }, 186 | }, 187 | ); 188 | expect(res.status).toEqual(HttpStatus.OK); 189 | expect(res.data.account).toEqual(TEST_ACCOUNT_ID); 190 | expect(res.data.baseCurrency).toEqual(app.config.BASE_CURRENCY); 191 | expect(res.data.totalCashValue).toEqual(TEST_TOTAL_CASH); 192 | }); 193 | 194 | test("GET /accountSummary/account (no account)", async () => { 195 | try { 196 | await axios.get( 197 | baseUrl + "/accountSummary/", 198 | { 199 | headers: { 200 | authorization: authToken, 201 | }, 202 | }, 203 | ); 204 | throw "This must fail"; 205 | } catch (e) { 206 | expect((e).response?.status).toEqual(HttpStatus.NOT_FOUND); 207 | } 208 | }); 209 | 210 | test("GET /accountSummary/account (no authorization)", async () => { 211 | try { 212 | await axios.get( 213 | baseUrl + "/accountSummary/" + TEST_ACCOUNT_ID, 214 | {}, 215 | ); 216 | throw "This must fail"; 217 | } catch (e) { 218 | expect((e).response?.status).toEqual(HttpStatus.UNAUTHORIZED); 219 | } 220 | }); 221 | 222 | test("GET /positions", async () => { 223 | const res = await axios.get( 224 | baseUrl + "/positions", 225 | { 226 | headers: { 227 | authorization: authToken, 228 | }, 229 | }, 230 | ); 231 | expect(res.status).toEqual(HttpStatus.OK); 232 | expect(res.data.positions?.length).toEqual(2); 233 | if (res.data.positions) { 234 | expect(res.data.positions[0]?.id).toEqual(POSITION0_ID); 235 | expect(res.data.positions[0]?.account).toEqual(TEST_ACCOUNT_ID); 236 | expect(res.data.positions[0]?.conId).toEqual(POSITION0.contract.conId); 237 | expect(res.data.positions[0]?.pos).toEqual(POSITION0.pos); 238 | expect(res.data.positions[0]?.dailyPnL).toEqual(PNL0.dailyPnL); 239 | expect(res.data.positions[0]?.unrealizedPnL).toEqual(PNL0.unrealizedPnL); 240 | expect(res.data.positions[0]?.realizedPnL).toEqual(PNL0.realizedPnL); 241 | expect(res.data.positions[0]?.avgCost).toEqual(POSITION0.avgCost); 242 | expect(res.data.positions[0]?.marketValue).toEqual(PNL0.marketValue); 243 | expect(res.data.positions[1]?.id).toEqual(POSITION1_ID); 244 | expect(res.data.positions[1]?.account).toEqual(TEST_ACCOUNT_ID); 245 | expect(res.data.positions[1]?.conId).toEqual(POSITION1.contract.conId); 246 | expect(res.data.positions[1]?.pos).toEqual(POSITION1.pos); 247 | expect(res.data.positions[1]?.dailyPnL).toEqual(PNL1.dailyPnL); 248 | expect(res.data.positions[1]?.unrealizedPnL).toEqual(PNL1.unrealizedPnL); 249 | expect(res.data.positions[1]?.realizedPnL).toEqual(PNL1.realizedPnL); 250 | expect(res.data.positions[1]?.avgCost).toEqual(POSITION1.avgCost); 251 | expect(res.data.positions[1]?.marketValue).toEqual(PNL1.marketValue); 252 | } 253 | }); 254 | }); 255 | -------------------------------------------------------------------------------- /src/__tests__/acceptance/app.test.ts: -------------------------------------------------------------------------------- 1 | import {HttpStatus} from "@waytrade/microservice-core"; 2 | import axios from "axios"; 3 | import {filter, firstValueFrom} from 'rxjs'; 4 | import {IBApiFactoryService} from '../../services/ib-api-factory.service'; 5 | import {wait_ms} from '../helper/test.helper'; 6 | import {IBApiApp} from "../ib-api-test-app"; 7 | 8 | /** 9 | * App test code. 10 | */ 11 | describe("Test App", () => { 12 | test("App boot", () => { 13 | return new Promise(async (resolve, reject) => { 14 | const app = new IBApiApp(); 15 | app 16 | .start({ 17 | SERVER_PORT: undefined, 18 | }) 19 | .then(() => { 20 | axios 21 | .get(`http://127.0.0.1:${app.apiServerPort}/`) 22 | .then(response => { 23 | expect(response.status).toEqual(HttpStatus.OK); 24 | app.stop(); 25 | resolve(); 26 | }) 27 | .catch(error => { 28 | reject(error); 29 | }); 30 | }) 31 | .catch(e => { 32 | reject(e); 33 | }); 34 | }); 35 | }); 36 | 37 | test("Proxy IBApiNext log to App Context", async () => { 38 | let logsReceived = 0; 39 | 40 | const app = new IBApiApp(false); 41 | await app.start({ 42 | LOG_LEVEL: "debug", 43 | SERVER_PORT: undefined, 44 | }); 45 | const api = await (app.getService(IBApiFactoryService)).api 46 | firstValueFrom( 47 | app.debugLog.pipe( 48 | filter(v => v === "[Test] This is a debug log message"), 49 | ), 50 | ).then(() => logsReceived++); 51 | api.logger?.debug("Test", "This is a debug log message"); 52 | app.stop(); 53 | 54 | await app.start({ 55 | LOG_LEVEL: "info", 56 | SERVER_PORT: undefined, 57 | }); 58 | firstValueFrom( 59 | app.infoLog.pipe(filter(v => v === "[Test] This is a info log message")), 60 | ).then(() => logsReceived++); 61 | api.logger?.info("Test", "This is a info log message"); 62 | app.stop(); 63 | 64 | await app.start({ 65 | LOG_LEVEL: "warn", 66 | SERVER_PORT: undefined, 67 | }); 68 | firstValueFrom( 69 | app.warnLog.pipe(filter(v => v === "[Test] This is a warn log message")), 70 | ).then(() => logsReceived++); 71 | api.logger?.warn("Test", "This is a warn log message"); 72 | app.stop(); 73 | 74 | await app.start({ 75 | LOG_LEVEL: "error", 76 | SERVER_PORT: undefined, 77 | }); 78 | firstValueFrom( 79 | app.errorLog.pipe( 80 | filter(v => v === "[Test] This is an error log message"), 81 | ), 82 | ).then(() => logsReceived++); 83 | api.logger?.error("Test", "This is an error log message"); 84 | app.stop(); 85 | 86 | await wait_ms(100) 87 | 88 | expect(logsReceived).toEqual(4); 89 | }); 90 | 91 | test("App boot (no IB_GATEWAY_PORT)", () => { 92 | return new Promise(async (resolve, reject) => { 93 | const app = new IBApiApp(false); 94 | app 95 | .start({ 96 | SERVER_PORT: undefined, 97 | IB_GATEWAY_PORT: undefined, 98 | }) 99 | .then(() => { 100 | reject(); 101 | }) 102 | .catch((e: Error) => { 103 | expect(e.message).toEqual("IB_GATEWAY_PORT not configured."); 104 | resolve(); 105 | }) 106 | .finally(() => { 107 | app.stop(); 108 | }); 109 | }); 110 | }); 111 | 112 | test("App boot (no IB_GATEWAY_HOST)", () => { 113 | return new Promise(async (resolve, reject) => { 114 | const app = new IBApiApp(false); 115 | app 116 | .start({ 117 | SERVER_PORT: undefined, 118 | IB_GATEWAY_HOST: undefined, 119 | }) 120 | .then(() => { 121 | reject(); 122 | }) 123 | .catch((e: Error) => { 124 | expect(e.message).toEqual("IB_GATEWAY_HOST not configured."); 125 | resolve(); 126 | }) 127 | .finally(() => { 128 | app.stop(); 129 | }); 130 | }); 131 | }); 132 | 133 | test("App boot (no REST_API_USERNAME)", () => { 134 | return new Promise(async (resolve, reject) => { 135 | const app = new IBApiApp(false); 136 | app 137 | .start({ 138 | SERVER_PORT: undefined, 139 | REST_API_USERNAME: undefined, 140 | }) 141 | .then(() => { 142 | reject(); 143 | }) 144 | .catch((e: Error) => { 145 | expect(e.message).toEqual("REST_API_USERNAME not configured."); 146 | resolve(); 147 | }) 148 | .finally(() => { 149 | app.stop(); 150 | }); 151 | }); 152 | }); 153 | 154 | test("App boot (no REST_API_PASSWORD)", () => { 155 | return new Promise(async (resolve, reject) => { 156 | const app = new IBApiApp(false); 157 | app 158 | .start({ 159 | SERVER_PORT: undefined, 160 | REST_API_PASSWORD: undefined, 161 | }) 162 | .then(() => { 163 | reject(); 164 | }) 165 | .catch((e: Error) => { 166 | expect(e.message).toEqual("REST_API_PASSWORD not configured."); 167 | resolve(); 168 | }) 169 | .finally(() => { 170 | app.stop(); 171 | }); 172 | }); 173 | }); 174 | 175 | test("App shutdown on connection loss", () => { 176 | return new Promise(async (resolve, reject) => { 177 | const app = new IBApiApp(); 178 | app 179 | .start({ 180 | SERVER_PORT: undefined, 181 | IB_GATEWAY_RECONNECT_TRIES: 2, 182 | }) 183 | .then(async () => { 184 | const api = await (app.getService(IBApiFactoryService)).api 185 | firstValueFrom(app.appStopped).then(() => resolve()); 186 | wait_ms(3000).then(() => { 187 | api.disconnect(); // connecton lost 188 | api.connect(); // 1st re-connect try 189 | api.disconnect(); // connecton lost 190 | api.connect(); // 2nd re-connect try 191 | api.disconnect(); // connecton lost: must exit now 192 | }); 193 | }) 194 | .catch(e => { 195 | reject(e); 196 | }); 197 | }); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /src/__tests__/acceptance/authentication.controller.test.ts: -------------------------------------------------------------------------------- 1 | import {HttpStatus} from "@waytrade/microservice-core"; 2 | import axios, {AxiosError} from "axios"; 3 | import {IBApiApp} from "../ib-api-test-app"; 4 | 5 | /** 6 | * User authentication test code. 7 | */ 8 | describe("Test User Authentication", () => { 9 | const TEST_USERNAME = "User" + Math.random(); 10 | const TEST_PASSWORD = "Password" + Math.random(); 11 | 12 | const app = new IBApiApp(); 13 | 14 | beforeAll(async () => { 15 | await app.start({ 16 | SERVER_PORT: undefined, 17 | REST_API_USERNAME: TEST_USERNAME, 18 | REST_API_PASSWORD: TEST_PASSWORD, 19 | }); 20 | }); 21 | 22 | afterAll(async () => { 23 | app.stop(); 24 | }); 25 | 26 | test("Login with username and password", async () => { 27 | const auth = ( 28 | await axios.post( 29 | `http://127.0.0.1:${app.apiServerPort}/auth/password`, 30 | { 31 | username: TEST_USERNAME, 32 | password: TEST_PASSWORD, 33 | }, 34 | ) 35 | ).headers["authorization"] as string; 36 | 37 | expect(auth.startsWith("Bearer ")).toBeTruthy(); 38 | }); 39 | 40 | test("Login with username and password (wrong credentials)", async () => { 41 | try { 42 | await axios.post( 43 | `http://127.0.0.1:${app.apiServerPort}/auth/password`, 44 | { 45 | username: TEST_USERNAME, 46 | password: "wrong password", 47 | }, 48 | ); 49 | throw "This must fail"; 50 | } catch (e) { 51 | expect((e).response?.status).toEqual(HttpStatus.UNAUTHORIZED); 52 | expect((e).response?.data).toEqual({message: "Wrong username or password"}); 53 | } 54 | }); 55 | 56 | test("Login with username and password (no credentials)", async () => { 57 | try { 58 | await axios.post( 59 | `http://127.0.0.1:${app.apiServerPort}/auth/password`, 60 | {}, 61 | ); 62 | throw "This must fail"; 63 | } catch (e) { 64 | expect((e).response?.status).toEqual(HttpStatus.BAD_REQUEST); 65 | } 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/__tests__/acceptance/contracts.controller.test.ts: -------------------------------------------------------------------------------- 1 | import {Bar, ContractDescription, ContractDetails, OptionType, SecType} from "@stoqey/ib"; 2 | import {HttpStatus} from "@waytrade/microservice-core"; 3 | import axios, {AxiosError} from "axios"; 4 | import {ContractDescriptionList} from '../../models/contract-description.model'; 5 | import {ContractDetailsList} from "../../models/contract-details.model"; 6 | import {BarSize, HistoricDataRequestArguments, WhatToShow} from '../../models/historic-data-request.model'; 7 | import {OHLCBars} from '../../models/ohlc-bar.model'; 8 | import {IBApiApp} from "../ib-api-test-app"; 9 | 10 | describe("Test Contracts Controller", () => { 11 | const app = new IBApiApp(); 12 | let authToken = ""; 13 | let baseUrl = ""; 14 | 15 | const TEST_USERNAME = "User" + Math.random(); 16 | const TEST_PASSWORD = "Password" + Math.random(); 17 | 18 | const REF_CONID = 42; 19 | const REF_CONTRACT_DETAILS: ContractDetails = { 20 | contract: { 21 | conId: REF_CONID, 22 | symbol: "" + Math.random(), 23 | secType: SecType.FUT, 24 | lastTradeDateOrContractMonth: "20211217", 25 | strike: Math.random(), 26 | right: OptionType.Call, 27 | multiplier: Math.random(), 28 | exchange: "" + Math.random(), 29 | currency: "" + Math.random(), 30 | localSymbol: "" + Math.random(), 31 | primaryExch: "" + Math.random(), 32 | tradingClass: "" + Math.random(), 33 | includeExpired: true, 34 | secIdType: "" + Math.random(), 35 | secId: "" + Math.random(), 36 | comboLegsDescription: "" + Math.random(), 37 | }, 38 | marketName: "marketName" + Math.random(), 39 | minTick: Math.random(), 40 | priceMagnifier: Math.random(), 41 | orderTypes: "" + Math.random(), 42 | validExchanges: "" + Math.random(), 43 | underConId: Math.random(), 44 | longName: "longName" + Math.random(), 45 | contractMonth: "20211217 08:30 CST", 46 | industry: "" + Math.random(), 47 | category: "" + Math.random(), 48 | subcategory: "" + Math.random(), 49 | timeZoneId: "CST", 50 | tradingHours: "" + Math.random(), 51 | liquidHours: "" + Math.random(), 52 | evRule: "" + Math.random(), 53 | evMultiplier: Math.random(), 54 | mdSizeMultiplier: Math.random(), 55 | aggGroup: Math.random(), 56 | underSymbol: "" + Math.random(), 57 | underSecType: SecType.FUT, 58 | marketRuleIds: "" + Math.random(), 59 | realExpirationDate: "" + Math.random(), 60 | lastTradeTime: "20211217 08:30 CST", 61 | stockType: "" + Math.random(), 62 | cusip: "" + Math.random(), 63 | ratings: "" + Math.random(), 64 | descAppend: "" + Math.random(), 65 | bondType: "" + Math.random(), 66 | couponType: "" + Math.random(), 67 | callable: true, 68 | putable: true, 69 | coupon: Math.random(), 70 | convertible: true, 71 | maturity: "20311217", 72 | }; 73 | 74 | const REF_CONTRACT_DESC: ContractDescription = { 75 | contract: { 76 | conId: REF_CONID, 77 | symbol: "" + Math.random(), 78 | secType: SecType.FUT, 79 | lastTradeDateOrContractMonth: "20211217", 80 | strike: Math.random(), 81 | right: OptionType.Call, 82 | multiplier: Math.random(), 83 | exchange: "" + Math.random(), 84 | currency: "" + Math.random(), 85 | localSymbol: "" + Math.random(), 86 | primaryExch: "" + Math.random(), 87 | tradingClass: "" + Math.random(), 88 | includeExpired: true, 89 | secIdType: "" + Math.random(), 90 | secId: "" + Math.random(), 91 | comboLegsDescription: "" + Math.random(), 92 | }, 93 | derivativeSecTypes: [SecType.STK, SecType.OPT] 94 | } 95 | 96 | const REF_BARS: Bar[] = [{ 97 | time: "Time" + Math.random(), 98 | open: Math.random(), 99 | high: Math.random(), 100 | low: Math.random(), 101 | close: Math.random(), 102 | volume: Math.random(), 103 | WAP: Math.random(), 104 | count: Math.random(), 105 | }, { 106 | time: "Time" + Math.random(), 107 | open: Math.random(), 108 | high: Math.random(), 109 | low: Math.random(), 110 | close: Math.random(), 111 | volume: Math.random(), 112 | WAP: Math.random(), 113 | count: Math.random(), 114 | }, { 115 | time: "Time" + Math.random(), 116 | open: Math.random(), 117 | high: Math.random(), 118 | low: Math.random(), 119 | close: Math.random(), 120 | volume: Math.random(), 121 | WAP: Math.random(), 122 | count: Math.random(), 123 | } 124 | ] 125 | app.ibApiMock.historicalData.set(REF_CONTRACT_DETAILS.contract.conId??0, REF_BARS); 126 | 127 | beforeAll(async () => { 128 | await app.start({ 129 | SERVER_PORT: undefined, 130 | REST_API_USERNAME: TEST_USERNAME, 131 | REST_API_PASSWORD: TEST_PASSWORD, 132 | }); 133 | 134 | baseUrl = `http://127.0.0.1:${app.apiServerPort}/contracts`; 135 | 136 | authToken = ( 137 | await axios.post( 138 | `http://127.0.0.1:${app.apiServerPort}/auth/password`, 139 | { 140 | username: TEST_USERNAME, 141 | password: TEST_PASSWORD, 142 | }, 143 | ) 144 | ).headers["authorization"] as string; 145 | }); 146 | 147 | afterAll(async () => { 148 | app.stop(); 149 | }); 150 | 151 | test("POST /search", async () => { 152 | app.ibApiMock.searchContractsResult.next( 153 | [REF_CONTRACT_DESC] 154 | ) 155 | 156 | const res = await axios.get( 157 | baseUrl + "/search?pattern=Apple", 158 | { 159 | headers: { 160 | authorization: authToken, 161 | }, 162 | }, 163 | ); 164 | 165 | expect(res.status).toEqual(HttpStatus.OK); 166 | expect(res.data.descs?.length).toEqual(1); 167 | expect(res.data.descs).toEqual([REF_CONTRACT_DESC]); 168 | }); 169 | 170 | test("GET /search (no authorization)", async () => { 171 | try { 172 | await axios.get( 173 | baseUrl + "/search?pattern=Apple", 174 | {}, 175 | ); 176 | throw "This must fail"; 177 | } catch (e) { 178 | expect((e).response?.status).toEqual(HttpStatus.UNAUTHORIZED); 179 | } 180 | }); 181 | 182 | test("GET /search (empty result)", async () => { 183 | app.ibApiMock.searchContractsResult.next( 184 | [] 185 | ) 186 | const res = await axios.get( 187 | baseUrl + "/search?pattern=Apple", 188 | { 189 | headers: { 190 | authorization: authToken, 191 | }, 192 | }, 193 | ); 194 | 195 | expect(res.status).toEqual(HttpStatus.OK); 196 | expect(res.data.descs).toEqual([]); 197 | }); 198 | 199 | test("GET /search (no pattern)", async () => { 200 | try { 201 | await axios.get( 202 | baseUrl + `/search`, 203 | { 204 | headers: { 205 | authorization: authToken, 206 | }, 207 | }, 208 | ); 209 | throw "This must fail"; 210 | } catch (e) { 211 | expect((e).response?.status).toEqual(HttpStatus.BAD_REQUEST); 212 | expect((e).response?.data?.message).toEqual("Missing pattern parameter on query"); 213 | } 214 | }); 215 | 216 | test("GET /search (IBApiNext error)", async () => { 217 | app.ibApiMock.searchContractssError = { 218 | reqId: -1, 219 | code: 0, 220 | error: Error("TEST ERROR") 221 | } 222 | try { 223 | await axios.get( 224 | baseUrl + `/search?pattern=Apple`, 225 | { 226 | headers: { 227 | authorization: authToken, 228 | }, 229 | }, 230 | ); 231 | throw "This must fail"; 232 | } catch (e) { 233 | expect((e).response?.status).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); 234 | expect((e).response?.data?.message).toEqual("TEST ERROR"); 235 | } 236 | }); 237 | 238 | test("GET /detailsById", async () => { 239 | // set on IBApiNext mock 240 | app.ibApiMock.contractDb.set(REF_CONID, REF_CONTRACT_DETAILS); 241 | 242 | var getContractDetailsCalledCount = 0; 243 | app.ibApiMock.getContractDetailsCalled.subscribe({ 244 | next: () => { 245 | getContractDetailsCalledCount++; 246 | }, 247 | }); 248 | 249 | // query (getContractDetailsCalledCount will increment) 250 | const res = await axios.get( 251 | baseUrl + `/detailsById?conId=${REF_CONID}`, 252 | { 253 | headers: { 254 | authorization: authToken, 255 | }, 256 | }, 257 | ); 258 | expect(res.status).toEqual(HttpStatus.OK); 259 | expect(res.data.details).toEqual([REF_CONTRACT_DETAILS]); 260 | 261 | // query again from cache (getContractDetailsCalledCount won't increment) 262 | const cachedRes = await axios.get( 263 | baseUrl + `/detailsById?conId=${REF_CONID}`, 264 | { 265 | headers: { 266 | authorization: authToken, 267 | }, 268 | }, 269 | ); 270 | 271 | expect(cachedRes.status).toEqual(HttpStatus.OK); 272 | expect(cachedRes.data.details).toEqual([REF_CONTRACT_DETAILS]); 273 | 274 | expect(getContractDetailsCalledCount).toEqual(1); 275 | }); 276 | 277 | test("GET /detailsById (no authorization)", async () => { 278 | try { 279 | await axios.get( 280 | baseUrl + `/detailsById?conId=9999999`, 281 | {}, 282 | ); 283 | throw "This must fail"; 284 | } catch (e) { 285 | expect((e).response?.status).toEqual(HttpStatus.UNAUTHORIZED); 286 | } 287 | }); 288 | 289 | test("GET /detailsById (empty result)", async () => { 290 | const res = await axios.get( 291 | baseUrl + `/detailsById?conId=9999999`, 292 | { 293 | headers: { 294 | authorization: authToken, 295 | }, 296 | }, 297 | ); 298 | 299 | expect(res.status).toEqual(HttpStatus.OK); 300 | expect(res.data.details).toEqual([]); 301 | }); 302 | 303 | test("GET /detailsById (invalid conId format)", async () => { 304 | try { 305 | await axios.get( 306 | baseUrl + `/detailsById?conId=thisIsNoConID`, 307 | { 308 | headers: { 309 | authorization: authToken, 310 | }, 311 | }, 312 | ); 313 | throw "This must fail"; 314 | } catch (e) { 315 | expect((e).response?.status).toEqual(HttpStatus.BAD_REQUEST); 316 | } 317 | }); 318 | 319 | test("GET /detailsById (IBApiNext error)", async () => { 320 | app.ibApiMock.getContractDetailsError = { 321 | reqId: -1, 322 | code: 0, 323 | error: Error("TEST ERROR") 324 | } 325 | try { 326 | await axios.get( 327 | baseUrl + `/detailsById?conId=9999999`, 328 | { 329 | headers: { 330 | authorization: authToken, 331 | }, 332 | }, 333 | ); 334 | throw "This must fail"; 335 | } catch (e) { 336 | expect((e).response?.status).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); 337 | expect((e).response?.data?.message).toEqual("TEST ERROR"); 338 | } 339 | }); 340 | 341 | test("POST /details", async () => { 342 | app.ibApiMock.getContractDetailsError = undefined; 343 | const res = await axios.post( 344 | baseUrl + "/details", 345 | { 346 | conId: REF_CONTRACT_DETAILS.contract.conId 347 | }, 348 | { 349 | headers: { 350 | authorization: authToken, 351 | }, 352 | }, 353 | ); 354 | 355 | expect(res.status).toEqual(HttpStatus.OK); 356 | expect(res.data.details?.length).toEqual(1); 357 | expect(res.data.details).toEqual([REF_CONTRACT_DETAILS]); 358 | }); 359 | 360 | test("GET /details (no authorization)", async () => { 361 | try { 362 | await axios.post( 363 | baseUrl + `/details`, 364 | { 365 | conId: REF_CONTRACT_DETAILS.contract.conId 366 | }, 367 | {}, 368 | ); 369 | throw "This must fail"; 370 | } catch (e) { 371 | expect((e).response?.status).toEqual(HttpStatus.UNAUTHORIZED); 372 | } 373 | }); 374 | 375 | test("POST /details (empty result)", async () => { 376 | const res = await axios.post( 377 | baseUrl + "/details", 378 | { 379 | symbol: "" + Math.random(), 380 | }, 381 | { 382 | headers: { 383 | authorization: authToken, 384 | }, 385 | }, 386 | ); 387 | 388 | expect(res.status).toEqual(HttpStatus.OK); 389 | expect(res.data.details?.length).toEqual(0); 390 | }); 391 | 392 | test("GET /details (IBApiNext error)", async () => { 393 | app.ibApiMock.getContractDetailsError = { 394 | reqId: -1, 395 | code: 0, 396 | error: Error("TEST ERROR") 397 | } 398 | try { 399 | await axios.post( 400 | baseUrl + `/details`, 401 | { 402 | symbol: "" + Math.random(), 403 | }, 404 | { 405 | headers: { 406 | authorization: authToken, 407 | }, 408 | }, 409 | ); 410 | throw "This must fail"; 411 | } catch (e) { 412 | expect((e).response?.status).toEqual(HttpStatus.BAD_REQUEST); 413 | expect((e).response?.data?.message).toEqual("TEST ERROR"); 414 | } 415 | }); 416 | 417 | test("POST /historicData", async () => { 418 | app.ibApiMock.getContractDetailsError = undefined; 419 | const res = await axios.post( 420 | baseUrl + "/historicData", 421 | { 422 | conId: REF_CONTRACT_DETAILS.contract.conId, 423 | duration: "52 W", 424 | barSize: BarSize.MINUTES_FIVE, 425 | whatToShow: WhatToShow.TRADES 426 | } as HistoricDataRequestArguments, 427 | { 428 | headers: { 429 | authorization: authToken, 430 | }, 431 | }, 432 | ); 433 | 434 | expect(res.status).toEqual(HttpStatus.OK); 435 | expect(res.data.bars).toEqual(REF_BARS); 436 | }); 437 | 438 | test("POST /historicData (no authorization)", async () => { 439 | try { 440 | await axios.post( 441 | baseUrl + `/historicData`, 442 | { 443 | conId: REF_CONTRACT_DETAILS.contract.conId, 444 | duration: "52 W", 445 | barSize: BarSize.MINUTES_FIVE, 446 | whatToShow: WhatToShow.TRADES 447 | } as HistoricDataRequestArguments, 448 | {}, 449 | ); 450 | throw "This must fail"; 451 | } catch (e) { 452 | expect((e).response?.status).toEqual(HttpStatus.UNAUTHORIZED); 453 | } 454 | }); 455 | 456 | 457 | test("POST /historicData (invalid conId)", async () => { 458 | try { 459 | await axios.post( 460 | baseUrl + "/historicData", 461 | { 462 | conId: 0, 463 | } as HistoricDataRequestArguments, 464 | { 465 | headers: { 466 | authorization: authToken, 467 | }, 468 | }, 469 | ); 470 | throw "This must fail"; 471 | } catch (e) { 472 | expect((e).response?.status).toEqual(HttpStatus.BAD_REQUEST); 473 | } 474 | }); 475 | 476 | test("POST /historicData (no conId)", async () => { 477 | try { 478 | await axios.post( 479 | baseUrl + "/historicData", 480 | {}, 481 | { 482 | headers: { 483 | authorization: authToken, 484 | }, 485 | }, 486 | ); 487 | throw "This must fail"; 488 | } catch (e) { 489 | expect((e).response?.status).toEqual(HttpStatus.BAD_REQUEST); 490 | } 491 | }); 492 | }); 493 | -------------------------------------------------------------------------------- /src/__tests__/acceptance/realtime-accountSummary.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AccountSummariesUpdate, 3 | AccountSummaryTagValues, 4 | AccountSummaryValue, 5 | AccountSummaryValues, 6 | PnL 7 | } from "@stoqey/ib"; 8 | import {MapExt} from "@waytrade/microservice-core"; 9 | import axios from "axios"; 10 | import {BehaviorSubject, ReplaySubject} from 'rxjs'; 11 | import WebSocket from "ws"; 12 | import { 13 | RealtimeDataMessage, 14 | RealtimeDataMessageType 15 | } from "../../models/realtime-data-message.model"; 16 | import {IBApiApp} from "../ib-api-test-app"; 17 | 18 | describe("Test Real-time accountSummaries", () => { 19 | const TEST_USERNAME = "User" + Math.random(); 20 | const TEST_PASSWORD = "Password" + Math.random(); 21 | 22 | const app = new IBApiApp(); 23 | 24 | let authToken = ""; 25 | let streamEndpointUrl = ""; 26 | let authenticatedStreamEndpointUrl = ""; 27 | 28 | const TEST_ACCOUNT = "DU1233445"; 29 | let TEST_NAV = Math.random(); 30 | let TEST_TOTAL_CASH = Math.random(); 31 | let TEST_REALIZED_PNL = Math.random(); 32 | let TEST_UNREALIZED_PNL = Math.random(); 33 | 34 | beforeAll(async () => { 35 | app.ibApiMock.managedAccounts.add(TEST_ACCOUNT); 36 | 37 | await app.start({ 38 | SERVER_PORT: undefined, 39 | REST_API_USERNAME: TEST_USERNAME, 40 | REST_API_PASSWORD: TEST_PASSWORD, 41 | }); 42 | 43 | streamEndpointUrl = `ws://localhost:${app.apiServerPort}/realtime/stream`; 44 | 45 | authToken = ( 46 | await axios.post( 47 | `http://127.0.0.1:${app.apiServerPort}/auth/password`, 48 | { 49 | username: TEST_USERNAME, 50 | password: TEST_PASSWORD, 51 | }, 52 | ) 53 | ).headers["authorization"] as string; 54 | 55 | authenticatedStreamEndpointUrl = 56 | streamEndpointUrl + `?auth=${encodeURI(authToken)}`; 57 | }); 58 | 59 | afterAll(() => { 60 | app.stop(); 61 | }); 62 | 63 | test("Subscribe on 'accountSummary/'", async () => { 64 | return new Promise((resolve, reject) => { 65 | const ws = new WebSocket(authenticatedStreamEndpointUrl); 66 | ws.onerror = err => { 67 | reject(err.message); 68 | }; 69 | 70 | let messagesReceived = 0; 71 | 72 | const TEST_SUMMARIES = new MapExt< 73 | string, 74 | Map 75 | >(); 76 | 77 | function emitSummaryChange( 78 | account: string, 79 | values: AccountSummaryTagValues, 80 | ): void { 81 | const accountValues = TEST_SUMMARIES.getOrAdd( 82 | account, 83 | () => new Map(), 84 | ); 85 | values.forEach((v, k) => { 86 | accountValues.set(k, v); 87 | }); 88 | 89 | app.ibApiMock.accountSummaryUpdate.next({ 90 | all: TEST_SUMMARIES, 91 | changed: new Map([ 92 | [account, values], 93 | ]), 94 | }); 95 | } 96 | 97 | ws.onopen = () => { 98 | ws.send( 99 | JSON.stringify({ 100 | type: RealtimeDataMessageType.Subscribe, 101 | topic: "accountSummary/#", 102 | } as RealtimeDataMessage), 103 | ); 104 | 105 | setTimeout(() => { 106 | emitSummaryChange( 107 | TEST_ACCOUNT, 108 | new Map([ 109 | [ 110 | "NetLiquidation", 111 | new Map([ 112 | [ 113 | app.config.BASE_CURRENCY ?? "", 114 | { 115 | value: "" + TEST_NAV, 116 | ingressTm: Math.random(), 117 | }, 118 | ], 119 | ]), 120 | ], 121 | ]), 122 | ); 123 | }, 10); 124 | }; 125 | 126 | ws.onmessage = event => { 127 | const msg = JSON.parse(event.data.toString()) as RealtimeDataMessage; 128 | switch (messagesReceived) { 129 | case 0: { 130 | expect(msg.topic).toEqual("accountSummary/" + TEST_ACCOUNT); 131 | expect(msg.data?.accountSummary?.baseCurrency).toEqual( 132 | app.config.BASE_CURRENCY, 133 | ); 134 | expect(msg.data?.accountSummary?.account).toEqual(TEST_ACCOUNT); 135 | expect(msg.data?.accountSummary?.netLiquidation).toEqual(TEST_NAV); 136 | 137 | ws.send( 138 | JSON.stringify({ 139 | type: RealtimeDataMessageType.Unsubscribe, 140 | topic: "accountSummary/#", 141 | } as RealtimeDataMessage), 142 | ); 143 | 144 | setTimeout(() => { 145 | ws.send( 146 | JSON.stringify({ 147 | type: RealtimeDataMessageType.Subscribe, 148 | topic: "accountSummary/", 149 | } as RealtimeDataMessage), 150 | ); 151 | }, 50) 152 | break; 153 | } 154 | 155 | case 1: { 156 | expect(msg.topic).toEqual("accountSummary/"); 157 | expect(msg.error?.desc).toEqual( 158 | "invalid topic, account argument missing", 159 | ); 160 | 161 | 162 | ws.send( 163 | JSON.stringify({ 164 | type: RealtimeDataMessageType.Subscribe, 165 | topic: "accountSummary/" + TEST_ACCOUNT, 166 | } as RealtimeDataMessage), 167 | ); 168 | 169 | break; 170 | } 171 | 172 | case 2: { 173 | expect(msg.topic).toEqual("accountSummary/" + TEST_ACCOUNT); 174 | expect(msg.data?.accountSummary?.baseCurrency).toEqual( 175 | app.config.BASE_CURRENCY, 176 | ); 177 | expect(msg.data?.accountSummary?.account).toEqual(TEST_ACCOUNT); 178 | expect(msg.data?.accountSummary?.netLiquidation).toEqual(TEST_NAV); 179 | 180 | TEST_TOTAL_CASH = Math.random(); 181 | 182 | app.ibApiMock.accountSummaryUpdate.error({ 183 | error: { 184 | message: "accountSummary Test Error", 185 | }, 186 | }); 187 | 188 | setTimeout(() => { 189 | app.ibApiMock.accountSummaryUpdate = new ReplaySubject(1); 190 | emitSummaryChange( 191 | TEST_ACCOUNT, 192 | new Map([ 193 | [ 194 | "TotalCashValue", 195 | new Map([ 196 | [ 197 | app.config.BASE_CURRENCY ?? "", 198 | { 199 | value: "" + TEST_TOTAL_CASH, 200 | ingressTm: Math.random(), 201 | }, 202 | ], 203 | ]), 204 | ], 205 | ]), 206 | ); 207 | }, 100); 208 | 209 | break; 210 | } 211 | 212 | case 3: { 213 | expect(msg.topic).toEqual("accountSummary/" + TEST_ACCOUNT); 214 | expect(msg.data?.accountSummary?.totalCashValue).toEqual( 215 | TEST_TOTAL_CASH, 216 | ); 217 | 218 | app.ibApiMock.currentPnL.error({ 219 | error: { 220 | message: "accountSummary Test Error", 221 | }, 222 | }); 223 | 224 | TEST_REALIZED_PNL = Math.random(); 225 | TEST_UNREALIZED_PNL = Math.random(); 226 | 227 | setTimeout(() => { 228 | app.ibApiMock.currentPnL = new BehaviorSubject({ 229 | realizedPnL: TEST_REALIZED_PNL, 230 | unrealizedPnL: TEST_UNREALIZED_PNL, 231 | }); 232 | }, 100); 233 | 234 | break; 235 | } 236 | 237 | case 4: { 238 | expect(msg.topic).toEqual("accountSummary/" + TEST_ACCOUNT); 239 | expect(msg.data?.accountSummary?.account).toEqual(TEST_ACCOUNT); 240 | expect(msg.data?.accountSummary?.realizedPnL).toEqual( 241 | TEST_REALIZED_PNL, 242 | ); 243 | expect(msg.data?.accountSummary?.unrealizedPnL).toEqual( 244 | TEST_UNREALIZED_PNL, 245 | ); 246 | 247 | TEST_TOTAL_CASH = Math.random() 248 | 249 | emitSummaryChange( 250 | TEST_ACCOUNT, 251 | new Map([ 252 | [ 253 | "TotalCashValue", 254 | new Map([ 255 | [ 256 | app.config.BASE_CURRENCY ?? "", 257 | { 258 | value: "" + TEST_TOTAL_CASH, 259 | ingressTm: Math.random(), 260 | }, 261 | ], 262 | ]), 263 | ], 264 | ]), 265 | ); 266 | 267 | break; 268 | } 269 | 270 | case 5: { 271 | expect(msg.topic).toEqual("accountSummary/" + TEST_ACCOUNT); 272 | expect(msg.data?.accountSummary?.account).toEqual(TEST_ACCOUNT); 273 | expect(msg.data?.accountSummary?.totalCashValue).toEqual(TEST_TOTAL_CASH); 274 | 275 | ws.send( 276 | JSON.stringify({ 277 | type: RealtimeDataMessageType.Unsubscribe, 278 | topic: "accountSummary/" + TEST_ACCOUNT, 279 | } as RealtimeDataMessage), 280 | ); 281 | 282 | setTimeout(() => { 283 | emitSummaryChange( 284 | TEST_ACCOUNT, 285 | new Map([ 286 | [ 287 | "SettledCash", 288 | new Map([ 289 | [ 290 | app.config.BASE_CURRENCY ?? "", 291 | { 292 | value: "" + Math.random(), 293 | ingressTm: Math.random(), 294 | }, 295 | ], 296 | ]), 297 | ], 298 | ]), 299 | ); 300 | setTimeout(() => { 301 | resolve(); 302 | }, 50); 303 | }, 10); 304 | 305 | break; 306 | } 307 | 308 | default: 309 | ws.close(); 310 | reject(); 311 | 312 | break; 313 | } 314 | 315 | messagesReceived++; 316 | }; 317 | }); 318 | }); 319 | }); 320 | -------------------------------------------------------------------------------- /src/__tests__/acceptance/realtime-data.controller.test.ts: -------------------------------------------------------------------------------- 1 | import {HttpStatus} from "@waytrade/microservice-core"; 2 | import axios from "axios"; 3 | import WebSocket from "ws"; 4 | import { 5 | RealtimeDataMessage, 6 | RealtimeDataMessageType, 7 | } from "../../models/realtime-data-message.model"; 8 | import {IBApiApp} from "../ib-api-test-app"; 9 | 10 | describe("Test Real-time Data Controller", () => { 11 | const TEST_USERNAME = "User" + Math.random(); 12 | const TEST_PASSWORD = "Password" + Math.random(); 13 | 14 | const app = new IBApiApp(); 15 | 16 | let authToken = ""; 17 | let streamEndpointUrl = ""; 18 | let authenticatedStreamEndpointUrl = ""; 19 | 20 | beforeAll(async () => { 21 | await app.start({ 22 | SERVER_PORT: undefined, 23 | REST_API_USERNAME: TEST_USERNAME, 24 | REST_API_PASSWORD: TEST_PASSWORD, 25 | }); 26 | 27 | streamEndpointUrl = `ws://localhost:${app.apiServerPort}/realtime/stream`; 28 | 29 | authToken = ( 30 | await axios.post( 31 | `http://127.0.0.1:${app.apiServerPort}/auth/password`, 32 | { 33 | username: TEST_USERNAME, 34 | password: TEST_PASSWORD, 35 | }, 36 | ) 37 | ).headers["authorization"] as string; 38 | 39 | authenticatedStreamEndpointUrl = 40 | streamEndpointUrl + `?auth=${encodeURI(authToken)}`; 41 | }); 42 | 43 | afterAll(() => { 44 | app.stop(); 45 | }); 46 | 47 | test("Connect to /stream", () => { 48 | return new Promise((resolve, reject) => { 49 | const ws = new WebSocket(authenticatedStreamEndpointUrl); 50 | ws.onerror = err => { 51 | reject(err.message); 52 | }; 53 | ws.onopen = () => { 54 | resolve(); 55 | }; 56 | }); 57 | }); 58 | 59 | test("Connect to /stream (no auth argument)", () => { 60 | return new Promise((resolve, reject) => { 61 | let errorMessageReceived = false; 62 | const ws = new WebSocket(streamEndpointUrl); 63 | ws.onerror = err => { 64 | reject(err.message); 65 | }; 66 | 67 | ws.onmessage = event => { 68 | const msg = JSON.parse(event.data.toString()) as RealtimeDataMessage; 69 | expect(msg.error?.code).toEqual(HttpStatus.UNAUTHORIZED); 70 | expect(msg.error?.desc).toEqual( 71 | "Authorization header or auth argument missing", 72 | ); 73 | errorMessageReceived = true; 74 | }; 75 | 76 | ws.close = () => { 77 | expect(errorMessageReceived).toBeTruthy(); 78 | resolve(); 79 | }; 80 | }); 81 | }); 82 | 83 | test("Connect to /stream (not authorization)", () => { 84 | return new Promise((resolve, reject) => { 85 | let errorMessageReceived = false; 86 | const ws = new WebSocket( 87 | streamEndpointUrl + `?auth=thisIsNoValieBearerToken`, 88 | ); 89 | ws.onerror = err => { 90 | reject(err.message); 91 | }; 92 | 93 | ws.onmessage = event => { 94 | const msg = JSON.parse(event.data.toString()) as RealtimeDataMessage; 95 | expect(msg.error?.code).toEqual(HttpStatus.UNAUTHORIZED); 96 | expect(msg.error?.desc).toEqual("Not authorized"); 97 | errorMessageReceived = true; 98 | }; 99 | 100 | ws.close = () => { 101 | expect(errorMessageReceived).toBeTruthy(); 102 | 103 | resolve(); 104 | }; 105 | }); 106 | }); 107 | 108 | test("Subscribe to invalid topic", () => { 109 | return new Promise((resolve, reject) => { 110 | const ws = new WebSocket(authenticatedStreamEndpointUrl); 111 | ws.onerror = err => { 112 | reject(err.message); 113 | }; 114 | ws.onopen = () => { 115 | ws.send( 116 | JSON.stringify({ 117 | type: RealtimeDataMessageType.Subscribe, 118 | topic: "invalidTopic", 119 | } as RealtimeDataMessage), 120 | ); 121 | }; 122 | ws.onmessage = event => { 123 | const msg = JSON.parse(event.data.toString()) as RealtimeDataMessage; 124 | expect(msg.topic).toEqual("invalidTopic"); 125 | expect(msg.error?.code).toEqual(HttpStatus.BAD_REQUEST); 126 | expect(msg.error?.desc).toEqual("invalid topic"); 127 | 128 | resolve(); 129 | }; 130 | }); 131 | }); 132 | 133 | test("Send invalid request (no JSON)", () => { 134 | return new Promise((resolve, reject) => { 135 | const ws = new WebSocket(authenticatedStreamEndpointUrl); 136 | ws.onerror = err => { 137 | reject(err.message); 138 | }; 139 | ws.onopen = () => { 140 | ws.send("thisIsNoJSON"); 141 | }; 142 | ws.onmessage = event => { 143 | const msg = JSON.parse(event.data.toString()) as RealtimeDataMessage; 144 | expect(msg.error?.code).toEqual(HttpStatus.BAD_REQUEST); 145 | expect(msg.error?.desc).toEqual( 146 | "Unexpected token h in JSON at position 1", 147 | ); 148 | 149 | resolve(); 150 | }; 151 | }); 152 | }); 153 | 154 | test("Send invalid request (invalid message type)", () => { 155 | return new Promise((resolve, reject) => { 156 | const ws = new WebSocket(authenticatedStreamEndpointUrl); 157 | ws.onerror = err => { 158 | reject(err.message); 159 | }; 160 | ws.onopen = () => { 161 | ws.send( 162 | JSON.stringify({ 163 | type: "invalidType" as RealtimeDataMessageType, 164 | topic: "invalidTopic", 165 | } as RealtimeDataMessage), 166 | ); 167 | }; 168 | ws.onmessage = event => { 169 | const msg = JSON.parse(event.data.toString()) as RealtimeDataMessage; 170 | expect(msg.error?.code).toEqual(HttpStatus.BAD_REQUEST); 171 | expect(msg.error?.desc).toEqual("Invalid message type: invalidType"); 172 | 173 | resolve(); 174 | }; 175 | }); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /src/__tests__/acceptance/realtime-marketdata.test.ts: -------------------------------------------------------------------------------- 1 | import {MarketDataTick, MarketDataUpdate} from "@stoqey/ib"; 2 | import TickType from "@stoqey/ib/dist/api/market/tickType"; 3 | import {HttpStatus} from "@waytrade/microservice-core"; 4 | import axios from "axios"; 5 | import {ReplaySubject} from "rxjs"; 6 | import WebSocket from "ws"; 7 | import { 8 | RealtimeDataMessage, 9 | RealtimeDataMessageType 10 | } from "../../models/realtime-data-message.model"; 11 | import {IBApiApp} from "../ib-api-test-app"; 12 | 13 | describe("Test Real-time marketdata", () => { 14 | const TEST_USERNAME = "User" + Math.random(); 15 | const TEST_PASSWORD = "Password" + Math.random(); 16 | 17 | const app = new IBApiApp(); 18 | 19 | let authToken = ""; 20 | let streamEndpointUrl = ""; 21 | let authenticatedStreamEndpointUrl = ""; 22 | 23 | const TEST_CONID1 = 123451; 24 | const TEST_CONID2 = 123452; 25 | 26 | beforeAll(async () => { 27 | await app.start({ 28 | SERVER_PORT: undefined, 29 | REST_API_USERNAME: TEST_USERNAME, 30 | REST_API_PASSWORD: TEST_PASSWORD, 31 | }); 32 | 33 | streamEndpointUrl = `ws://localhost:${app.apiServerPort}/realtime/stream`; 34 | 35 | authToken = ( 36 | await axios.post( 37 | `http://127.0.0.1:${app.apiServerPort}/auth/password`, 38 | { 39 | username: TEST_USERNAME, 40 | password: TEST_PASSWORD, 41 | }, 42 | ) 43 | ).headers["authorization"] as string; 44 | 45 | authenticatedStreamEndpointUrl = 46 | streamEndpointUrl + `?auth=${encodeURI(authToken)}`; 47 | 48 | app.ibApiMock.contractDb.set(TEST_CONID1, {contract: {conId: TEST_CONID1}}); 49 | app.ibApiMock.contractDb.set(TEST_CONID2, {contract: {conId: TEST_CONID2}}); 50 | }); 51 | 52 | afterAll(() => { 53 | app.stop(); 54 | }); 55 | 56 | test("Subscribe on 'marketdata'", async () => { 57 | return new Promise((resolve, reject) => { 58 | const ws = new WebSocket(authenticatedStreamEndpointUrl); 59 | ws.onerror = err => { 60 | reject(err.message); 61 | }; 62 | 63 | ws.onopen = () => { 64 | ws.send( 65 | JSON.stringify({ 66 | type: RealtimeDataMessageType.Subscribe, 67 | topic: "marketdata/noNumber", 68 | } as RealtimeDataMessage), 69 | ); 70 | }; 71 | 72 | let messagesReceived = 0; 73 | 74 | const TEST_TICKS = new Map(); 75 | function emitTestTick(type: TickType, data: MarketDataTick): void { 76 | TEST_TICKS.set(type, data); 77 | app.ibApiMock.marketDataUpdate.next({ 78 | all: TEST_TICKS, 79 | changed: new Map([[type, data]]), 80 | }); 81 | } 82 | 83 | ws.onmessage = event => { 84 | const msg = JSON.parse(event.data.toString()) as RealtimeDataMessage; 85 | switch (messagesReceived) { 86 | case 0: 87 | expect(msg.topic).toEqual("marketdata/noNumber"); 88 | expect(msg.error?.code).toEqual(HttpStatus.BAD_REQUEST); 89 | expect(msg.error?.desc).toEqual("conId is not a number"); 90 | 91 | ws.send( 92 | JSON.stringify({ 93 | type: RealtimeDataMessageType.Subscribe, 94 | topic: "marketdata/123456789", 95 | } as RealtimeDataMessage), 96 | ); 97 | break; 98 | 99 | case 1: 100 | expect(msg.error?.code).toEqual(HttpStatus.BAD_REQUEST); 101 | expect(msg.topic).toEqual("marketdata/123456789"); 102 | expect(msg.error?.desc).toEqual("conId not found"); 103 | 104 | app.ibApiMock.getContractDetailsError = { 105 | code: 0, 106 | reqId: 0, 107 | error: { 108 | name: "getContractDetailsError test error", 109 | message: "getContractDetailsError test error", 110 | }, 111 | }; 112 | 113 | ws.send( 114 | JSON.stringify({ 115 | type: RealtimeDataMessageType.Subscribe, 116 | topic: "marketdata/123456789", 117 | } as RealtimeDataMessage), 118 | ); 119 | 120 | break; 121 | 122 | case 2: 123 | expect(msg.error?.code).toEqual(HttpStatus.BAD_REQUEST); 124 | expect(msg.topic).toEqual("marketdata/123456789"); 125 | expect(msg.error?.desc).toEqual( 126 | "getContractDetailsError test error", 127 | ); 128 | 129 | delete app.ibApiMock.getContractDetailsError; 130 | 131 | ws.send( 132 | JSON.stringify({ 133 | type: RealtimeDataMessageType.Subscribe, 134 | topic: `marketdata/${TEST_CONID1}`, 135 | } as RealtimeDataMessage), 136 | ); 137 | 138 | setTimeout(() => { 139 | app.ibApiMock.marketDataUpdate.error({ 140 | error: { 141 | message: "marketData Test Error", 142 | }, 143 | }); 144 | }, 10); 145 | 146 | break; 147 | 148 | case 3: 149 | expect(msg.error?.code).toEqual(HttpStatus.BAD_REQUEST); 150 | expect(msg.topic).toEqual(`marketdata/${TEST_CONID1}`); 151 | expect(msg.error?.desc).toEqual("marketData Test Error"); 152 | 153 | app.ibApiMock.marketDataUpdate = 154 | new ReplaySubject(1); 155 | 156 | emitTestTick(TickType.ASK, { 157 | value: Math.random(), 158 | ingressTm: Math.random(), 159 | }); 160 | 161 | ws.send( 162 | JSON.stringify({ 163 | type: RealtimeDataMessageType.Subscribe, 164 | topic: `marketdata/${TEST_CONID1}`, 165 | } as RealtimeDataMessage), 166 | ); 167 | break; 168 | 169 | case 4: 170 | expect(msg.topic).toEqual(`marketdata/${TEST_CONID1}`); 171 | expect(msg.data?.marketdata?.ASK).toEqual( 172 | TEST_TICKS.get(TickType.ASK)?.value, 173 | ); 174 | 175 | // must not trigger a change: 176 | emitTestTick(TickType.ASK, { 177 | value: TEST_TICKS.get(TickType.ASK)?.value, 178 | ingressTm: TEST_TICKS.get(TickType.ASK)?.ingressTm ?? 0, 179 | }); 180 | 181 | setTimeout(() => { 182 | emitTestTick(TickType.BID, { 183 | value: Math.random(), 184 | ingressTm: Math.random(), 185 | }); 186 | }, 50); 187 | 188 | break; 189 | 190 | case 5: 191 | expect(msg.topic).toEqual(`marketdata/${TEST_CONID1}`); 192 | expect(msg.data?.marketdata?.BID).toEqual( 193 | TEST_TICKS.get(TickType.BID)?.value, 194 | ); 195 | 196 | ws.close(); 197 | resolve(); 198 | break; 199 | } 200 | 201 | messagesReceived++; 202 | }; 203 | }); 204 | }); 205 | }); 206 | -------------------------------------------------------------------------------- /src/__tests__/acceptance/realtime-positions.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AccountPositionsUpdate, 3 | IBApiNextError, 4 | PnLSingle, 5 | Position 6 | } from "@stoqey/ib"; 7 | import axios from "axios"; 8 | import {BehaviorSubject, ReplaySubject} from "rxjs"; 9 | import WebSocket from "ws"; 10 | import { 11 | RealtimeDataMessage, 12 | RealtimeDataMessageType 13 | } from "../../models/realtime-data-message.model"; 14 | import {IBApiApp} from "../ib-api-test-app"; 15 | 16 | describe("Test Real-time positions", () => { 17 | const TEST_USERNAME = "User" + Math.random(); 18 | const TEST_PASSWORD = "Password" + Math.random(); 19 | 20 | let authToken = ""; 21 | let streamEndpointUrl = ""; 22 | let authenticatedStreamEndpointUrl = ""; 23 | 24 | const accountId = "Account" + Math.random(); 25 | 26 | const POSITION0: Position = { 27 | account: accountId, 28 | pos: Math.random(), 29 | avgCost: Math.random(), 30 | contract: { 31 | conId: Math.random(), 32 | }, 33 | }; 34 | const POSITION0_ID = POSITION0.account + ":" + POSITION0.contract.conId; 35 | 36 | const POSITION1: Position = { 37 | account: accountId, 38 | pos: Math.random() + 10, 39 | avgCost: Math.random() + 10, 40 | contract: { 41 | conId: Math.random(), 42 | }, 43 | }; 44 | const POSITION1_ID = POSITION1.account + ":" + POSITION1.contract.conId; 45 | 46 | const POSITION2: Position = { 47 | account: accountId, 48 | pos: Math.random() + 100, 49 | avgCost: Math.random() + 100, 50 | contract: { 51 | conId: Math.random(), 52 | }, 53 | }; 54 | const POSITION2_ID = POSITION2.account + ":" + POSITION2.contract.conId; 55 | 56 | const ZERO_POSITION: Position = { 57 | account: accountId, 58 | pos: 0, 59 | avgCost: Math.random(), 60 | contract: { 61 | conId: Math.random(), 62 | }, 63 | }; 64 | 65 | const POSITIONS: Position[] = [ 66 | POSITION0, 67 | POSITION1, 68 | POSITION2, 69 | ZERO_POSITION, 70 | ]; 71 | 72 | const positionsMap = new Map(); 73 | positionsMap.set(accountId, POSITIONS); 74 | 75 | const app = new IBApiApp(); 76 | app.ibApiMock.currentPositionsUpdate.next({ 77 | all: positionsMap, 78 | added: positionsMap, 79 | }); 80 | 81 | 82 | let PNL0: PnLSingle = { 83 | position: POSITION0.pos, 84 | marketValue: Math.random(), 85 | dailyPnL: Math.random(), 86 | unrealizedPnL: Math.random(), 87 | realizedPnL: Math.random(), 88 | }; 89 | let PNL1: PnLSingle = { 90 | position: POSITION1.pos, 91 | marketValue: Math.random(), 92 | dailyPnL: Math.random(), 93 | unrealizedPnL: Math.random(), 94 | realizedPnL: Math.random(), 95 | }; 96 | let PNL2: PnLSingle = { 97 | position: POSITION2.pos, 98 | marketValue: Math.random(), 99 | dailyPnL: Math.random(), 100 | unrealizedPnL: Math.random(), 101 | realizedPnL: Math.random(), 102 | }; 103 | 104 | app.ibApiMock.currentPnLSingle.set( 105 | POSITION0.contract.conId??0, 106 | new BehaviorSubject(PNL0) 107 | ); 108 | app.ibApiMock.currentPnLSingle.set( 109 | POSITION1.contract.conId??0, 110 | new BehaviorSubject(PNL1) 111 | ); 112 | app.ibApiMock.currentPnLSingle.set( 113 | POSITION2.contract.conId??0, 114 | new BehaviorSubject(PNL2) 115 | ); 116 | 117 | beforeAll(async () => { 118 | await app.start({ 119 | SERVER_PORT: undefined, 120 | REST_API_USERNAME: TEST_USERNAME, 121 | REST_API_PASSWORD: TEST_PASSWORD, 122 | }); 123 | 124 | streamEndpointUrl = `ws://localhost:${app.apiServerPort}/realtime/stream`; 125 | 126 | authToken = ( 127 | await axios.post( 128 | `http://127.0.0.1:${app.apiServerPort}/auth/password`, 129 | { 130 | username: TEST_USERNAME, 131 | password: TEST_PASSWORD, 132 | }, 133 | ) 134 | ).headers["authorization"] as string; 135 | 136 | authenticatedStreamEndpointUrl = 137 | streamEndpointUrl + `?auth=${encodeURI(authToken)}`; 138 | }); 139 | 140 | afterAll(() => { 141 | app.stop(); 142 | }); 143 | 144 | test("Subscribe on 'positions'", async () => { 145 | return new Promise((resolve, reject) => { 146 | 147 | let messagesReceived = 0; 148 | 149 | const ws = new WebSocket(authenticatedStreamEndpointUrl); 150 | ws.onerror = err => { 151 | reject(err.message); 152 | }; 153 | 154 | ws.onopen = () => { 155 | ws.send( 156 | JSON.stringify({ 157 | type: RealtimeDataMessageType.Subscribe, 158 | topic: "position/", 159 | } as RealtimeDataMessage), 160 | ); 161 | }; 162 | 163 | ws.onmessage = event => { 164 | const msg = JSON.parse(event.data.toString()) as RealtimeDataMessage; 165 | 166 | switch (messagesReceived) { 167 | case 0: 168 | expect(msg.topic).toBe("position/"); 169 | expect(msg.error?.desc).toEqual( 170 | "invalid topic, only 'position/#' wildcard supported", 171 | ); 172 | 173 | ws.send( 174 | JSON.stringify({ 175 | type: RealtimeDataMessageType.Subscribe, 176 | topic: "position/#", 177 | } as RealtimeDataMessage), 178 | ); 179 | break; 180 | case 1: 181 | expect(msg.topic).toBe("position/" + POSITION0_ID); 182 | expect(msg.data?.position?.account).toEqual(POSITION0.account); 183 | expect(msg.data?.position?.pos).toEqual(POSITION0.pos); 184 | expect(msg.data?.position?.conId).toEqual(POSITION0.contract.conId); 185 | expect(msg.data?.position?.avgCost).toEqual(POSITION0.avgCost); 186 | expect(msg.data?.position?.marketValue).toEqual(PNL0.marketValue); 187 | expect(msg.data?.position?.dailyPnL).toEqual(PNL0.dailyPnL); 188 | expect(msg.data?.position?.unrealizedPnL).toEqual( 189 | PNL0.unrealizedPnL, 190 | ); 191 | expect(msg.data?.position?.realizedPnL).toEqual(PNL0.realizedPnL); 192 | break; 193 | case 2: 194 | expect(msg.topic).toBe("position/" + POSITION1_ID); 195 | expect(msg.data?.position?.account).toEqual(POSITION1.account); 196 | expect(msg.data?.position?.pos).toEqual(POSITION1.pos); 197 | expect(msg.data?.position?.conId).toEqual(POSITION1.contract.conId); 198 | expect(msg.data?.position?.avgCost).toEqual(POSITION1.avgCost); 199 | expect(msg.data?.position?.marketValue).toEqual(PNL1.marketValue); 200 | expect(msg.data?.position?.dailyPnL).toEqual(PNL1.dailyPnL); 201 | expect(msg.data?.position?.unrealizedPnL).toEqual( 202 | PNL1.unrealizedPnL, 203 | ); 204 | expect(msg.data?.position?.realizedPnL).toEqual(PNL1.realizedPnL); 205 | break; 206 | case 3: 207 | expect(msg.topic).toBe("position/" + POSITION2_ID); 208 | expect(msg.data?.position?.account).toEqual(POSITION2.account); 209 | expect(msg.data?.position?.pos).toEqual(POSITION2.pos); 210 | expect(msg.data?.position?.conId).toEqual(POSITION2.contract.conId); 211 | expect(msg.data?.position?.avgCost).toEqual(POSITION2.avgCost); 212 | expect(msg.data?.position?.marketValue).toEqual(PNL2.marketValue); 213 | expect(msg.data?.position?.dailyPnL).toEqual(PNL2.dailyPnL); 214 | expect(msg.data?.position?.unrealizedPnL).toEqual( 215 | PNL2.unrealizedPnL, 216 | ); 217 | expect(msg.data?.position?.realizedPnL).toEqual(PNL2.realizedPnL); 218 | 219 | app.ibApiMock.currentPositionsUpdate.error({ 220 | error: {message: "Test error"}, 221 | } as IBApiNextError); 222 | 223 | setTimeout(() => { 224 | // clear the error on the subjects 225 | app.ibApiMock.currentPositionsUpdate = 226 | new ReplaySubject(1); 227 | app.ibApiMock.currentPositionsUpdate.next({ 228 | all: positionsMap, 229 | added: positionsMap, 230 | }); 231 | 232 | PNL0 = { 233 | position: Math.random(), 234 | }; 235 | app.ibApiMock.currentPnLSingle.get( 236 | POSITION0.contract.conId??0)?.next(PNL0); 237 | }, 10); 238 | 239 | break; 240 | 241 | case 4: 242 | expect(msg.topic).toBe("position/" + POSITION0_ID); 243 | expect(msg.data?.position?.pos).toEqual(PNL0.position); 244 | 245 | PNL1 = { 246 | position: Math.random(), 247 | }; 248 | app.ibApiMock.currentPnLSingle.get( 249 | POSITION1.contract.conId??0)?.next(PNL1); 250 | break; 251 | 252 | case 5: 253 | expect(msg.topic).toBe("position/" + POSITION1_ID); 254 | expect(msg.data?.position?.pos).toEqual(PNL1.position); 255 | 256 | PNL2 = { 257 | position: Math.random(), 258 | }; 259 | app.ibApiMock.currentPnLSingle.get( 260 | POSITION2.contract.conId??0)?.next(PNL2); 261 | break; 262 | 263 | case 6: 264 | expect(msg.topic).toBe("position/" + POSITION2_ID); 265 | expect(msg.data?.position?.pos).toEqual(PNL2.position); 266 | 267 | positionsMap.set(accountId, POSITIONS); 268 | Object.assign(POSITION0, {avgCost: Math.random()}); 269 | app.ibApiMock.currentPositionsUpdate.next({ 270 | all: positionsMap, 271 | changed: new Map([[accountId, [POSITION0]]]), 272 | }); 273 | break; 274 | 275 | case 7: 276 | expect(msg.topic).toBe("position/" + POSITION0_ID); 277 | expect(msg.data?.position?.avgCost).toEqual(POSITION0.avgCost); 278 | 279 | // must not trigger any extra event as no changed attributes 280 | app.ibApiMock.currentPnLSingle.get( 281 | POSITION0.contract.conId??0)?.next(PNL0); 282 | 283 | app.ibApiMock.currentPositionsUpdate.next({ 284 | all: new Map(), 285 | removed: new Map([ 286 | [POSITION0.account, [POSITION0]], 287 | ]), 288 | }); 289 | break; 290 | 291 | case 8: 292 | expect(msg.type).toBe(RealtimeDataMessageType.Unpublish); 293 | expect(msg.topic).toBe("position/" + POSITION0_ID); 294 | 295 | PNL1 = { 296 | position: 0, // zero size positions 297 | }; 298 | app.ibApiMock.currentPnLSingle.get( 299 | POSITION1.contract.conId??0)?.next(PNL1); 300 | 301 | PNL2 = { 302 | position: 0, // zero size positions 303 | }; 304 | app.ibApiMock.currentPnLSingle.get( 305 | POSITION2.contract.conId??0)?.next(PNL2); 306 | 307 | break; 308 | 309 | case 9: 310 | expect(msg.type).toBe(RealtimeDataMessageType.Unpublish); 311 | expect(msg.topic).toBe("position/" + POSITION1_ID); 312 | break; 313 | 314 | case 10: 315 | expect(msg.type).toBe(RealtimeDataMessageType.Unpublish); 316 | expect(msg.topic).toBe("position/" + POSITION2_ID); 317 | 318 | PNL1 = { 319 | position: 3, 320 | }; 321 | app.ibApiMock.currentPnLSingle.get( 322 | POSITION1.contract.conId??0)?.next(PNL1); 323 | 324 | PNL2 = { 325 | position: 4, 326 | }; 327 | app.ibApiMock.currentPnLSingle.get( 328 | POSITION2.contract.conId??0)?.next(PNL2); 329 | break; 330 | 331 | case 11: 332 | expect(msg.topic).toBe("position/" + POSITION1_ID); 333 | expect(msg.data?.position?.pos).toEqual(PNL1.position); 334 | break; 335 | 336 | case 12: 337 | expect(msg.topic).toBe("position/" + POSITION2_ID); 338 | expect(msg.data?.position?.pos).toEqual(PNL2.position); 339 | 340 | Object.assign(POSITION2, {avgCost: Math.random()}); 341 | app.ibApiMock.currentPositionsUpdate.next({ 342 | all: positionsMap, 343 | changed: new Map([[accountId, [POSITION2]]]), 344 | }); 345 | break; 346 | 347 | case 13: 348 | expect(msg.topic).toBe("position/" + POSITION2_ID); 349 | expect(msg.data?.position?.avgCost).toBe(POSITION2.avgCost); 350 | 351 | Object.assign(POSITION2, {pos: 0}); 352 | app.ibApiMock.currentPositionsUpdate.next({ 353 | all: positionsMap, 354 | changed: new Map([[accountId, [POSITION2]]]), 355 | }); 356 | break; 357 | 358 | case 14: 359 | expect(msg.type).toBe(RealtimeDataMessageType.Unpublish); 360 | expect(msg.topic).toBe("position/" + POSITION2_ID); 361 | 362 | resolve(); 363 | 364 | break; 365 | } 366 | 367 | messagesReceived++; 368 | }; 369 | }); 370 | }); 371 | }); 372 | -------------------------------------------------------------------------------- /src/__tests__/helper/test.helper.ts: -------------------------------------------------------------------------------- 1 | export function wait_ms(ms: number): Promise { 2 | return new Promise(res => { 3 | setTimeout(() => res(), ms); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /src/__tests__/ib-api-test-app.ts: -------------------------------------------------------------------------------- 1 | import {IBApiNext} from '@stoqey/ib'; 2 | import {DefaultMicroserviceComponentFactory} from '@waytrade/microservice-core'; 3 | import {Subject} from "rxjs"; 4 | import * as App from "../app"; 5 | import {IBApiNextMock} from "./mock/ib-api-next.mock"; 6 | 7 | const ibApiNetMock = new IBApiNextMock() 8 | 9 | class IBApiFactoryServiceMock { 10 | get api(): Promise { 11 | return new Promise(resolve => resolve(ibApiNetMock as IBApiNext)); 12 | } 13 | } 14 | 15 | class MockComponentFactory extends DefaultMicroserviceComponentFactory { 16 | create(type: unknown): unknown { 17 | if ((type).name === "IBApiFactoryService") { 18 | return new IBApiFactoryServiceMock() 19 | } 20 | return super.create(type) 21 | } 22 | } 23 | 24 | /** IBApiApp with mocked IBApiNext */ 25 | export class IBApiApp extends App.IBApiApp { 26 | constructor(mockIbApi = true) { 27 | super(mockIbApi ? new MockComponentFactory() : new DefaultMicroserviceComponentFactory()); 28 | } 29 | 30 | readonly appStopped = new Subject(); 31 | 32 | get ibApiMock(): IBApiNextMock { 33 | return ibApiNetMock 34 | } 35 | 36 | /** Stop the app. */ 37 | stop(): void { 38 | super.stop(); 39 | this.appStopped.next(); 40 | } 41 | 42 | readonly debugLog = new Subject(); 43 | 44 | debug(msg: string, ...args: unknown[]): void { 45 | this.debugLog.next(msg); 46 | } 47 | 48 | readonly infoLog = new Subject(); 49 | 50 | info(msg: string, ...args: unknown[]): void { 51 | this.infoLog.next(msg); 52 | } 53 | 54 | readonly warnLog = new Subject(); 55 | 56 | warn(msg: string, ...args: unknown[]): void { 57 | this.warnLog.next(msg); 58 | } 59 | 60 | readonly errorLog = new Subject(); 61 | 62 | error(msg: string, ...args: unknown[]): void { 63 | this.errorLog.next(msg); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/__tests__/mock/ib-api-next.mock.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AccountPositionsUpdate, 3 | AccountSummariesUpdate, Bar, BarSizeSetting, 4 | ConnectionState, 5 | Contract, 6 | ContractDescription, 7 | ContractDetails, IBApiNextCreationOptions, 8 | IBApiNextError, 9 | Logger, 10 | MarketDataType, 11 | MarketDataUpdate, 12 | PnL, 13 | PnLSingle 14 | } from "@stoqey/ib"; 15 | import {BehaviorSubject, firstValueFrom, Observable, ReplaySubject, Subject} from "rxjs"; 16 | 17 | /** 18 | * Mock implementation for @stoqey/ib's IBApiNext that will 19 | * return pre-configurd values. 20 | * 21 | * It is used by testing code to avoid the need of having a read IB account 22 | * and connection TWS for running the test codes. 23 | */ 24 | export class IBApiNextMock { 25 | constructor(private options?: IBApiNextCreationOptions) {} 26 | 27 | get logger(): Logger | undefined { 28 | return this.options?.logger; 29 | } 30 | 31 | private _connectionState = new BehaviorSubject( 32 | ConnectionState.Disconnected, 33 | ); 34 | 35 | get connectionState(): Observable { 36 | return this._connectionState; 37 | } 38 | 39 | connect(clientId?: number): IBApiNextMock { 40 | this._connectionState.next(ConnectionState.Connecting); 41 | this._connectionState.next(ConnectionState.Connected); 42 | return this; 43 | } 44 | 45 | disconnect(): IBApiNextMock { 46 | if (this._connectionState.value !== ConnectionState.Disconnected) { 47 | this._connectionState.next(ConnectionState.Disconnected); 48 | } 49 | return this; 50 | } 51 | 52 | readonly searchContractsResult = new ReplaySubject(1); 53 | searchContractssError?: IBApiNextError; 54 | 55 | searchContracts(pattern: string): Promise { 56 | if (this.searchContractssError) { 57 | throw this.searchContractssError 58 | } 59 | return firstValueFrom(this.searchContractsResult); 60 | } 61 | 62 | readonly contractDb = new Map(); 63 | readonly getContractDetailsCalled = new Subject(); 64 | getContractDetailsError?: IBApiNextError; 65 | 66 | async getContractDetails(contract: Contract): Promise { 67 | if (this.getContractDetailsError) { 68 | throw this.getContractDetailsError 69 | } 70 | 71 | this.getContractDetailsCalled.next(contract); 72 | const details = this.contractDb.get(contract.conId ?? 0); 73 | return details ? [details] : []; 74 | } 75 | 76 | readonly managedAccounts = new Set(); 77 | 78 | async getManagedAccounts(): Promise { 79 | return Array.from(this.managedAccounts); 80 | } 81 | 82 | accountSummaryUpdate = new ReplaySubject(1); 83 | 84 | getAccountSummary( 85 | group: string, 86 | tags: string, 87 | ): Observable { 88 | return this.accountSummaryUpdate; 89 | } 90 | 91 | currentPnL = new BehaviorSubject({}); 92 | 93 | getPnL(account: string, model?: string): Observable { 94 | return this.currentPnL; 95 | } 96 | 97 | currentPositionsUpdate = new ReplaySubject(1); 98 | 99 | getPositions(): Observable { 100 | return this.currentPositionsUpdate; 101 | } 102 | 103 | currentPnLSingle = new Map>(); 104 | 105 | getPnLSingle( 106 | account: string, 107 | modelCode: string, 108 | conId: number, 109 | ): Observable { 110 | return this.currentPnLSingle.get(conId) ?? new ReplaySubject(1) 111 | } 112 | 113 | readonly setMarketDataTypeCalled = new ReplaySubject(1); 114 | 115 | setMarketDataType(type: MarketDataType): void { 116 | this.setMarketDataTypeCalled.next(type); 117 | } 118 | 119 | marketDataUpdate = new ReplaySubject(1); 120 | 121 | getMarketData( 122 | contract: Contract, 123 | genericTickList: string, 124 | snapshot: boolean, 125 | regulatorySnapshot: boolean, 126 | ): Observable { 127 | return this.marketDataUpdate; 128 | } 129 | 130 | readonly historicalData = new Map() 131 | 132 | async getHistoricalData( 133 | contract: Contract, 134 | endDateTime: string | undefined, 135 | durationStr: string, 136 | barSizeSetting: BarSizeSetting, 137 | whatToShow: string, 138 | useRTH: number, 139 | formatDate: number 140 | ): Promise { 141 | const res = this.historicalData.get(contract.conId??0) 142 | if (!res) { 143 | throw { 144 | error: new Error("conId not found") 145 | } 146 | } 147 | return res; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/__tests__/nodb-test-environment.ts: -------------------------------------------------------------------------------- 1 | const NodeEnvironment = require("jest-environment-node"); 2 | 3 | require("dotenv").config(); 4 | 5 | module.exports = NodeEnvironment; 6 | -------------------------------------------------------------------------------- /src/__tests__/setup.ts: -------------------------------------------------------------------------------- 1 | import {CustomConsole, LogMessage, LogType} from "@jest/console"; 2 | 3 | function simpleFormatter(type: LogType, message: LogMessage): string { 4 | const TITLE_INDENT = " "; 5 | const CONSOLE_INDENT = TITLE_INDENT + " "; 6 | 7 | return message 8 | .split(/\n/) 9 | .map(line => CONSOLE_INDENT + line) 10 | .join("\n"); 11 | } 12 | 13 | global.console = new CustomConsole( 14 | process.stdout, 15 | process.stderr, 16 | simpleFormatter, 17 | ); 18 | -------------------------------------------------------------------------------- /src/__tests__/unit/ib.helper.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AccountSummaryValue, 3 | AccountSummaryValues, 4 | IBApiNextTickType, 5 | } from "@stoqey/ib"; 6 | import TickType from "@stoqey/ib/dist/api/market/tickType"; 7 | import {MapExt} from "@waytrade/microservice-core"; 8 | import {AccountSummary} from "../../models/account-summary.model"; 9 | import {IBApiServiceHelper} from "../../utils/ib.helper"; 10 | 11 | describe("Test IBApiServiceHelper", () => { 12 | test("Test formatPositionId", () => { 13 | const account = "TestAccount" + Math.random(); 14 | const conId = Math.random(); 15 | 16 | expect(IBApiServiceHelper.formatPositionId(account, conId)).toEqual( 17 | `${account}:${conId}`, 18 | ); 19 | }); 20 | 21 | test("Test marketDataTicksToModel", () => { 22 | let testVal = Math.random(); 23 | expect( 24 | IBApiServiceHelper.marketDataTicksToModel( 25 | new Map([ 26 | [ 27 | TickType.ASK, 28 | { 29 | value: testVal, 30 | ingressTm: Math.random(), 31 | }, 32 | ], 33 | ]), 34 | ), 35 | ).toEqual({ASK: testVal}); 36 | 37 | testVal = Math.random(); 38 | expect( 39 | IBApiServiceHelper.marketDataTicksToModel( 40 | new Map([ 41 | [ 42 | TickType.DELAYED_ASK, 43 | { 44 | value: testVal, 45 | ingressTm: Math.random(), 46 | }, 47 | ], 48 | ]), 49 | ), 50 | ).toEqual({ASK: testVal}); 51 | 52 | testVal = Math.random(); 53 | expect( 54 | IBApiServiceHelper.marketDataTicksToModel( 55 | new Map([ 56 | [ 57 | IBApiNextTickType.ASK_OPTION_DELTA, 58 | { 59 | value: testVal, 60 | ingressTm: Math.random(), 61 | }, 62 | ], 63 | ]), 64 | ), 65 | ).toEqual({ASK_OPTION_DELTA: testVal}); 66 | 67 | testVal = Math.random(); 68 | expect( 69 | IBApiServiceHelper.marketDataTicksToModel( 70 | new Map([ 71 | [ 72 | IBApiNextTickType.DELAYED_ASK_OPTION_DELTA, 73 | { 74 | value: testVal, 75 | ingressTm: Math.random(), 76 | }, 77 | ], 78 | ]), 79 | ), 80 | ).toEqual({ASK_OPTION_DELTA: testVal}); 81 | 82 | expect( 83 | IBApiServiceHelper.marketDataTicksToModel( 84 | new Map([ 85 | [ 86 | 999999999, 87 | { 88 | ingressTm: Math.random(), 89 | }, 90 | ], 91 | ]), 92 | ), 93 | ).toEqual({}); 94 | 95 | testVal = Math.random(); 96 | expect( 97 | IBApiServiceHelper.marketDataTicksToModel( 98 | new Map([ 99 | [ 100 | 999999999, 101 | { 102 | value: testVal, 103 | ingressTm: Math.random(), 104 | }, 105 | ], 106 | ]), 107 | ), 108 | ).toEqual({}); 109 | }); 110 | 111 | test("Test colllectAccountSummaryTagValues", () => { 112 | const account = "TestAccount" + Math.random(); 113 | const currency = "USD"; 114 | const value = Math.random(); 115 | let tagValues = new Map([ 116 | [ 117 | "NetLiquidation", 118 | new Map([ 119 | [ 120 | currency, 121 | { 122 | value: "" + value, 123 | ingressTm: Math.random(), 124 | }, 125 | ], 126 | ]), 127 | ], 128 | ]); 129 | let all = new MapExt(); 130 | IBApiServiceHelper.colllectAccountSummaryTagValues( 131 | account, 132 | tagValues, 133 | currency, 134 | all, 135 | ); 136 | 137 | expect(all.get(account)?.account).toEqual(account); 138 | expect(all.get(account)?.netLiquidation).toEqual(value); 139 | 140 | tagValues = new Map([ 141 | [ 142 | "invalidTag", 143 | new Map([ 144 | [ 145 | currency, 146 | { 147 | value: "" + value, 148 | ingressTm: Math.random(), 149 | }, 150 | ], 151 | ]), 152 | ], 153 | ]); 154 | all = new MapExt(); 155 | IBApiServiceHelper.colllectAccountSummaryTagValues( 156 | account, 157 | tagValues, 158 | currency, 159 | all, 160 | ); 161 | 162 | expect(all.size).toEqual(0); 163 | 164 | tagValues = new Map([ 165 | [ 166 | "NetLiquidation", 167 | new Map([ 168 | [ 169 | "OtherCurrency", 170 | { 171 | value: "" + value, 172 | ingressTm: Math.random(), 173 | }, 174 | ], 175 | ]), 176 | ], 177 | ]); 178 | all = new MapExt(); 179 | IBApiServiceHelper.colllectAccountSummaryTagValues( 180 | account, 181 | tagValues, 182 | currency, 183 | all, 184 | ); 185 | 186 | expect(all.size).toEqual(0); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /src/__tests__/unit/security.utils.test.ts: -------------------------------------------------------------------------------- 1 | import {HttpError, HttpStatus, MicroserviceRequest} from "@waytrade/microservice-core"; 2 | import Cookie from "cookie"; 3 | import {SecurityUtils} from "../../utils/security.utils"; 4 | 5 | describe("Test SecurityUtils", () => { 6 | test("Test ensureAuthorization (authorization header)", () => { 7 | const jwt = SecurityUtils.createJWT(); 8 | 9 | const headers = new Map(); 10 | headers.set("authorization", `Bearer ${jwt}`); 11 | 12 | SecurityUtils.ensureAuthorization({ 13 | headers, 14 | }); 15 | }); 16 | 17 | test("Test ensureAuthorization (cookie header)", () => { 18 | const jwt = SecurityUtils.createJWT(); 19 | 20 | const headers = new Map(); 21 | headers.set("cookie", Cookie.serialize("authorization", `Bearer ${jwt}`)); 22 | 23 | SecurityUtils.ensureAuthorization({ 24 | headers, 25 | }); 26 | }); 27 | 28 | test("Test ensureAuthorization (no authorization header)", () => { 29 | const headers = new Map(); 30 | 31 | let hasThrown = false; 32 | try { 33 | SecurityUtils.ensureAuthorization({ 34 | headers, 35 | }); 36 | } catch (e) { 37 | expect((e).code).toEqual(HttpStatus.UNAUTHORIZED); 38 | expect((e).message).toEqual("Missing authorization header"); 39 | hasThrown = true; 40 | } 41 | 42 | expect(hasThrown).toBeTruthy(); 43 | }); 44 | 45 | test("Test ensureAuthorization (invalid Bearer token)", () => { 46 | const headers = new Map(); 47 | headers.set("authorization", `thisIsNoValidBearer`); 48 | 49 | let hasThrown = false; 50 | try { 51 | SecurityUtils.ensureAuthorization({ 52 | headers, 53 | }); 54 | } catch (e) { 55 | expect((e).code).toEqual(HttpStatus.UNAUTHORIZED); 56 | expect((e).message).toEqual( 57 | "Invalid bearer token", 58 | ); 59 | hasThrown = true; 60 | } 61 | 62 | expect(hasThrown).toBeTruthy(); 63 | }); 64 | 65 | test("Test ensureAuthorization (invalid JWT token)", () => { 66 | const headers = new Map(); 67 | headers.set("authorization", `Bearer thisIsNoValidJWT`); 68 | 69 | let hasThrown = false; 70 | try { 71 | SecurityUtils.ensureAuthorization({ 72 | headers, 73 | }); 74 | } catch (e) { 75 | expect((e).code).toEqual(HttpStatus.UNAUTHORIZED); 76 | expect((e).message).toEqual("Invalid bearer token"); 77 | hasThrown = true; 78 | } 79 | 80 | expect(hasThrown).toBeTruthy(); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import {DefaultMicroserviceComponentFactory, MicroserviceApp, MicroserviceComponentFactory} from "@waytrade/microservice-core"; 2 | import path from "path"; 3 | import {IBApiServiceConfig} from "./config"; 4 | import {AccountController} from "./controllers/account.controller"; 5 | import {AuthenticatonController} from "./controllers/authentication.controller"; 6 | import {ContractsController} from "./controllers/contracts.controller"; 7 | import {RealtimeDataController} from "./controllers/realtime-data.controller"; 8 | import {AuthenticationService} from "./services/authentication.service"; 9 | import {IBApiFactoryService} from "./services/ib-api-factory.service"; 10 | import {IBApiService} from "./services/ib-api.service"; 11 | import {SecurityUtils} from "./utils/security.utils"; 12 | 13 | /** 14 | * The Interactive Brokers TWS API service App. 15 | */ 16 | export class IBApiApp extends MicroserviceApp { 17 | constructor(componentFactory: MicroserviceComponentFactory = 18 | new DefaultMicroserviceComponentFactory()) { 19 | super(path.resolve(__dirname, ".."), { 20 | apiControllers: [ 21 | AuthenticatonController, 22 | ContractsController, 23 | AccountController, 24 | RealtimeDataController, 25 | ], 26 | services: [AuthenticationService, IBApiService, IBApiFactoryService], 27 | }, componentFactory); 28 | } 29 | 30 | /** Called when the app shall boot up. */ 31 | protected async boot(): Promise { 32 | this.info(`Booting ib-api-service at port ${this.config.SERVER_PORT}`); 33 | this.info(`IB Gateway host: ${this.config.IB_GATEWAY_HOST}`); 34 | this.info(`IB Gateway port: ${this.config.IB_GATEWAY_PORT}`); 35 | } 36 | 37 | /** Called when the microservice has been started. */ 38 | onStarted(): void { 39 | this.info(`ib-api-service is running at port ${this.config.SERVER_PORT}`); 40 | } 41 | 42 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 43 | protected onVerifyBearerAuth = (token: string, scopes: string[]): boolean => { 44 | return SecurityUtils.vefiyBearer(token); 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import {MicroserviceConfig} from "@waytrade/microservice-core"; 2 | 3 | /** 4 | * Configuration of a Saurons AI microservice. 5 | */ 6 | export interface IBApiServiceConfig extends MicroserviceConfig { 7 | /** Port of the IB Gateway. */ 8 | IB_GATEWAY_PORT?: number; 9 | 10 | /** Host of the IB Gateway. */ 11 | IB_GATEWAY_HOST?: string; 12 | 13 | /** 14 | * Number of re-connect tries when connection to IB Gateway drops, 15 | * before the App will shutdown. 16 | */ 17 | IB_GATEWAY_RECONNECT_TRIES?: number; 18 | 19 | /** The username for login on the REST API. */ 20 | REST_API_USERNAME?: string; 21 | 22 | /** The password for login on the REST API. */ 23 | REST_API_PASSWORD?: string; 24 | 25 | /** 26 | * Base currency of the IB account(s). 27 | * Multiple linked accounts with different base currency are not supported (yet?). 28 | */ 29 | BASE_CURRENCY?: string; 30 | } 31 | -------------------------------------------------------------------------------- /src/controllers/account.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | bearerAuth, 3 | controller, 4 | description, 5 | get, HttpError, HttpStatus, 6 | inject, 7 | MicroserviceRequest, 8 | pathParameter, 9 | response, 10 | responseBody, 11 | summary 12 | } from "@waytrade/microservice-core"; 13 | import {firstValueFrom} from "rxjs"; 14 | import {AccountList} from "../models/account-list.model"; 15 | import { 16 | AccountSummary, 17 | AccountSummaryList 18 | } from "../models/account-summary.model"; 19 | import {PositionList} from "../models/position-list.model"; 20 | import {IBApiService} from "../services/ib-api.service"; 21 | 22 | /** The account information controller. */ 23 | @controller("Account", "/account") 24 | export class AccountController { 25 | @inject("IBApiService") 26 | private apiService!: IBApiService; 27 | 28 | @get("/managedAccounts") 29 | @summary("Get the managed accounts.") 30 | @description("Get the accounts to which the logged user has access to.") 31 | @response(HttpStatus.UNAUTHORIZED, "Missing or invalid authorization header.") 32 | @responseBody(AccountList) 33 | @bearerAuth([]) 34 | async getManagedAccounts(req: MicroserviceRequest): Promise { 35 | return { 36 | accounts: await this.apiService.managedAccounts, 37 | }; 38 | } 39 | 40 | @get("/accountSummaries") 41 | @summary("Get a snapshot the account summaries.") 42 | @description( 43 | "Get a snapshot of the account summaries of all managed accounts.
" + 44 | "Use Real-time Data endpoint for receiving realtime updates.", 45 | ) 46 | @response(HttpStatus.UNAUTHORIZED, "Missing or invalid authorization header.") 47 | @responseBody(AccountSummaryList) 48 | @bearerAuth([]) 49 | async getAccountSummaries( 50 | req: MicroserviceRequest, 51 | ): Promise { 52 | return { 53 | summaries: await firstValueFrom(this.apiService.accountSummaries) 54 | }; 55 | } 56 | 57 | @get("/accountSummary/{account}") 58 | @pathParameter("account", String, "The account id") 59 | @summary("Get a snapshot of a account summary.") 60 | @description( 61 | "Get a snapshot of the current account summary of a given account.
" + 62 | "Use Real-time Data endpoint for receiving realtime updates.", 63 | ) 64 | @response(HttpStatus.UNAUTHORIZED, "Missing or invalid authorization header.") 65 | @responseBody(AccountSummary) 66 | @bearerAuth([]) 67 | async getAccountSummary(req: MicroserviceRequest): Promise { 68 | const paths = req.url.split("/"); 69 | const summary = await firstValueFrom( 70 | this.apiService.getAccountSummary(paths[paths.length - 1]) 71 | ); 72 | 73 | if (!summary || !summary.baseCurrency) { 74 | throw new HttpError(HttpStatus.NOT_FOUND, "Invalid account id"); 75 | } 76 | 77 | return summary; 78 | } 79 | 80 | @get("/positions") 81 | @summary("Get a snapshot of all open positions.") 82 | @description( 83 | "Get a snapshot of all open positions on all managed accounts.
" + 84 | "Use Real-time Data endpoint for receiving realtime updates.", 85 | ) 86 | @response(HttpStatus.UNAUTHORIZED, "Missing or invalid authorization header.") 87 | @responseBody(PositionList) 88 | @bearerAuth([]) 89 | async getPositions(req: MicroserviceRequest): Promise { 90 | return { 91 | positions: ((await firstValueFrom(this.apiService.positions))?.changed) ?? [] 92 | }; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/controllers/authentication.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | controller, 3 | description, 4 | HttpError, 5 | HttpStatus, 6 | inject, 7 | MicroserviceRequest, 8 | post, 9 | requestBody, 10 | response, 11 | summary, 12 | } from "@waytrade/microservice-core"; 13 | import {UsernamePassword} from "../models/username-password.model"; 14 | import {AuthenticationService} from "../services/authentication.service"; 15 | 16 | /** The user authentication controller. */ 17 | @controller("Authentication", "/auth") 18 | export class AuthenticatonController { 19 | @inject("AuthenticationService") 20 | private authService!: AuthenticationService; 21 | 22 | @post("/password") 23 | @summary("Login with username and password.") 24 | @description( 25 | "Login with username and password and return the Bearer token on authorization header.", 26 | ) 27 | @requestBody(UsernamePassword) 28 | @response( 29 | HttpStatus.UNAUTHORIZED, 30 | "Unauthorized: wrong username or password.", 31 | ) 32 | async loginPassword( 33 | request: MicroserviceRequest, 34 | params: UsernamePassword, 35 | ): Promise { 36 | // verify input 37 | 38 | if (!params.password || !params.username) { 39 | throw new HttpError(HttpStatus.BAD_REQUEST); 40 | } 41 | 42 | // login with password 43 | 44 | try { 45 | const jwt = await this.authService.loginUserPassword( 46 | params.username, 47 | params.password, 48 | ); 49 | 50 | // set response headers 51 | 52 | request.writeResponseHeader( 53 | "access-control-expose-headers", 54 | "authorization", 55 | ); 56 | request.writeResponseHeader("authorization", `Bearer ${jwt}`); 57 | } catch (err: unknown) { 58 | throw new HttpError(HttpStatus.UNAUTHORIZED, (err).message); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/controllers/contracts.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | bearerAuth, 3 | controller, 4 | description, 5 | get, 6 | HttpError, 7 | HttpStatus, 8 | inject, 9 | MicroserviceRequest, post, queryParameter, requestBody, response, 10 | responseBody, 11 | summary 12 | } from "@waytrade/microservice-core"; 13 | import {ContractDescriptionList} from "../models/contract-description.model"; 14 | import {ContractDetailsList} from "../models/contract-details.model"; 15 | import {Contract} from "../models/contract.model"; 16 | import {HistoricDataRequestArguments} from "../models/historic-data-request.model"; 17 | import {OHLCBars} from "../models/ohlc-bar.model"; 18 | import {IBApiService} from "../services/ib-api.service"; 19 | 20 | /** The contracts database controller. */ 21 | @controller("Contracts", "/contracts") 22 | export class ContractsController { 23 | @inject("IBApiService") 24 | private apiService!: IBApiService; 25 | 26 | @get("/search") 27 | @summary("Search contracts.") 28 | @description("Search contracts where name or symbol matches the given text pattern.") 29 | @queryParameter("pattern", String, true, "The text pattern.") 30 | @responseBody(ContractDescriptionList) 31 | @response(HttpStatus.BAD_REQUEST) 32 | @response(HttpStatus.UNAUTHORIZED, "Missing or invalid authorization header.") 33 | @bearerAuth([]) 34 | async searchContract( 35 | req: MicroserviceRequest, 36 | ): Promise { 37 | const pattern = req.queryParams.pattern as string; 38 | if (pattern === undefined) { 39 | throw new HttpError( 40 | HttpStatus.BAD_REQUEST, 41 | "Missing pattern parameter on query", 42 | ); 43 | } 44 | 45 | try { 46 | return { 47 | descs: await this.apiService.searchContracts(pattern) 48 | } as ContractDescriptionList; 49 | } catch (e) { 50 | throw new HttpError( 51 | HttpStatus.INTERNAL_SERVER_ERROR, 52 | (e).message, 53 | ); 54 | } 55 | } 56 | 57 | @post("/details") 58 | @summary("Get contract details.") 59 | @description("Get the contract details of a given contract ID.") 60 | @response(HttpStatus.BAD_REQUEST) 61 | @response(HttpStatus.UNAUTHORIZED, "Missing or invalid authorization header.") 62 | @requestBody(Contract) 63 | @responseBody(ContractDetailsList) 64 | @bearerAuth([]) 65 | async getContractDetails( 66 | req: MicroserviceRequest, 67 | contract: Contract, 68 | ): Promise { 69 | try { 70 | return { 71 | details: await this.apiService.getContractDetails(contract) 72 | } as ContractDetailsList; 73 | } catch (e) { 74 | throw new HttpError( 75 | HttpStatus.BAD_REQUEST, 76 | (e).message, 77 | ); 78 | } 79 | } 80 | 81 | @get("/detailsById") 82 | @summary("Get contract details by conId.") 83 | @description("Get the contract details of a given contract ID.") 84 | @queryParameter("conId", Number, true, "The IB contract ID.") 85 | @response(HttpStatus.BAD_REQUEST) 86 | @response(HttpStatus.NOT_FOUND, "Contract not found.") 87 | @response(HttpStatus.UNAUTHORIZED, "Missing or invalid authorization header.") 88 | @responseBody(ContractDetailsList) 89 | @bearerAuth([]) 90 | async getContractDetailsById( 91 | req: MicroserviceRequest, 92 | ): Promise { 93 | // verify arguments 94 | 95 | const conId = Number(req.queryParams.conId); 96 | if (conId === undefined || isNaN(conId)) { 97 | throw new HttpError( 98 | HttpStatus.BAD_REQUEST, 99 | "Missing conId parameter on query", 100 | ); 101 | } 102 | 103 | // get contract details 104 | 105 | try { 106 | return { 107 | details: await this.apiService.getContractDetails({ 108 | conId, 109 | }) 110 | } as ContractDetailsList; 111 | } catch (e) { 112 | throw new HttpError( 113 | HttpStatus.INTERNAL_SERVER_ERROR, 114 | (e).message, 115 | ); 116 | } 117 | } 118 | 119 | @post("/historicData") 120 | @summary("Get historic data of a contract.") 121 | @description("Get historic OHLC data of a contract of a given contract ID.") 122 | @requestBody(HistoricDataRequestArguments) 123 | @response(HttpStatus.BAD_REQUEST) 124 | @response(HttpStatus.NOT_FOUND, "Contract not found.") 125 | @response(HttpStatus.UNAUTHORIZED, "Missing or invalid authorization header.") 126 | @responseBody(OHLCBars) 127 | @bearerAuth([]) 128 | async getHistoricData( 129 | req: MicroserviceRequest, 130 | args: HistoricDataRequestArguments): Promise { 131 | // verify arguments 132 | 133 | const conId = Number(args.conId); 134 | if (conId === undefined || isNaN(conId)) { 135 | throw new HttpError( 136 | HttpStatus.BAD_REQUEST, 137 | "Missing conId on HistoricDataRequestArguments", 138 | ); 139 | } 140 | 141 | try { 142 | return await this.apiService.getHistoricData( 143 | conId, args.endDate, args.duration, args.barSize, args.whatToShow); 144 | } catch (e) { 145 | throw new HttpError( 146 | HttpStatus.BAD_REQUEST, 147 | (e).message, 148 | ); 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/controllers/realtime-data.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | controller, 3 | description, 4 | HttpStatus, 5 | inject, 6 | MapExt, 7 | MicroserviceRequest, 8 | MicroserviceStream, 9 | queryParameter, 10 | responseBody, 11 | summary, 12 | websocket 13 | } from "@waytrade/microservice-core"; 14 | import { 15 | WaytradeEventMessage, 16 | WaytradeEventMessageType 17 | } from "@waytrade/microservice-core/dist/vendor/waytrade"; 18 | import {firstValueFrom, Subject, Subscription} from "rxjs"; 19 | import { 20 | RealtimeDataError, 21 | RealtimeDataMessage, 22 | RealtimeDataMessagePayload, 23 | RealtimeDataMessageType 24 | } from "../models/realtime-data-message.model"; 25 | import {IBApiService} from "../services/ib-api.service"; 26 | import {SecurityUtils} from "../utils/security.utils"; 27 | 28 | /** Send an error to the stream */ 29 | function sendError( 30 | stream: MicroserviceStream, 31 | topic: string, 32 | error: RealtimeDataError, 33 | ): void { 34 | stream.send( 35 | JSON.stringify({ 36 | topic, 37 | error, 38 | } as RealtimeDataMessage), 39 | ); 40 | } 41 | 42 | /** Send a response to the stream */ 43 | function sendReponse( 44 | stream: MicroserviceStream, 45 | topic: string, 46 | data?: RealtimeDataMessagePayload, 47 | type?: RealtimeDataMessageType, 48 | ): void { 49 | stream.send( 50 | JSON.stringify({ 51 | type, 52 | topic, 53 | data, 54 | } as RealtimeDataMessage), 55 | ); 56 | } 57 | 58 | /** The Real-time Data controller. */ 59 | @controller("Real-time Data", "/realtime") 60 | export class RealtimeDataController { 61 | @inject("IBApiService") 62 | private apiService!: IBApiService; 63 | 64 | /** Shutdown signal */ 65 | private shutdown = new Subject(); 66 | 67 | /** Shutdown the controller. */ 68 | stop(): void { 69 | this.shutdown.next(); 70 | } 71 | 72 | @websocket("/stream") 73 | @summary("Create a stream to receive real-time data.") 74 | @description( 75 | "Upgrade the connection to a WebSocket to send and receive real-time data messages.

" + 76 | "To subscribe on a message topic, send a LiveDataMessage with a valid topic attribute and type='subscribe'.
" + 77 | "To unsubscribe from a message topic, send a LiveDataMessage with a valid topic attribute and type='unsubscribe'
" + 78 | "
Avaiable message topics:
    " + 79 | "
  • accountSummary/#
  • " + 80 | "
  • accountSummary/<account<
  • " + 81 | "
  • position/#
  • " + 82 | "
  • marketdata/<conId>
  • " + 83 | "
", 84 | ) 85 | @queryParameter("auth", String, false, "The authorization token.") 86 | @responseBody(RealtimeDataMessage) 87 | @responseBody(RealtimeDataMessage) 88 | createStream(stream: MicroserviceStream): void { 89 | // enusre authorization 90 | 91 | const authTokenOverwrite = new URL( 92 | stream.url, 93 | "http://127.0.0.1", 94 | ).searchParams.get("auth"); 95 | 96 | const requestHeader = new Map(stream.requestHeader); 97 | if (authTokenOverwrite) { 98 | requestHeader.set("authorization", authTokenOverwrite); 99 | } 100 | 101 | if (!requestHeader.has("authorization")) { 102 | sendError(stream, "", { 103 | code: HttpStatus.UNAUTHORIZED, 104 | desc: "Authorization header or auth argument missing", 105 | }); 106 | stream.close(); 107 | return; 108 | } 109 | 110 | try { 111 | SecurityUtils.ensureAuthorization({ 112 | headers: requestHeader, 113 | } as MicroserviceRequest); 114 | } catch (e) { 115 | sendError(stream, "", { 116 | code: HttpStatus.UNAUTHORIZED, 117 | desc: "Not authorized", 118 | }); 119 | stream.close(); 120 | return; 121 | } 122 | 123 | this.processMessages(stream); 124 | } 125 | 126 | /** Process messages on a realtime data stream. */ 127 | private processMessages(stream: MicroserviceStream): void { 128 | const subscriptionCancelSignals = new MapExt>(); 129 | 130 | // handle incomming messages 131 | 132 | stream.onReceived = (data: string): void => { 133 | let msg: WaytradeEventMessage; 134 | try { 135 | msg = JSON.parse(data) as WaytradeEventMessage; 136 | } catch (e) { 137 | sendError(stream, "", { 138 | code: HttpStatus.BAD_REQUEST, 139 | desc: (e as Error).message, 140 | }); 141 | return; 142 | } 143 | 144 | if (msg.type === WaytradeEventMessageType.Subscribe && msg.topic) { 145 | // handle subscribe requests 146 | let subscriptionCancelSignal = subscriptionCancelSignals.get(msg.topic); 147 | const previousSubscriptionCancelSignal = subscriptionCancelSignal; 148 | if (!subscriptionCancelSignal) { 149 | subscriptionCancelSignal = new Subject(); 150 | } 151 | 152 | subscriptionCancelSignals.set(msg.topic, subscriptionCancelSignal); 153 | previousSubscriptionCancelSignal?.next(); 154 | 155 | firstValueFrom(this.shutdown).then(() => 156 | subscriptionCancelSignal?.next(), 157 | ); 158 | 159 | this.startSubscription( 160 | msg.topic, 161 | stream, 162 | subscriptionCancelSignal, 163 | subscriptionCancelSignals, 164 | ); 165 | } else if (msg.type === WaytradeEventMessageType.Unsubscribe) { 166 | // handle unsubscribe requests 167 | subscriptionCancelSignals.get(msg.topic)?.next(); 168 | subscriptionCancelSignals.delete(msg.topic); 169 | } else { 170 | sendError(stream, msg.topic, { 171 | code: HttpStatus.BAD_REQUEST, 172 | desc: `Invalid message type: ${msg.type}`, 173 | }); 174 | } 175 | }; 176 | 177 | // cancel all subscriptions on connection drop 178 | 179 | stream.closed.then(() => { 180 | subscriptionCancelSignals.forEach(s => s.next()); 181 | }); 182 | } 183 | 184 | /** Start a realtime data subscription. */ 185 | private startSubscription( 186 | topic: string, 187 | stream: MicroserviceStream, 188 | cancel: Subject, 189 | subscriptionMap: MapExt>, 190 | ): void { 191 | function handleSubscriptionError(desc: string): void { 192 | subscriptionMap.delete(topic); 193 | sendError(stream, topic, { 194 | code: HttpStatus.BAD_REQUEST, 195 | desc, 196 | }); 197 | } 198 | 199 | // handle subscription requests: 200 | const topicTokens = topic.split("/"); 201 | 202 | let sub$: Subscription | undefined = undefined; 203 | 204 | // account summariies 205 | if (topicTokens[0] === "accountSummary") { 206 | const accountId = topicTokens[1]; 207 | 208 | if (!accountId) { 209 | handleSubscriptionError("invalid topic, account argument missing"); 210 | return; 211 | } 212 | 213 | if (accountId == "#") { 214 | sub$ = this.apiService.accountSummaries.subscribe({ 215 | next: update => { 216 | update.forEach(summary => { 217 | sendReponse(stream, topicTokens[0] + "/" + summary.account, { 218 | accountSummary: summary, 219 | }); 220 | }); 221 | } 222 | }); 223 | } else { 224 | sub$ = this.apiService.getAccountSummary(accountId).subscribe({ 225 | next: update => { 226 | sendReponse(stream, topicTokens[0] + "/" + accountId, { 227 | accountSummary: update, 228 | }); 229 | } 230 | }); 231 | } 232 | } 233 | 234 | // position 235 | else if (topicTokens[0] === "position") { 236 | const posId = topicTokens[1]; 237 | if (posId !== "#") { 238 | handleSubscriptionError( 239 | "invalid topic, only 'position/#' wildcard supported", 240 | ); 241 | return; 242 | } 243 | 244 | sub$ = this.apiService.positions.subscribe({ 245 | next: update => { 246 | update.changed?.forEach(position => { 247 | sendReponse(stream, topicTokens[0] + "/" + position.id, { 248 | position, 249 | }); 250 | }); 251 | 252 | update.closed?.forEach(position => { 253 | sendReponse( 254 | stream, 255 | topicTokens[0] + "/" + position.id, 256 | undefined, 257 | RealtimeDataMessageType.Unpublish, 258 | ); 259 | }); 260 | } 261 | }); 262 | } 263 | 264 | // marketdata 265 | else if (topicTokens[0] == "marketdata") { 266 | const conId = Number(topicTokens[1]); 267 | if (isNaN(conId)) { 268 | handleSubscriptionError("conId is not a number"); 269 | return; 270 | } 271 | sub$ = this.apiService.getMarketData(conId).subscribe({ 272 | next: update => 273 | sendReponse(stream, topic, { 274 | marketdata: update, 275 | }), 276 | error: err => handleSubscriptionError((err).message), 277 | }); 278 | } 279 | 280 | // invalid topic 281 | else { 282 | handleSubscriptionError("invalid topic"); 283 | } 284 | 285 | firstValueFrom(cancel).then(() => sub$?.unsubscribe()); 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/export-openapi.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import {exit} from "process"; 3 | import {IBApiApp} from "./app"; 4 | 5 | new IBApiApp() 6 | .exportOpenApi(path.resolve(__dirname, "..")) 7 | .then(() => { 8 | console.info("Successfully exported openapi.json"); 9 | exit(); 10 | }) 11 | .catch(() => { 12 | console.error("Failed to export openapi.json"); 13 | exit(1); 14 | }); 15 | -------------------------------------------------------------------------------- /src/models/account-list.model.ts: -------------------------------------------------------------------------------- 1 | import {arrayProperty, model} from "@waytrade/microservice-core"; 2 | 3 | /** 4 | * A list of account IDs. 5 | */ 6 | @model("A list of account ID's") 7 | export class AccountList { 8 | /** The account ID's. */ 9 | @arrayProperty(String, "The account ID's.") 10 | accounts?: string[]; 11 | } 12 | -------------------------------------------------------------------------------- /src/models/account-summary.model.ts: -------------------------------------------------------------------------------- 1 | import {arrayProperty, model, property} from "@waytrade/microservice-core"; 2 | 3 | /** 4 | * An account summary. 5 | */ 6 | @model("An account summary") 7 | export class AccountSummary { 8 | constructor(summary: AccountSummary) { 9 | Object.assign(this, summary); 10 | } 11 | 12 | /** The name of the account. */ 13 | @property("The name of the account.") 14 | account!: string; 15 | 16 | /** Account base currency. */ 17 | @property("The account base currency.") 18 | baseCurrency?: string; 19 | 20 | /** Identifies the IB account structure. */ 21 | @property(" Identifies the IB account structure.") 22 | accountType?: string; 23 | 24 | /** 25 | * The basis for determining the price of the assets in your account. 26 | * Total cash value + stock value + options value + bond value. 27 | */ 28 | @property("Total cash value + stock value + options value + bond value.") 29 | netLiquidation?: number; 30 | 31 | /** Total cash balance recognized at the time of trade + futures PNL. */ 32 | @property("Total cash balance recognized at the time of trade + futures PNL.") 33 | totalCashValue?: number; 34 | 35 | /** 36 | * Cash recognized at the time of settlement, without purchases at the time of 37 | * trade, commissions, taxes and fees. 38 | */ 39 | @property( 40 | "Cash recognized at the time of settlement, without purchases at " + 41 | " the time of trade, commissions, taxes and fees.", 42 | ) 43 | settledCash?: number; 44 | 45 | /** 46 | * Total accrued cash value of stock, commodities and securities. 47 | */ 48 | @property("Total accrued cash value of stock, commodities and securities.") 49 | accruedCash?: number; 50 | 51 | /** 52 | * Buying power serves as a measurement of the dollar value of securities 53 | * that one may purchase in a securities account without depositing 54 | * additional funds 55 | */ 56 | @property("Total buying power.") 57 | buyingPower?: number; 58 | 59 | /** 60 | * Forms the basis for determining whether a client has the necessary assets 61 | * to either initiate or maintain security positions. 62 | * Cash + stocks + bonds + mutual funds. 63 | */ 64 | @property("Cash + stocks + bonds + mutual funds.") 65 | equityWithLoanValue?: number; 66 | 67 | /** 68 | * Marginable Equity with Loan value as of 16:00 ET the previous day 69 | */ 70 | @property( 71 | " Marginable Equity with Loan value as of 16:00 ET the previous day.", 72 | ) 73 | previousEquityWithLoanValue?: number; 74 | 75 | /** The sum of the absolute value of all stock and equity option positions. */ 76 | @property( 77 | "The sum of the absolute value of all stock and equity option positions.", 78 | ) 79 | grossPositionValue?: number; 80 | 81 | /** Regulation T margin for universal account. */ 82 | @property("Regulation T margin for universal account.") 83 | regTEquity?: number; 84 | 85 | /** Regulation T margin for universal account. */ 86 | @property("Regulation T margin for universal account.") 87 | regTMargin?: number; 88 | 89 | /** 90 | * Special Memorandum Account: Line of credit created when the market value 91 | * of securities in a Regulation T account increase in value. 92 | * */ 93 | @property( 94 | "Special Memorandum Account: Line of credit created when the market " + 95 | "value of securities in a Regulation T account increase in value.", 96 | ) 97 | SMA?: number; 98 | 99 | /** Initial Margin requirement of whole portfolio. */ 100 | @property("Initial Margin requirement of whole portfolio.") 101 | initMarginReq?: number; 102 | 103 | /** Maintenance Margin requirement of whole portfolio. */ 104 | @property("Maintenance Margin requirement of whole portfolio.") 105 | maintMarginReq?: number; 106 | 107 | /** This value tells what you have available for trading. */ 108 | @property("This value tells what you have available for trading.") 109 | availableFunds?: number; 110 | 111 | /** This value shows your margin cushion, before liquidation. */ 112 | @property("This value shows your margin cushion, before liquidation.") 113 | excessLiquidity?: number; 114 | 115 | /** Excess liquidity as a percentage of net liquidation value. */ 116 | @property("Excess liquidity as a percentage of net liquidation value.") 117 | cushion?: number; 118 | 119 | /** Initial Margin requirement of whole portfolio. */ 120 | @property("Initial Margin requirement of whole portfolio.") 121 | fullInitMarginReq?: number; 122 | /** Maintenance Margin of whole portfolio. */ 123 | @property("Maintenance Margin of whole portfolio.") 124 | fullMaintMarginReq?: number; 125 | 126 | /** Available funds of whole portfolio. */ 127 | @property("Available funds of whole portfolio.") 128 | fullAvailableFunds?: number; 129 | 130 | /** Excess liquidity of whole portfolio. */ 131 | @property("Excess liquidity of whole portfolio.") 132 | fullExcessLiquidity?: number; 133 | 134 | /** Time when look-ahead values take effect. */ 135 | @property("Time when look-ahead values take effect.") 136 | lookAheadNextChange?: number; 137 | 138 | /** 139 | * Initial Margin requirement of whole portfolio as of next 140 | * period's margin change. 141 | */ 142 | @property( 143 | "Initial Margin requirement of whole portfolio as of next " + 144 | "period's margin change.", 145 | ) 146 | lookAheadInitMarginReq?: number; 147 | 148 | /** 149 | * Maintenance Margin requirement of whole portfolio as of next 150 | * period's margin change. 151 | */ 152 | @property( 153 | "Maintenance Margin requirement of whole portfolio as of next " + 154 | "period's margin change.", 155 | ) 156 | lookAheadMaintMarginReq?: number; 157 | 158 | /** This value reflects your available funds at the next margin change. */ 159 | @property( 160 | "This value reflects your available funds at the next margin change.", 161 | ) 162 | lookAheadAvailableFunds?: number; 163 | 164 | /** This value reflects your excess liquidity at the next margin change. */ 165 | @property( 166 | "This value reflects your excess liquidity at the next margin change.", 167 | ) 168 | lookAheadExcessLiquidity?: number; 169 | 170 | /** A measure of how close the account is to liquidation. */ 171 | @property("A measure of how close the account is to liquidation.") 172 | highestSeverity?: number; 173 | 174 | /** 175 | * The Number of Open/Close trades a user could put on before Pattern 176 | * Day Trading is detected. A value of "-1" means that the user can put on 177 | * unlimited day trades. 178 | */ 179 | @property( 180 | "The Number of Open/Close trades a user could put on before " + 181 | "Pattern Day Trading is detected. A value of -1 means that the user " + 182 | "can put on unlimited day trades.", 183 | ) 184 | dayTradesRemaining?: number; 185 | 186 | /** GrossPositionValue / NetLiquidation. */ 187 | @property("GrossPositionValue / NetLiquidation.") 188 | leverage?: number; 189 | 190 | /** The daily PnL. */ 191 | @property("The daily PnL.") 192 | dailyPnL?: number; 193 | 194 | /** The daily unrealized PnL. */ 195 | @property("The daily unrealized PnL.") 196 | unrealizedPnL?: number; 197 | 198 | /** The daily realized PnL. */ 199 | @property("The daily realized PnL.") 200 | realizedPnL?: number; 201 | } 202 | 203 | /** 204 | * An account summary. 205 | */ 206 | @model("A list of account sumarries") 207 | export class AccountSummaryList { 208 | @arrayProperty(AccountSummary, "The account summaries") 209 | summaries?: AccountSummary[]; 210 | } 211 | -------------------------------------------------------------------------------- /src/models/contract-description.model.ts: -------------------------------------------------------------------------------- 1 | import {arrayProperty, model} from "@waytrade/microservice-core"; 2 | import {Contract} from "./contract.model"; 3 | 4 | /** 5 | * A contract description. 6 | */ 7 | @model(" A contract description.") 8 | export class ContractDescription { 9 | /** The underlying contract. */ 10 | contract?: Contract; 11 | 12 | /** Array of derivative security types. */ 13 | derivativeSecTypes?: string[]; 14 | } 15 | 16 | /** 17 | * A list of contract descriprions. 18 | */ 19 | @model("A list of contract descriprions.") 20 | export class ContractDescriptionList { 21 | @arrayProperty(ContractDescription, "Array of contract descriprions") 22 | descs?: ContractDescription[]; 23 | } 24 | -------------------------------------------------------------------------------- /src/models/contract-details.model.ts: -------------------------------------------------------------------------------- 1 | import {arrayProperty, model, property} from "@waytrade/microservice-core"; 2 | import {Contract} from "./contract.model"; 3 | 4 | /** 5 | * Contract details on Interactive Brokers. 6 | */ 7 | @model("Details of a contract on Interactive Brokers.") 8 | export class ContractDetails { 9 | /** A fully-defined Contract object. */ 10 | @property("A fully-defined Contract object.") 11 | contract?: Contract; 12 | 13 | /** The market name for this product. */ 14 | @property("The market name for this product.") 15 | marketName?: string; 16 | 17 | /** The minimum allowed price variation. */ 18 | @property("The minimum allowed price variation.") 19 | minTick?: number; 20 | 21 | /** Supported order types for this product. */ 22 | @property("Supported order types for this product.") 23 | orderTypes?: string; 24 | 25 | /**Valid exchange fields when placing an order for this contract. */ 26 | @property("Valid exchange fields when placing an order for this contract.") 27 | validExchanges?: string; 28 | 29 | /** For derivatives, the contract ID (conID) of the underlying instrument. */ 30 | @property( 31 | "For derivatives, the contract ID (conID) of the underlying instrument.", 32 | ) 33 | underConId?: number; 34 | 35 | /** Descriptive name of the product. */ 36 | @property("Descriptive name of the product.") 37 | longName?: string; 38 | 39 | /**Typically the contract month of the underlying for a Future contract. */ 40 | @property( 41 | "Typically the contract month of the underlying for a Future contract.", 42 | ) 43 | contractMonth?: string; 44 | 45 | /** The industry classification of the underlying/product. For example, Financial. */ 46 | @property( 47 | "The industry classification of the underlying/product. For example, Financial.", 48 | ) 49 | industry?: string; 50 | 51 | /** The industry category of the underlying. For example, InvestmentSvc. */ 52 | @property( 53 | "The industry category of the underlying. For example, InvestmentSvc.", 54 | ) 55 | category?: string; 56 | 57 | /** The industry subcategory of the underlying. For example, Brokerage. */ 58 | @property( 59 | "The industry subcategory of the underlying. For example, Brokerage.", 60 | ) 61 | subcategory?: string; 62 | 63 | /** The time zone for the trading hours of the product. For example, EST. */ 64 | @property( 65 | "The time zone for the trading hours of the product. For example, EST.", 66 | ) 67 | timeZoneId?: string; 68 | 69 | /**The trading hours of the product. */ 70 | @property("The trading hours of the product.") 71 | tradingHours?: string; 72 | 73 | /** The liquid hours of the product.*/ 74 | @property("The liquid hours of the product.") 75 | liquidHours?: string; 76 | 77 | /** Tick Size Multiplier. */ 78 | @property("Tick size multiplier.") 79 | mdSizeMultiplier?: number; 80 | 81 | /** For derivatives, the symbol of the underlying contract. */ 82 | @property("For derivatives, the symbol of the underlying contract.") 83 | underSymbol?: string; 84 | 85 | /** For derivatives, the underlying security type. */ 86 | @property("For derivatives, the underlying security type.") 87 | underSecType?: string; 88 | 89 | /** 90 | * The list of market rule IDs separated by comma Market rule IDs can 91 | * be used to determine the minimum price increment at a given price. 92 | */ 93 | @property("For derivatives, the underlying security type.") 94 | marketRuleIds?: string; 95 | 96 | /** 97 | * Real expiration date. 98 | * 99 | * Requires TWS 968+ and API v973.04+. 100 | */ 101 | @property("Real expiration date.") 102 | realExpirationDate?: string; 103 | 104 | /** Last trade time. */ 105 | @property("Last trade time.") 106 | lastTradeTime?: string; 107 | 108 | /** Stock type. */ 109 | @property("Stock type.") 110 | stockType?: string; 111 | } 112 | 113 | /** 114 | * A list of contract details. 115 | */ 116 | @model("A list of contract details.") 117 | export class ContractDetailsList { 118 | @arrayProperty(ContractDetails, "Array of contract details") 119 | details?: ContractDetails[]; 120 | } 121 | 122 | 123 | -------------------------------------------------------------------------------- /src/models/contract.model.ts: -------------------------------------------------------------------------------- 1 | import {OptionType, SecType} from "@stoqey/ib"; 2 | import {enumProperty, model, property} from "@waytrade/microservice-core"; 3 | 4 | /** 5 | * A contract on Interactive Brokers. 6 | */ 7 | @model("A contract on Interactive Brokers.") 8 | export class Contract { 9 | /** The unique IB contract identifier. */ 10 | @property("The unique IB contract identifier.") 11 | conId?: number; 12 | 13 | /** The asset symbol. */ 14 | @property("The asset symbol.") 15 | symbol?: string; 16 | 17 | /** The security type. */ 18 | @enumProperty("SecType", SecType, "The security type.") 19 | secType?: SecType; 20 | 21 | /** 22 | * The contract's last trading day or contract month (for Options and Futures). 23 | * 24 | * Strings with format YYYYMM will be interpreted as the Contract Month 25 | * whereas YYYYMMDD will be interpreted as Last Trading Day. 26 | */ 27 | @property("The contract's last trading day or contract month.") 28 | lastTradeDateOrContractMonth?: string; 29 | 30 | /** The option's strike price. */ 31 | @property("The option's strike price.") 32 | strike?: number; 33 | 34 | /** Either Put or Call (i.e. Options). Valid values are P, PUT, C, CALL. */ 35 | @enumProperty( 36 | "OptionType", 37 | OptionType, 38 | "Either Put or Call (i.e. Options). Valid values are P, PUT, C, CALL.", 39 | ) 40 | right?: OptionType; 41 | 42 | /** The instrument's multiplier (i.e. options, futures). */ 43 | @property("The instrument's multiplier (i.e. options, futures).") 44 | multiplier?: number; 45 | 46 | /** The destination exchange. */ 47 | @property("The destination exchange.") 48 | exchange?: string; 49 | 50 | /** The trading currency. */ 51 | @property("The trading currency.") 52 | currency?: string; 53 | 54 | /** 55 | * The contract's symbol within its primary exchange. 56 | * For options, this will be the OCC symbol. 57 | */ 58 | @property("The contract's symbol within its primary exchange.") 59 | localSymbol?: string; 60 | 61 | /** 62 | * The contract's primary exchange. For smart routed contracts, 63 | * used to define contract in case of ambiguity. 64 | * Should be defined as native exchange of contract, e.g. ISLAND for MSFT. 65 | * For exchanges which contain a period in name, will only be part of exchange 66 | * name prior to period, i.e. ENEXT for ENEXT.BE. 67 | */ 68 | @property("The contract's primary exchange.") 69 | primaryExch?: string; 70 | 71 | /** 72 | * The trading class name for this contract. 73 | * Available in TWS contract description window as well. 74 | * For example, GBL Dec '13 future's trading class is "FGBL". 75 | */ 76 | @property("The trading class name for this contract.") 77 | tradingClass?: string; 78 | 79 | /** 80 | * Security's identifier when querying contract's details or placing orders 81 | * ISIN - Example: Apple: US0378331005. 82 | * CUSIP - Example: Apple: 037833100. 83 | */ 84 | @property( 85 | "Security's identifier when querying contract's details or placing orders.", 86 | ) 87 | secIdType?: string; 88 | 89 | /**Identifier of the security type. */ 90 | @property("Identifier of the security type.") 91 | secId?: string; 92 | } 93 | -------------------------------------------------------------------------------- /src/models/historic-data-request.model.ts: -------------------------------------------------------------------------------- 1 | import {enumProperty, model, property} from "@waytrade/microservice-core"; 2 | 3 | export enum BarSize { 4 | SECONDS_ONE = "1 secs", 5 | SECONDS_FIVE = "5 secs", 6 | SECONDS_TEN = "10 secs", 7 | SECONDS_FIFTEEN = "15 secs", 8 | SECONDS_THIRTY = "30 secs", 9 | MINUTES_ONE = "1 min", 10 | MINUTES_TWO = "2 mins", 11 | MINUTES_THREE = "3 mins", 12 | MINUTES_FIVE = "5 mins", 13 | MINUTES_TEN = "10 mins", 14 | MINUTES_FIFTEEN = "15 mins", 15 | MINUTES_TWENTY = "20 mins", 16 | MINUTES_THIRTY = "30 mins", 17 | HOURS_ONE = "1 hour", 18 | HOURS_TWO = "2 hours", 19 | HOURS_THREE = "3 hours", 20 | HOURS_FOUR = "4 hours", 21 | HOURS_EIGHT = "8 hours", 22 | DAYS_ONE = "1 day", 23 | WEEKS_ONE = "1W", 24 | MONTHS_ONE = "1M" 25 | } 26 | 27 | export enum WhatToShow { 28 | TRADES = "TRADES", 29 | MIDPOINT = "MIDPOINT", 30 | BID = "BID", 31 | ASK = "ASK", 32 | BID_ASK = "BID_ASK", 33 | HISTORICAL_VOLATILITY = "HISTORICAL_VOLATILITY", 34 | OPTION_IMPLIED_VOLATILITY = "OPTION_IMPLIED_VOLATILITY", 35 | FEE_RATE = "FEE_RATE", 36 | REBATE_RATE = "REBATE_RATE" 37 | } 38 | 39 | /** 40 | * A historic data request. 41 | */ 42 | @model("A historic data request arguments.") 43 | export class HistoricDataRequestArguments { 44 | /** The contract id. */ 45 | @property("The contract id.") 46 | conId!: number; 47 | 48 | /** Date of the end (must up to date) bar. If undefined, end date is now. */ 49 | @property("Date of the end (must up to date) bar. If undefined, end date is now.") 50 | endDate?: string; 51 | 52 | /** The duration, in format '[n] S' (seconds), '[n] D' (days), '[n] W' (weeks), '[n] M' (months), '[n] Y' (years). */ 53 | @property("The duration, in format '[n] S' (seconds), '[n] D' (days), '[n] W' (weeks), '[n] M' (months), '[n] Y' (years).") 54 | duration!: string; 55 | 56 | /** The bar size. */ 57 | @enumProperty("BarSize", BarSize, "The bar size.") 58 | barSize!: BarSize; 59 | 60 | /** Data type to show. */ 61 | @enumProperty("WhatToShow", WhatToShow, "Data type to show.") 62 | whatToShow!: WhatToShow; 63 | } 64 | -------------------------------------------------------------------------------- /src/models/market-data.model.ts: -------------------------------------------------------------------------------- 1 | import {model, property} from "@waytrade/microservice-core"; 2 | 3 | /** 4 | * Market ata values. 5 | */ 6 | @model("Market data values.") 7 | export class MarketData { 8 | /** Number of contracts or lots offered at the bid price. */ 9 | @property("Number of contracts or lots offered at the bid price.") 10 | BID_SIZE?: number; 11 | 12 | /** Highest priced bid for the contract. */ 13 | @property("Highest priced bid for the contract.") 14 | BID?: number; 15 | 16 | /** Lowest price offer on the contract. */ 17 | @property("Lowest price offer on the contract.") 18 | ASK?: number; 19 | 20 | /** Number of contracts or lots offered at the ask price. */ 21 | @property("Number of contracts or lots offered at the ask price.") 22 | ASK_SIZE?: number; 23 | 24 | /** Last price at which the contract traded. */ 25 | @property("Last price at which the contract traded.") 26 | LAST?: number; 27 | 28 | /** Number of contracts or lots traded at the last price. */ 29 | @property("Number of contracts or lots traded at the last price.") 30 | LAST_SIZE?: number; 31 | 32 | /** High price for the day. */ 33 | @property("High price for the day.") 34 | HIGH?: number; 35 | 36 | /** Low price for the day. */ 37 | @property("Low price for the day.") 38 | LOW?: number; 39 | 40 | /** Trading volume for the day for the selected contract (US Stocks: multiplier 100). */ 41 | @property("Trading volume for the day for the selected contract.") 42 | VOLUME?: number; 43 | 44 | /** 45 | * The last available closing price for the previous day. 46 | * For US Equities, we use corporate action processing to get the closing price, 47 | * so the close price is adjusted to reflect forward and reverse splits and cash and stock dividends. 48 | */ 49 | @property("The last available closing price for the previous day.") 50 | CLOSE?: number; 51 | 52 | /** Today's opening price. */ 53 | @property("Today's opening price.") 54 | OPEN?: number; 55 | 56 | /** Lowest price for the last 13 weeks. */ 57 | @property("Lowest price for the last 13 weeks.") 58 | LOW_13_WEEK?: number; 59 | 60 | /** Highest price for the last 13 weeks. */ 61 | @property("Highest price for the last 13 weeks.") 62 | HIGH_13_WEEK?: number; 63 | 64 | /** Lowest price for the last 26 weeks. */ 65 | @property("Lowest price for the last 26 weeks.") 66 | LOW_26_WEEK?: number; 67 | 68 | /** Highest price for the last 26 weeks. */ 69 | @property("Highest price for the last 26 weeks.") 70 | HIGH_26_WEEK?: number; 71 | 72 | /** Lowest price for the last 52 weeks. */ 73 | @property("Lowest price for the last 52 weeks.") 74 | LOW_52_WEEK?: number; 75 | 76 | /** Highest price for the last 52 weeks. */ 77 | @property("Highest price for the last 52 weeks.") 78 | HIGH_52_WEEK?: number; 79 | 80 | /** The average daily trading volume over 90 days (multiply this value times 100). */ 81 | @property( 82 | "The average daily trading volume over 90 days (multiply this value times 100).", 83 | ) 84 | AVG_VOLUME?: number; 85 | 86 | /** Total number of options that were not closed. */ 87 | @property("Total number of options that were not closed).") 88 | OPEN_INTEREST?: number; 89 | 90 | /** The 30-day historical volatility (currently for stocks). */ 91 | @property("The 30-day historical volatility (currently for stocks).") 92 | OPTION_HISTORICAL_VOL?: number; 93 | 94 | /** 95 | * A prediction of how volatile an underlying will be in the future. 96 | * The IB 30-day volatility is the at-market volatility estimated for a maturity thirty calendar days forward of the current trading day, 97 | * and is based on option prices from two consecutive expiration months. 98 | */ 99 | @property("A prediction of how volatile an underlying will be in the future.") 100 | OPTION_IMPLIED_VOL?: number; 101 | 102 | /** Call option open interest. */ 103 | @property("Call option open interest.") 104 | OPTION_CALL_OPEN_INTEREST?: number; 105 | 106 | /** Put option open interest. */ 107 | @property("Put option open interest.") 108 | OPTION_PUT_OPEN_INTEREST?: number; 109 | 110 | /** Call option volume for the trading day. */ 111 | @property("Call option volume for the trading day.") 112 | OPTION_CALL_VOLUME?: number; 113 | 114 | /** Put option volume for the trading day. */ 115 | @property("Put option volume for the trading day.") 116 | OPTION_PUT_VOLUME?: number; 117 | 118 | /** The number of points that the index is over the cash index. */ 119 | @property("The number of points that the index is over the cash index.") 120 | INDEX_FUTURE_PREMIUM?: number; 121 | 122 | /** Identifies the options exchange(s) posting the best bid price on the options contract. */ 123 | @property( 124 | "Identifies the options exchange(s) posting the best bid price on the options contract.", 125 | ) 126 | BID_EXCH?: number; 127 | 128 | /** Identifies the options exchange(s) posting the best ask price on the options contract. */ 129 | @property( 130 | "Identifies the options exchange(s) posting the best ask price on the options contract.", 131 | ) 132 | ASK_EXCH?: number; 133 | 134 | /** 135 | * The mark price is equal to the Last Price unless: Ask < Last - the mark price is equal to the Ask Price. 136 | * Bid > Last - the mark price is equal to the Bid Price. 137 | */ 138 | @property("The mark price.") 139 | MARK_PRICE?: number; 140 | 141 | /** Time of the last trade (in UNIX time). */ 142 | @property("Time of the last trade (in UNIX time).") 143 | LAST_TIMESTAMP?: number; 144 | 145 | /** Describes the level of difficulty with which the contract can be sold short. */ 146 | @property( 147 | "Describes the level of difficulty with which the contract can be sold short.", 148 | ) 149 | SHORTABLE?: number; 150 | 151 | /** Provides the available Reuter's Fundamental Ratios. */ 152 | @property("Provides the available Reuter's Fundamental Ratios.") 153 | FUNDAMENTAL_RATIOS?: number; 154 | 155 | /** Last trade details. */ 156 | @property("Last trade details.") 157 | RT_VOLUME?: number; 158 | 159 | /** Indicates if a contract is halted */ 160 | @property("Indicates if a contract is halted.") 161 | HALTED?: number; 162 | 163 | /** Trade count for the day. */ 164 | @property("Trade count for the day.") 165 | TRADE_COUNT?: number; 166 | 167 | /** Trade count per minute. */ 168 | @property("Trade count per minute.") 169 | TRADE_RATE?: number; 170 | 171 | /** Volume per minute. */ 172 | @property("Volume per minute.") 173 | VOLUME_RATE?: number; 174 | 175 | /** Last Regular Trading Hours traded price. */ 176 | @property("Last Regular Trading Hours traded price.") 177 | LAST_RTH_TRADE?: number; 178 | 179 | /** 30-day real time historical volatility. */ 180 | @property("30-day real time historical volatility.") 181 | RT_HISTORICAL_VOL?: number; 182 | 183 | /** Contract's dividends. */ 184 | @property("Contract's dividends.") 185 | IB_DIVIDENDS?: number; 186 | 187 | /** Contract's news feed. */ 188 | @property("Contract's news feed.") 189 | NEWS_TICK?: number; 190 | 191 | /** The past three minutes volume. Interpolation may be applied. */ 192 | @property("The past three minutes volume. Interpolation may be applied") 193 | SHORT_TERM_VOLUME_3_MIN?: number; 194 | 195 | /** The past five minutes volume. Interpolation may be applied. */ 196 | @property("The past five minutes volume. Interpolation may be applied.") 197 | SHORT_TERM_VOLUME_5_MIN?: number; 198 | 199 | /** The past ten minutes volume. Interpolation may be applied. */ 200 | @property("The past ten minutes volume. Interpolation may be applied.") 201 | SHORT_TERM_VOLUME_10_MIN?: number; 202 | 203 | /** Exchange of last traded price. */ 204 | @property("Exchange of last traded price.") 205 | LAST_EXCH?: number; 206 | 207 | /** Timestamp (in Unix ms time) of last trade returned with regulatory snapshot. */ 208 | @property( 209 | "Timestamp (in Unix ms time) of last trade returned with regulatory snapshot", 210 | ) 211 | LAST_REG_TIME?: number; 212 | 213 | /** Total number of outstanding futures contracts (TWS v965+). *HSI open interest requested with generic tick 101. */ 214 | @property("Total number of outstanding futures contracts.") 215 | FUTURES_OPEN_INTEREST?: number; 216 | 217 | /** Average volume of the corresponding option contracts(TWS Build 970+ is required). */ 218 | @property( 219 | "Average volume of the corresponding option contracts(TWS Build 970+ is required).", 220 | ) 221 | AVG_OPT_VOLUME?: number; 222 | 223 | /** Number of shares available to short (TWS Build 974+ is required) */ 224 | @property("Number of shares available to short.") 225 | SHORTABLE_SHARES?: number; 226 | 227 | /** 228 | * Today's closing price of ETF's Net Asset Value (NAV). 229 | * Calculation is based on prices of ETF's underlying securities. 230 | */ 231 | @property("Today's closing price of ETF's Net Asset Value (NAV).") 232 | ETF_NAV_CLOSE?: number; 233 | 234 | /** 235 | * Yesterday's closing price of ETF's Net Asset Value (NAV). 236 | * Calculation is based on prices of ETF's underlying securities. 237 | */ 238 | @property("Yesterday's closing price of ETF's Net Asset Value (NAV).") 239 | ETF_NAV_PRIOR_CLOSE?: number; 240 | 241 | /** 242 | * The bid price of ETF's Net Asset Value (NAV). 243 | * Calculation is based on prices of ETF's underlying securities. 244 | */ 245 | @property("The bid price of ETF's Net Asset Value (NAV).") 246 | ETF_NAV_BID?: number; 247 | 248 | /** 249 | * The ask price of ETF's Net Asset Value (NAV). 250 | * Calculation is based on prices of ETF's underlying securities. 251 | */ 252 | @property("The ask price of ETF's Net Asset Value (NAV).") 253 | ETF_NAV_ASK?: number; 254 | 255 | /** 256 | * The last price of Net Asset Value (NAV). 257 | * For ETFs: Calculation is based on prices of ETF's underlying securities. 258 | * For NextShares: Value is provided by NASDAQ. 259 | */ 260 | @property("The ask price of ETF's Net Asset Value (NAV).") 261 | ETF_NAV_LAST?: number; 262 | 263 | /** ETF Nav Last for Frozen data. */ 264 | @property("ETF Nav Last for Frozen data.") 265 | ETF_NAV_FROZEN_LAST?: number; 266 | 267 | /** The high price of ETF's Net Asset Value (NAV). */ 268 | @property("The high price of ETF's Net Asset Value (NAV).") 269 | ETF_NAV_HIGH?: number; 270 | 271 | /** The low price of ETF's Net Asset Value (NAV). */ 272 | @property("The low price of ETF's Net Asset Value (NAV).") 273 | ETF_NAV_LOW?: number; 274 | 275 | /** The underlying asset price of an option. */ 276 | @property("The underlying asset price of an option.") 277 | OPTION_UNDERLYING?: number; 278 | 279 | /** The underlying asset price of an option. */ 280 | @property("The IV on the bid price of an option.") 281 | BID_OPTION_IV?: number; 282 | 283 | /** The bid price of an option. */ 284 | @property("The bid price of an option.") 285 | BID_OPTION_PRICE?: number; 286 | 287 | /** The delta on the bid price of an option. */ 288 | @property("The delta on the bid price of an option.") 289 | BID_OPTION_DELTA?: number; 290 | 291 | /** The gamma on the bid price of an option. */ 292 | @property("The gamma on the bid price of an option.") 293 | BID_OPTION_GAMMA?: number; 294 | 295 | /** The vega on the bid price of an option. */ 296 | @property("The vega on the bid price of an option.") 297 | BID_OPTION_VEGA?: number; 298 | 299 | /** The theta on the bid price of an option. */ 300 | @property("The theta on the bid price of an option.") 301 | BID_OPTION_THETA?: number; 302 | 303 | /** The IV on the ask price of an option. */ 304 | @property("The IV on the ask price of an option.") 305 | ASK_OPTION_IV?: number; 306 | 307 | /** The ask price of an option. */ 308 | @property("The ask price of an option.") 309 | ASK_OPTION_PRICE?: number; 310 | 311 | /** The delta on the ask price of an option. */ 312 | @property("The delta on the ask price of an option.") 313 | ASK_OPTION_DELTA?: number; 314 | 315 | /** The gamma on the ask price of an option. */ 316 | @property("The gamma on the ask price of an option.") 317 | ASK_OPTION_GAMMA?: number; 318 | 319 | /** The vega on the ask price of an option. */ 320 | @property("The vega on the ask price of an option.") 321 | ASK_OPTION_VEGA?: number; 322 | 323 | /** The theta on the ask price of an option. */ 324 | @property("The theta on the ask price of an option.") 325 | ASK_OPTION_THETA?: number; 326 | 327 | /** The IV on the last price of an option. */ 328 | @property("The IV on the last price of an option.") 329 | LAST_OPTION_IV?: number; 330 | 331 | /** The last price of an option. */ 332 | @property("The last price of an option.") 333 | LAST_OPTION_PRICE?: number; 334 | 335 | /**The delta on the last price of an option. */ 336 | @property("The delta on the last price of an option.") 337 | LAST_OPTION_DELTA?: number; 338 | 339 | /** The gamma on the last price of an option. */ 340 | @property("The gamma on the last price of an option.") 341 | LAST_OPTION_GAMMA?: number; 342 | 343 | /** The vega on the last price of an option. */ 344 | @property("The vega on the last price of an option.") 345 | LAST_OPTION_VEGA?: number; 346 | 347 | /** The theta on the last price of an option. */ 348 | @property("The theta on the last price of an option.") 349 | LAST_OPTION_THETA?: number; 350 | 351 | /** The IV on the pricing-model of an option. */ 352 | @property("The IV on the pricing-model of an option.") 353 | MODEL_OPTION_IV?: number; 354 | 355 | /** The price on the pricing-model of an option. */ 356 | @property("The price on the pricing-model of an option.") 357 | MODEL_OPTION_PRICE?: number; 358 | 359 | /** The delta on the pricing-model of an option. */ 360 | @property("The delta on the pricing-model of an option.") 361 | MODEL_OPTION_DELTA?: number; 362 | 363 | /** The gamma on the pricing-model of an option. */ 364 | @property("The gamma on the pricing-model of an option.") 365 | MODEL_OPTION_GAMMA?: number; 366 | 367 | /** The vega on the pricing-model of an option. */ 368 | @property("The vega on the pricing-model of an option.") 369 | MODEL_OPTION_VEGA?: number; 370 | 371 | /** The theta on the pricing-model of an option. */ 372 | @property("The theta on the pricing-model of an option.") 373 | MODEL_OPTION_THETA?: number; 374 | } 375 | -------------------------------------------------------------------------------- /src/models/ohlc-bar.model.ts: -------------------------------------------------------------------------------- 1 | import {arrayProperty, model, property} from "@waytrade/microservice-core"; 2 | 3 | /** 4 | * A OHLC bar. 5 | */ 6 | @model("A OHLC bar.") 7 | export class OHLCBar { 8 | /** The date and time (as a yyyymmss hh:mm:ss). */ 9 | @property("The date and time (as a yyyymmss hh:mm:ss).") 10 | time?: string; 11 | 12 | /** The open price. */ 13 | @property("The open price.") 14 | open?: number; 15 | 16 | /** The high price. */ 17 | @property("The high price") 18 | high?: number; 19 | 20 | /** The low price. */ 21 | @property("The low price.") 22 | low?: number; 23 | 24 | /** The close price. */ 25 | @property(" The close price.") 26 | close?: number; 27 | 28 | /** The traded volume if available (only available for TRADES). */ 29 | @property("The traded volume if available (only available for TRADES).") 30 | volume?: number; 31 | 32 | /** The Weighted Average Price (only available for TRADES). */ 33 | @property("The Weighted Average Price (only available for TRADES).") 34 | WAP?: number; 35 | 36 | /** The number of trades during the bar timespan (only available for TRADES). */ 37 | @property("The number of trades during the bar timespan (only available for TRADES).") 38 | count?: number; 39 | } 40 | 41 | /** 42 | * A chart bar. 43 | */ 44 | @model("A list of OHLC bars.") 45 | export class OHLCBars { 46 | @arrayProperty(OHLCBar, "The bars") 47 | bars?: OHLCBar[]; 48 | } 49 | -------------------------------------------------------------------------------- /src/models/pnl.model.ts: -------------------------------------------------------------------------------- 1 | import {model, property} from "@waytrade/microservice-core"; 2 | 3 | /** 4 | * Daily Profit & Loss information. 5 | */ 6 | @model("Daily Profit & Loss information.") 7 | export class PnL { 8 | /** The daily PnL. */ 9 | @property("The daily PnL.") 10 | dailyPnL?: number; 11 | 12 | /** The daily unrealized PnL. */ 13 | @property("The daily unrealized PnL.") 14 | unrealizedPnL?: number; 15 | 16 | /** The daily realized PnL. */ 17 | @property("The daily realized PnL.") 18 | realizedPnL?: number; 19 | } 20 | -------------------------------------------------------------------------------- /src/models/position-list.model.ts: -------------------------------------------------------------------------------- 1 | import {arrayProperty, model} from "@waytrade/microservice-core"; 2 | import {Position} from "./position.model"; 3 | 4 | /** 5 | * A list of positions. 6 | */ 7 | @model("A list of positions.") 8 | export class PositionList { 9 | /** Array of positions. */ 10 | @arrayProperty(Position, "Array of positions.") 11 | positions?: Position[]; 12 | } 13 | -------------------------------------------------------------------------------- /src/models/position.model.ts: -------------------------------------------------------------------------------- 1 | import {model, property} from "@waytrade/microservice-core"; 2 | 3 | /** 4 | * A position on an IBKR account. 5 | */ 6 | @model("An positions on an IBKR account") 7 | export class Position { 8 | constructor(contract: Position) { 9 | Object.assign(this, contract); 10 | } 11 | 12 | /** The position id. */ 13 | @property("The position id.") 14 | id!: string; 15 | 16 | /** The account id. */ 17 | @property("The account id.") 18 | account?: string; 19 | 20 | /** The position's contract id. */ 21 | @property("The position's contract id.") 22 | conId?: number; 23 | 24 | /** The number of positions held. */ 25 | @property("The number of positions held.") 26 | pos?: number; 27 | 28 | /** The daily PnL. */ 29 | @property("The daily PnL.") 30 | dailyPnL?: number; 31 | 32 | /** The daily unrealized PnL. */ 33 | @property("The daily unrealized PnL.") 34 | unrealizedPnL?: number; 35 | 36 | /** The daily realized PnL. */ 37 | @property("The daily realized PnL.") 38 | realizedPnL?: number; 39 | 40 | /** The average cost of the position. */ 41 | @property("The average cost of the position.") 42 | avgCost?: number; 43 | 44 | /** The current market value of the position. */ 45 | @property("The current market value of the position.") 46 | marketValue?: number; 47 | } 48 | -------------------------------------------------------------------------------- /src/models/realtime-data-message.model.ts: -------------------------------------------------------------------------------- 1 | import {enumProperty, model, property} from "@waytrade/microservice-core"; 2 | import {AccountSummary} from "./account-summary.model"; 3 | import {MarketData} from "./market-data.model"; 4 | import {Position} from "./position.model"; 5 | 6 | /** 7 | * Type of a real-time data message. 8 | */ 9 | export enum RealtimeDataMessageType { 10 | Subscribe = "subscribe", 11 | Unsubscribe = "unsubscribe", 12 | Publish = "publish", 13 | Unpublish = "unpublish", 14 | } 15 | 16 | /** Payload of a real-time data error message. */ 17 | @model("Payload of a real-time data error message.") 18 | export class RealtimeDataError { 19 | /** The error code. */ 20 | @property("The error code.") 21 | code?: number; 22 | 23 | /** The error description. */ 24 | @property("The error description") 25 | desc?: string; 26 | } 27 | 28 | /** Payload of a real-time data message. */ 29 | @model("Payload of a message on the live-data stream.") 30 | export class RealtimeDataMessagePayload { 31 | @property("Update on the account summary.") 32 | accountSummary?: AccountSummary; 33 | 34 | @property("Updated position.") 35 | position?: Position; 36 | 37 | @property("Updated market data.") 38 | marketdata?: MarketData; 39 | } 40 | 41 | /** A message on the real-time data stream. */ 42 | @model("A message on the real-time data stream.") 43 | export class RealtimeDataMessage { 44 | /** The message topic. */ 45 | @property("The message topic.") 46 | topic!: string; 47 | 48 | @property( 49 | "If valid, this a error mesaage and this attribute provides details " + 50 | "about the error", 51 | ) 52 | error?: RealtimeDataError; 53 | 54 | /** The message type. 'publish' if not specified. */ 55 | @enumProperty( 56 | "RealtimeDataMessageType", 57 | RealtimeDataMessageType, 58 | "The message type. 'publish' if not specified.", 59 | ) 60 | type?: RealtimeDataMessageType; 61 | 62 | /** The message data payload. */ 63 | @property("The message data payload.") 64 | data?: RealtimeDataMessagePayload; 65 | } 66 | -------------------------------------------------------------------------------- /src/models/username-password.model.ts: -------------------------------------------------------------------------------- 1 | import {model, property} from "@waytrade/microservice-core"; 2 | 3 | /** A username and password combination. */ 4 | @model("A username and password combination.") 5 | export class UsernamePassword { 6 | /** The username. */ 7 | @property("The username") 8 | username!: string; 9 | 10 | /** The password. */ 11 | @property("The password") 12 | password!: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/run.ts: -------------------------------------------------------------------------------- 1 | import {exit} from "process"; 2 | import {IBApiApp} from "./app"; 3 | 4 | new IBApiApp().start().catch(() => { 5 | console.error("Failed to start ib-api-service App"); 6 | exit(1); 7 | }); 8 | -------------------------------------------------------------------------------- /src/services/authentication.service.ts: -------------------------------------------------------------------------------- 1 | import {inject, service} from "@waytrade/microservice-core"; 2 | import {IBApiApp} from "../app"; 3 | import {SecurityUtils} from "../utils/security.utils"; 4 | 5 | /** 6 | * The user authentication service. 7 | */ 8 | @service() 9 | export class AuthenticationService { 10 | @inject("IBApiApp") 11 | private app!: IBApiApp; 12 | 13 | /** Start the service. */ 14 | start(): void { 15 | if (!this.app.config.REST_API_USERNAME) { 16 | throw new Error("REST_API_USERNAME not configured."); 17 | } 18 | if (!this.app.config.REST_API_PASSWORD) { 19 | throw new Error("REST_API_PASSWORD not configured."); 20 | } 21 | } 22 | 23 | /** 24 | * Login with username and password. 25 | * 26 | * @returns the JWT token. 27 | */ 28 | async loginUserPassword(username: string, password: string): Promise { 29 | // verify username/password 30 | if ( 31 | username !== this.app.config.REST_API_USERNAME || 32 | password !== this.app.config.REST_API_PASSWORD 33 | ) { 34 | throw new Error("Wrong username or password"); 35 | } 36 | 37 | return SecurityUtils.createJWT(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/services/ib-api-factory.service.ts: -------------------------------------------------------------------------------- 1 | import * as IB from "@stoqey/ib"; 2 | import {inject, service} from "@waytrade/microservice-core"; 3 | import {firstValueFrom, ReplaySubject} from "rxjs"; 4 | import {IBApiApp} from "../app"; 5 | import {IBApiLoggerProxy} from "../utils/ib-api-logger-proxy"; 6 | 7 | 8 | /** 9 | * IBApiNext factory service. 10 | */ 11 | @service() 12 | export class IBApiFactoryService { 13 | @inject("IBApiApp") 14 | private app!: IBApiApp; 15 | 16 | /** The [[IBApiNext]] instance subject. */ 17 | private _api = new ReplaySubject(1); 18 | 19 | 20 | /** Start the service. */ 21 | start(): void { 22 | if (!this.app.config.IB_GATEWAY_PORT) { 23 | throw Error("IB_GATEWAY_PORT not configured."); 24 | } 25 | if (!this.app.config.IB_GATEWAY_HOST) { 26 | throw Error("IB_GATEWAY_HOST not configured."); 27 | } 28 | 29 | const ibApi = new IB.IBApiNext({ 30 | port: this.app.config.IB_GATEWAY_PORT, 31 | host: this.app.config.IB_GATEWAY_HOST, 32 | logger: new IBApiLoggerProxy(this.app), 33 | connectionWatchdogInterval: 30, 34 | reconnectInterval: 5000, 35 | }); 36 | 37 | switch (this.app.config.LOG_LEVEL) { 38 | case "debug": 39 | ibApi.logLevel = IB.LogLevel.DETAIL; 40 | break; 41 | case "info": 42 | ibApi.logLevel = IB.LogLevel.INFO; 43 | break; 44 | case "warn": 45 | ibApi.logLevel = IB.LogLevel.WARN; 46 | break; 47 | case "error": 48 | ibApi.logLevel = IB.LogLevel.ERROR; 49 | break; 50 | } 51 | 52 | ibApi.connect(0); 53 | 54 | this._api.next(ibApi); 55 | } 56 | 57 | /** Stop the service. */ 58 | stop(): void { 59 | this.api.then((p) => p.disconnect()); 60 | } 61 | 62 | get api(): Promise { 63 | return firstValueFrom(this._api); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/services/ib-api.service.ts: -------------------------------------------------------------------------------- 1 | import * as IB from "@stoqey/ib"; 2 | import {IBApiNextError} from "@stoqey/ib"; 3 | import { 4 | DiffTools, 5 | inject, 6 | MapExt, 7 | service, 8 | subscribeUntil 9 | } from "@waytrade/microservice-core"; 10 | import LruCache from "lru-cache"; 11 | import { 12 | firstValueFrom, map, 13 | Observable, Subject, 14 | Subscription, 15 | takeUntil 16 | } from "rxjs"; 17 | import {IBApiApp} from "../app"; 18 | import {AccountSummary} from "../models/account-summary.model"; 19 | import {BarSize} from "../models/historic-data-request.model"; 20 | import {MarketData} from "../models/market-data.model"; 21 | import {OHLCBars} from "../models/ohlc-bar.model"; 22 | import {Position} from "../models/position.model"; 23 | import { 24 | ACCOUNT_SUMMARY_TAGS, 25 | IBApiServiceHelper as Helper 26 | } from "../utils/ib.helper"; 27 | import {IBApiFactoryService} from "./ib-api-factory.service"; 28 | 29 | /** 30 | * Send interval on market data ticks in milliseconds. 31 | * TWS sends market data value changes one-by-one which can cause a lot of 32 | * overhead donwstream. Use this setting to reduce update frequency. 33 | */ 34 | const MARKET_DATA_SEND_INTERVAL_MS = 10; 35 | 36 | /** Re-try intervall when an IB function has reported an error */ 37 | const IB_ERROR_RETRY_INTERVAL = 1000 * 30; // 30sec 38 | 39 | /** Re-try intervall when an IB function has reported an error for tests */ 40 | const IB_ERROR_RETRY_INTERVAL_TEST = 1000; // 1sec 41 | 42 | 43 | /** An update the positions. */ 44 | export class PositionsUpdate { 45 | /** List of positions added or changed since last update. */ 46 | changed?: Position[]; 47 | 48 | /** List of positions closed since last update. */ 49 | closed?: Position[]; 50 | } 51 | 52 | /** 53 | * The Interactive Brokers TWS API Service 54 | */ 55 | @service() 56 | export class IBApiService { 57 | @inject("IBApiApp") 58 | private app!: IBApiApp; 59 | 60 | /** The [[IBApiNext]] factory instance. */ 61 | @inject("IBApiFactoryService") 62 | private factory!: IBApiFactoryService; 63 | 64 | /** The [[IBApiNext]] instance. */ 65 | private api!: IB.IBApiNext; 66 | 67 | /** Subscription on IBApiNext connection date. */ 68 | private connectionState$?: Subscription; 69 | 70 | /** The service shutdown signal. */ 71 | private shutdownSignal = new Subject(); 72 | 73 | /** Cache of a requested contract details, with conId as key. */ 74 | private readonly contractDetailsCache = new LruCache< 75 | number, 76 | IB.ContractDetails[] 77 | >({ 78 | max: 128, 79 | }); 80 | 81 | /** All current account summary values. */ 82 | private readonly currentAccountSummaries = new MapExt< 83 | string, 84 | AccountSummary 85 | >(); 86 | 87 | /** Account summary change subject. */ 88 | private readonly accountSummariesChange = new Subject(); 89 | 90 | /** All current positions. */ 91 | private readonly currentPositions = new MapExt< 92 | string, 93 | Position 94 | >(); 95 | 96 | /** Position change subject. */ 97 | private readonly positionsChange = new Subject(); 98 | 99 | /** Start the service. */ 100 | async start(): Promise { 101 | this.factory.api.then(api => { 102 | this.api = api; 103 | this.subscribeAccountSummaries(); 104 | this.subscribeAccountPnL(); 105 | this.subscribePositions(); 106 | 107 | // exit on connection loss 108 | 109 | let connectionTries = 0; 110 | let connectedTimer: NodeJS.Timeout; 111 | 112 | this.connectionState$ = api.connectionState.subscribe({ 113 | next: state => { 114 | switch (state) { 115 | case IB.ConnectionState.Connecting: 116 | connectionTries++; 117 | break; 118 | case IB.ConnectionState.Connected: 119 | if (connectedTimer) { 120 | clearTimeout(connectedTimer); 121 | } 122 | connectedTimer = global.setTimeout(() => { 123 | connectionTries = 0; 124 | }, 2000); // wait 2s to ensure a stable connection before resetting connectionTries 125 | break; 126 | case IB.ConnectionState.Disconnected: 127 | if (connectedTimer) { 128 | clearTimeout(connectedTimer); 129 | } 130 | if ( 131 | connectionTries >= (this.app.config.IB_GATEWAY_RECONNECT_TRIES ?? 0) 132 | ) { 133 | this.app.error("Lost connection to IB Gateway, shutown app..."); 134 | api.disconnect(); 135 | this.app.stop(); 136 | } 137 | break; 138 | } 139 | }, 140 | }); 141 | }); 142 | } 143 | 144 | /** Stop the service. */ 145 | stop(): void { 146 | this.connectionState$?.unsubscribe(); 147 | this.shutdownSignal.next(); 148 | } 149 | 150 | /** Search contracts where name or symbol matches the given text pattern. */ 151 | async searchContracts(pattern: string): Promise { 152 | try { 153 | return await this.api.searchContracts(pattern); 154 | } catch(e) { 155 | throw (e).error; 156 | } 157 | } 158 | 159 | /** Get the contract details for contract that match the given criteria. */ 160 | async getContractDetails( 161 | contract: IB.Contract, 162 | ): Promise { 163 | if (contract.conId) { 164 | const cache = this.contractDetailsCache.get(contract.conId); 165 | if (cache) { 166 | return cache; 167 | } 168 | } 169 | 170 | try { 171 | const details = await this.api?.getContractDetails(contract); 172 | if (contract.conId && details.length) { 173 | this.contractDetailsCache.set(contract.conId, details); 174 | } 175 | return details; 176 | } catch(e) { 177 | throw (e).error; 178 | } 179 | } 180 | 181 | /** Get historic OHLC data of a contract of a given contract ID. */ 182 | async getHistoricData( 183 | conId: number, 184 | end: string | undefined, 185 | duration: string, 186 | barSize: BarSize, 187 | whatToShow: string): Promise { 188 | const contractDetails = (await this.getContractDetails({conId})); 189 | if (!contractDetails.length) { 190 | throw Error("Contract to found."); 191 | } 192 | try { 193 | return { 194 | bars: await this.api.getHistoricalData( 195 | contractDetails[0].contract, end, duration, 196 | barSize as IB.BarSizeSetting, whatToShow, 1, 1) 197 | }; 198 | } catch(e) { 199 | throw (e).error; 200 | } 201 | } 202 | 203 | /** Get the accounts to which the logged user has access to. */ 204 | get managedAccounts(): Promise { 205 | return this.api.getManagedAccounts(); 206 | } 207 | 208 | /** Get the account summary of the given account */ 209 | getAccountSummary(account: string): Observable { 210 | return new Observable(res => { 211 | // initial event 212 | const current = this.currentAccountSummaries.get(account); 213 | const firstEvent: AccountSummary = {account}; 214 | let baseCurrencySent = false; 215 | if (current) { 216 | Object.assign(firstEvent, current); 217 | firstEvent.baseCurrency = this.app.config.BASE_CURRENCY; 218 | baseCurrencySent = true; 219 | } 220 | res.next(firstEvent); 221 | 222 | // dispatch changes 223 | const sub$ = this.accountSummariesChange 224 | .pipe(map(v => v.find(v => v.account === account))) 225 | .subscribe({ 226 | next: update => { 227 | const current: AccountSummary = {account}; 228 | Object.assign(current, update); 229 | if (!baseCurrencySent) { 230 | current.baseCurrency = this.app.config.BASE_CURRENCY; 231 | } 232 | res.next(update); 233 | } 234 | }); 235 | 236 | return (): void => sub$.unsubscribe(); 237 | }); 238 | } 239 | 240 | /** Get the account summaries of all accounts */ 241 | get accountSummaries(): Observable { 242 | return new Observable(res => { 243 | const baseCurrencytSent = new Set(); 244 | const current = Array.from(this.currentAccountSummaries.values()); 245 | current.forEach(v => { 246 | v.baseCurrency = this.app.config.BASE_CURRENCY; 247 | baseCurrencytSent.add(v.account); 248 | }); 249 | res.next(current); 250 | const sub$ = this.accountSummariesChange.subscribe({ 251 | next: update => { 252 | update.forEach(v => { 253 | if (!baseCurrencytSent.has(v.account)) { 254 | baseCurrencytSent.add(v.account); 255 | v.baseCurrency = this.app.config.BASE_CURRENCY; 256 | } 257 | }); 258 | res.next(update); 259 | } 260 | }); 261 | return (): void => sub$.unsubscribe(); 262 | }); 263 | } 264 | 265 | get positions(): Observable { 266 | return new Observable(res => { 267 | res.next({ 268 | changed: Array.from(this.currentPositions.values()) 269 | }); 270 | const sub$ = this.positionsChange.subscribe({ 271 | next: update => { 272 | res.next(update); 273 | } 274 | }); 275 | return (): void => sub$.unsubscribe(); 276 | }); 277 | } 278 | 279 | /** Get market data updates for the given conId.*/ 280 | getMarketData(conId: number): Observable { 281 | return new Observable(subscriber => { 282 | const cancel = new Subject(); 283 | 284 | // lookup contract details from conId 285 | 286 | this.getContractDetails({conId}) 287 | .then(details => { 288 | const contract = details.find( 289 | v => v.contract.conId === conId, 290 | )?.contract; 291 | if (!contract) { 292 | subscriber.error(new Error("conId not found")); 293 | return; 294 | } 295 | 296 | const lastSentMarketData: MarketData = {}; 297 | let currentMarketData: MarketData = {}; 298 | 299 | // donwstream send timer 300 | 301 | const sendTimer = setInterval(() => { 302 | const changedMarketData = DiffTools.diff( 303 | lastSentMarketData, 304 | currentMarketData, 305 | ).changed; 306 | 307 | if (changedMarketData) { 308 | Object.assign(currentMarketData, changedMarketData); 309 | Object.assign(lastSentMarketData, currentMarketData); 310 | subscriber.next(changedMarketData); 311 | } 312 | }, MARKET_DATA_SEND_INTERVAL_MS); 313 | 314 | // do not display delayed market data at all: 315 | this.api.setMarketDataType(IB.MarketDataType.FROZEN); 316 | 317 | // subscribe on market data 318 | 319 | const sub$ = this.api 320 | .getMarketData(contract, "104,105,106,165,411", false, false) 321 | .subscribe({ 322 | next: update => { 323 | currentMarketData = Helper.marketDataTicksToModel(update.all); 324 | }, 325 | error: (error: IB.IBApiNextError) => { 326 | this.app.error( 327 | `getMarketData(${conId} / ${contract.symbol}) failed with ${error.error.message}`, 328 | ); 329 | subscriber.error(new Error(error.error.message)); 330 | clearInterval(sendTimer); 331 | }, 332 | }); 333 | 334 | // handle cancel signal 335 | 336 | firstValueFrom(cancel).then(() => { 337 | clearInterval(sendTimer); 338 | sub$.unsubscribe(); 339 | }); 340 | }) 341 | .catch(e => { 342 | this.app.error( 343 | `getContractDetails(${conId}) failed with: ${e.message}`, 344 | ); 345 | subscriber.error(e); 346 | }); 347 | 348 | return (): void => { 349 | cancel.next(true); 350 | cancel.complete(); 351 | }; 352 | }); 353 | } 354 | 355 | /** Subscribe on account summaries */ 356 | private subscribeAccountSummaries(): void { 357 | subscribeUntil( 358 | this.shutdownSignal, 359 | this.api 360 | .getAccountSummary( 361 | "All", 362 | ACCOUNT_SUMMARY_TAGS.join(",") + 363 | `$LEDGER:${this.app.config.BASE_CURRENCY}`, 364 | ), 365 | { 366 | error: err => { 367 | setTimeout(() => { 368 | this.app.error("getAccountSummary: " + err.error.message); 369 | this.subscribeAccountSummaries(); 370 | }, this.app.config.isTest ? 371 | IB_ERROR_RETRY_INTERVAL_TEST : IB_ERROR_RETRY_INTERVAL 372 | ); 373 | }, 374 | next: update => { 375 | // collect updated 376 | 377 | const updated = new Map([ 378 | ...(update.changed?.entries() ?? []), 379 | ...(update.added?.entries() ?? []), 380 | ]); 381 | 382 | const changed = new MapExt(); 383 | updated.forEach((tagValues, accountId) => { 384 | Helper.colllectAccountSummaryTagValues( 385 | accountId, 386 | tagValues, 387 | this.app.config.BASE_CURRENCY ?? "", 388 | changed, 389 | ); 390 | }); 391 | 392 | changed.forEach(changedSummary => { 393 | const currentSummary = this.currentAccountSummaries.getOrAdd( 394 | changedSummary.account, 395 | () => new AccountSummary({account: changedSummary.account}), 396 | ); 397 | Object.assign(currentSummary, changedSummary); 398 | }); 399 | 400 | // emit update event 401 | 402 | if (changed.size) { 403 | this.accountSummariesChange.next(Array.from(changed.values())); 404 | } 405 | }, 406 | }, 407 | ); 408 | } 409 | 410 | /** Subscribe on account PnLs */ 411 | private subscribeAccountPnL(): void { 412 | this.managedAccounts.then(managedAccount => { 413 | managedAccount?.forEach(accountId => { 414 | subscribeUntil( 415 | this.shutdownSignal, 416 | this.api.getPnL(accountId), 417 | { 418 | error: (err: IB.IBApiNextError) => { 419 | this.app.error(`getPnL(${accountId}): ${err.error.message}`); 420 | setTimeout(() => { 421 | this.subscribeAccountPnL(); 422 | }, this.app.config.isTest ? 423 | IB_ERROR_RETRY_INTERVAL_TEST : IB_ERROR_RETRY_INTERVAL 424 | ); 425 | }, 426 | next: pnl => { 427 | if ( 428 | pnl.dailyPnL !== undefined || 429 | pnl.realizedPnL !== undefined || 430 | pnl.unrealizedPnL !== undefined 431 | ) { 432 | const changedSummary: AccountSummary = { 433 | account: accountId, 434 | dailyPnL: pnl.dailyPnL, 435 | realizedPnL: pnl.realizedPnL, 436 | unrealizedPnL: pnl.unrealizedPnL, 437 | }; 438 | 439 | const currentSummary = this.currentAccountSummaries.getOrAdd( 440 | changedSummary.account, 441 | () => new AccountSummary({account: changedSummary.account}), 442 | ); 443 | Object.assign(currentSummary, changedSummary); 444 | 445 | this.accountSummariesChange.next([changedSummary]); 446 | } 447 | }, 448 | }, 449 | ); 450 | }); 451 | }); 452 | } 453 | 454 | /** Subscribe on all account positions. */ 455 | private subscribePositions(): void { 456 | const pnlSubscriptions = new Map(); 457 | firstValueFrom(this.shutdownSignal).then(() => { 458 | pnlSubscriptions.forEach(p => p.unsubscribe()); 459 | pnlSubscriptions.clear(); 460 | }); 461 | 462 | subscribeUntil( 463 | this.shutdownSignal, 464 | this.api.getPositions(), { 465 | error: (error: IB.IBApiNextError) => { 466 | pnlSubscriptions.forEach(p => p.unsubscribe()); 467 | pnlSubscriptions.clear(); 468 | this.app.error("getPositions(): " + error.error.message); 469 | setTimeout(() => { 470 | this.subscribePositions(); 471 | }, this.app.config.isTest ? 472 | IB_ERROR_RETRY_INTERVAL_TEST : IB_ERROR_RETRY_INTERVAL 473 | ); 474 | }, 475 | next: update => { 476 | const changed = new MapExt(); 477 | 478 | // collect updated 479 | 480 | const updated = new Map([ 481 | ...(update.changed?.entries() ?? []), 482 | ...(update.added?.entries() ?? []), 483 | ]); 484 | 485 | const zeroSizedIds: string[] = []; 486 | 487 | updated.forEach((positions, accountId) => { 488 | positions.forEach(ibPosition => { 489 | const posId = Helper.formatPositionId( 490 | accountId, 491 | ibPosition.contract.conId, 492 | ); 493 | 494 | if (!ibPosition.pos) { 495 | if (this.currentPositions.has(posId)) { 496 | zeroSizedIds.push(posId); 497 | } 498 | return; 499 | } 500 | 501 | let newPosition = false; 502 | const prevPositions = this.currentPositions.getOrAdd(posId, () => { 503 | newPosition = true; 504 | return new Position({ 505 | id: posId, 506 | avgCost: ibPosition.avgCost, 507 | account: accountId, 508 | conId: ibPosition.contract.conId, 509 | pos: ibPosition.pos, 510 | }); 511 | }); 512 | 513 | const changedPos: Position = {id: posId}; 514 | if (ibPosition.avgCost != undefined && 515 | prevPositions.avgCost !== ibPosition.avgCost) { 516 | changedPos.avgCost = ibPosition.avgCost; 517 | } 518 | if (ibPosition.pos != undefined && 519 | prevPositions.pos !== ibPosition.pos) { 520 | changedPos.pos = ibPosition.pos; 521 | } 522 | 523 | if (newPosition) { 524 | changed.set(posId, prevPositions); 525 | } else if (Object.keys(changedPos).length > 1) { 526 | changed.set(posId, changedPos); 527 | } 528 | }); 529 | }); 530 | 531 | // collect closed 532 | 533 | const removedIds = new Set(zeroSizedIds); 534 | update.removed?.forEach((positions, account) => { 535 | positions.forEach(pos => { 536 | const id = Helper.formatPositionId(account, pos.contract.conId); 537 | removedIds.add(id); 538 | }); 539 | }); 540 | 541 | // update 542 | 543 | if (changed.size || removedIds.size) { 544 | const closedPositions: Position[] = []; 545 | removedIds.forEach(id => { 546 | this.currentPositions.delete(id); 547 | closedPositions.push(new Position({id})); 548 | }); 549 | 550 | this.positionsChange.next({ 551 | changed: changed.size 552 | ? Array.from(changed.values()) 553 | : undefined, 554 | closed: closedPositions, 555 | }); 556 | } 557 | 558 | // subscribe on PnL 559 | 560 | updated.forEach((positions, accountId) => { 561 | positions.forEach(ibPosition => { 562 | if (!ibPosition.pos) { 563 | return; 564 | } 565 | 566 | const posId = Helper.formatPositionId( 567 | accountId, 568 | ibPosition.contract.conId, 569 | ); 570 | 571 | if (pnlSubscriptions.has(posId)) { 572 | return; 573 | } 574 | 575 | const sub = this.api.getPnLSingle( 576 | ibPosition.account, 577 | "", 578 | ibPosition.contract.conId ?? 0, 579 | ) 580 | .pipe(takeUntil(this.shutdownSignal)) 581 | .subscribe({ 582 | error: (error: IB.IBApiNextError) => { 583 | pnlSubscriptions.delete(posId); 584 | this.app.error( 585 | `getPnLSingle(${ibPosition.contract.symbol}): ${error.error.message}`, 586 | ); 587 | }, 588 | next: pnl => { 589 | let prePos = this.currentPositions.get(posId); 590 | 591 | if ( 592 | prePos && 593 | pnl.position !== undefined && 594 | !pnl.position 595 | ) { 596 | this.currentPositions.delete(posId); 597 | this.positionsChange.next({ 598 | closed: [new Position({id: posId})], 599 | }); 600 | return; 601 | } 602 | 603 | const changedPos: Position = {id: posId}; 604 | if (prePos?.account !== accountId) { 605 | changedPos.account = accountId; 606 | } 607 | if (pnl.dailyPnL != undefined && 608 | prePos?.dailyPnL !== pnl.dailyPnL) { 609 | changedPos.dailyPnL = pnl.dailyPnL; 610 | } 611 | if (pnl.marketValue != undefined && 612 | prePos?.marketValue !== pnl.marketValue) { 613 | changedPos.marketValue = pnl.marketValue; 614 | } 615 | if (pnl.position != undefined && 616 | prePos?.pos !== pnl.position) { 617 | changedPos.pos = pnl.position; 618 | } 619 | if (pnl.realizedPnL != undefined && 620 | prePos?.realizedPnL !== pnl.realizedPnL) { 621 | changedPos.realizedPnL = pnl.realizedPnL; 622 | } 623 | if (pnl.unrealizedPnL != undefined && 624 | prePos?.unrealizedPnL !== pnl.unrealizedPnL) { 625 | changedPos.unrealizedPnL = pnl.unrealizedPnL; 626 | } 627 | 628 | if (!prePos) { 629 | prePos = {id: posId}; 630 | Object.assign(prePos, changedPos); 631 | this.currentPositions.set(posId, prePos); 632 | } else { 633 | Object.assign(prePos, changedPos); 634 | } 635 | 636 | if (Object.keys(changedPos).length > 1) { 637 | this.positionsChange.next({ 638 | changed: [changedPos], 639 | }); 640 | } 641 | }, 642 | }); 643 | 644 | pnlSubscriptions.set(posId, sub); 645 | }); 646 | }); 647 | 648 | removedIds.forEach(id => { 649 | pnlSubscriptions.get(id)?.unsubscribe(); 650 | }); 651 | } 652 | } 653 | ); 654 | } 655 | } 656 | -------------------------------------------------------------------------------- /src/utils/ib-api-logger-proxy.ts: -------------------------------------------------------------------------------- 1 | import {Logger} from "@stoqey/ib"; 2 | import {IBApiApp} from "../app"; 3 | 4 | /** 5 | * Proxy to froward IBApiNext logs to into the context. 6 | */ 7 | export class IBApiLoggerProxy implements Logger { 8 | constructor(private readonly app: IBApiApp) {} 9 | 10 | debug(tag: string, args: string | unknown[]): void { 11 | this.app.debug(`[${tag}] ${args}`); 12 | } 13 | 14 | info(tag: string, args: string | unknown[]): void { 15 | this.app.info(`[${tag}] ${args}`); 16 | } 17 | 18 | warn(tag: string, args: string | unknown[]): void { 19 | this.app.warn(`[${tag}] ${args}`); 20 | } 21 | 22 | error(tag: string, args: string | unknown[]): void { 23 | this.app.error(`[${tag}] ${args}`); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/ib.helper.ts: -------------------------------------------------------------------------------- 1 | import * as IB from "@stoqey/ib"; 2 | import {MapExt} from "@waytrade/microservice-core"; 3 | import {AccountSummary} from "../models/account-summary.model"; 4 | import {MarketData} from "../models/market-data.model"; 5 | 6 | /** 7 | * Account summary tag values, make suer this is in sync with 8 | * AccountSummary model. 9 | * 10 | * Ecented AccountSummary model if you extend this!! 11 | */ 12 | export const ACCOUNT_SUMMARY_TAGS = [ 13 | "AccountType", 14 | "NetLiquidation", 15 | "TotalCashValue", 16 | "SettledCash", 17 | "AccruedCash", 18 | "BuyingPower", 19 | "EquityWithLoanValue", 20 | "PreviousEquityWithLoanValue", 21 | "GrossPositionValue", 22 | "RegTEquity", 23 | "RegTMargin", 24 | "InitMarginReq", 25 | "SMA", 26 | "InitMarginReq", 27 | "MaintMarginReq", 28 | "AvailableFunds", 29 | "ExcessLiquidity", 30 | "Cushion", 31 | "FullInitMarginReq", 32 | "FullMaintMarginReq", 33 | "FullAvailableFunds", 34 | "FullExcessLiquidity", 35 | "LookAheadNextChange", 36 | "LookAheadInitMarginReq", 37 | "LookAheadMaintMarginReq", 38 | "LookAheadAvailableFunds", 39 | "LookAheadExcessLiquidity", 40 | "HighestSeverity", 41 | "DayTradesRemaining", 42 | "Leverage", 43 | ]; 44 | 45 | /** 46 | * Collection of helper functions used by IBApiService. 47 | */ 48 | export class IBApiServiceHelper { 49 | /** 50 | * Collect account summary tag values to map of account ids and 51 | * AccountSummary models. 52 | */ 53 | static colllectAccountSummaryTagValues( 54 | accountId: string, 55 | tagValues: IB.AccountSummaryTagValues, 56 | baseCurrency: string, 57 | all: MapExt, 58 | ): void { 59 | const accountSummary = all.getOrAdd( 60 | accountId, 61 | () => new AccountSummary({account: accountId}), 62 | ); 63 | tagValues.forEach((summaryValues, key) => { 64 | if (ACCOUNT_SUMMARY_TAGS.find(v => v === key)) { 65 | let value = summaryValues.get(baseCurrency); 66 | if (!value) { 67 | value = summaryValues.get(""); 68 | } 69 | let val: number | string | undefined = Number(value?.value); 70 | if (isNaN(val)) { 71 | val = value?.value; 72 | } 73 | if (val !== undefined) { 74 | ((accountSummary) as Record)[ 75 | key[0].toLowerCase() + key.substr(1) 76 | ] = val; 77 | } 78 | } 79 | }); 80 | if (Object.keys(accountSummary).length === 1) { 81 | all.delete(accountId); 82 | } 83 | } 84 | 85 | /** Convert IB.MarketDataTicks to MarketData model. */ 86 | static marketDataTicksToModel(data: IB.MarketDataTicks): MarketData { 87 | const result: Record = {}; 88 | data.forEach((value, type) => { 89 | if (!value.value) { 90 | return; 91 | } 92 | let propName = 93 | type > IB.IBApiNextTickType.API_NEXT_FIRST_TICK_ID 94 | ? IB.IBApiNextTickType[type] 95 | : IB.IBApiTickType[type]; 96 | if (propName?.startsWith("DELAYED_")) { 97 | propName = propName.substr("DELAYED_".length); 98 | } 99 | if (propName) { 100 | result[propName] = value.value; 101 | } 102 | }); 103 | return result; 104 | } 105 | 106 | /** Format a possition id. */ 107 | static formatPositionId(accountId: string, conId?: number): string { 108 | return `${accountId}:${conId}`; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/utils/security.utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpError, 3 | HttpStatus, 4 | MicroserviceRequest 5 | } from "@waytrade/microservice-core"; 6 | import Cookie from "cookie"; 7 | import crypto from "crypto"; 8 | import jwt from "jsonwebtoken"; 9 | 10 | /** Secret used signing JWT tokens. */ 11 | const JWT_SECRET = crypto.randomBytes(64).toString("hex"); 12 | 13 | /** Lifetime of a JWT Bearer token in seconds. */ 14 | const JWT_TOKEN_LIFETIME = 60 * 60 * 48; // 48h 15 | 16 | /** 17 | * Collection of security-related helper functions. 18 | */ 19 | export class SecurityUtils { 20 | /** Create a JWT token. */ 21 | static createJWT(): string { 22 | return jwt.sign( 23 | { 24 | exp: Math.floor(Date.now() / 1000) + JWT_TOKEN_LIFETIME, 25 | }, 26 | JWT_SECRET, 27 | ); 28 | } 29 | 30 | /* 31 | * Verify that authorization headers contains a valid JWT token, signed 32 | * by this service instance. 33 | */ 34 | static vefiyBearer(token: string): boolean { 35 | try { 36 | jwt.verify(token.substr("Bearer ".length), JWT_SECRET); 37 | } catch (e) { 38 | return false; 39 | } 40 | return true; 41 | } 42 | 43 | /** 44 | * Verify that authorization headers contains a valid JWT token, signed 45 | * by this service instance. 46 | * 47 | * @throws a HttpError if failed. 48 | */ 49 | static ensureAuthorization(request: MicroserviceRequest): void { 50 | // get the bearer token from request headers 51 | 52 | let bearerToken = request.headers.get("authorization"); 53 | if (!bearerToken) { 54 | const cookie = request.headers.get("cookie"); 55 | if (cookie) { 56 | bearerToken = Cookie.parse(cookie).authorization; 57 | } 58 | } 59 | 60 | if (!bearerToken) { 61 | throw new HttpError( 62 | HttpStatus.UNAUTHORIZED, 63 | "Missing authorization header", 64 | ); 65 | } 66 | 67 | if (!this.vefiyBearer(bearerToken)) { 68 | throw new HttpError( 69 | HttpStatus.UNAUTHORIZED, 70 | "Invalid bearer token", 71 | ); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "include": ["src"], 4 | "exclude": [ 5 | "**/*.test.ts" 6 | ], 7 | "compilerOptions": { 8 | "baseUrl": "./", 9 | "outDir": "./dist", 10 | "rootDir": "./src", 11 | "target": "es2020", 12 | "module": "commonjs", 13 | "moduleResolution": "node", 14 | "lib": ["es2020", "dom"], 15 | "declaration": true, 16 | "sourceMap": true, 17 | "strict": true, 18 | "noImplicitAny": true, 19 | "strictNullChecks": true, 20 | "strictFunctionTypes": true, 21 | "strictBindCallApply": true, 22 | "strictPropertyInitialization": true, 23 | "noImplicitThis": true, 24 | "alwaysStrict": true, 25 | "paths": { 26 | "*": ["./node_modules/*"] 27 | }, 28 | "typeRoots": ["node_modules/@types", "node_modules/types"], 29 | "esModuleInterop": true, 30 | "experimentalDecorators": true, 31 | "emitDecoratorMetadata": true, 32 | "resolveJsonModule": true, 33 | "skipLibCheck": true, 34 | "forceConsistentCasingInFileNames": true 35 | } 36 | } 37 | --------------------------------------------------------------------------------