├── .browserslistrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── build └── icons │ ├── icon.icns │ ├── icon.png │ ├── icon_128x128.png │ ├── icon_128x128@2x.png │ ├── icon_16x16.png │ ├── icon_16x16@2x.png │ ├── icon_256x256.png │ ├── icon_256x256@2x.png │ ├── icon_32x32.png │ ├── icon_32x32@2x.png │ ├── icon_512x512.png │ └── icon_512x512@2x.png ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── iconTemplate.png ├── index.html └── worker.html ├── src ├── App.vue ├── assets │ ├── .gitkeep │ └── logo.svg ├── background.js ├── components │ ├── HelloWorld.vue │ ├── Portfolio.vue │ └── StrategySelector.vue ├── lib │ ├── api │ │ ├── index.js │ │ └── twitter.js │ ├── brokers │ │ ├── index.js │ │ └── robinhood │ │ │ └── index.js │ ├── db.js │ ├── initDb.js │ ├── login.js │ ├── strategies │ │ ├── ReverseSplitArbitrage.js │ │ └── Strategy.js │ └── trader.js ├── logger │ └── index.js ├── main.js ├── plugins │ └── vuetify.js ├── router │ └── index.js ├── store │ └── index.js ├── views │ ├── Home.vue │ ├── Login.vue │ ├── Logout.vue │ ├── Logs.vue │ └── Strategies.vue └── worker │ ├── Worker.vue │ └── worker.js ├── tests └── unit │ ├── electron.spec.js │ └── strategy-selector.spec.js └── vue.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist_electron 2 | *.spec.js 3 | strategies/* 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | '@vue/airbnb', 9 | ], 10 | parserOptions: { 11 | parser: 'babel-eslint', 12 | }, 13 | rules: { 14 | 'linebreak-style': ["off", "unix"], 15 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 17 | 'import/no-extraneous-dependencies': ['error', {'devDependencies': true}], 18 | 'max-len': ["error", { 19 | "code": 150, 20 | "ignoreTemplateLiterals": true, 21 | "ignoreStrings": true 22 | }], 23 | }, 24 | overrides: [ 25 | { 26 | files: [ 27 | '**/__tests__/*.{j,t}s?(x)', 28 | '**/tests/unit/**/*.spec.{j,t}s?(x)', 29 | ], 30 | env: { 31 | mocha: true, 32 | }, 33 | }, 34 | ], 35 | }; 36 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [mafischer] 4 | patreon: trader_bot 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build/release 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | 9 | strategy: 10 | matrix: 11 | os: [macos-latest, ubuntu-latest, windows-latest] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: Get npm cache directory 20 | id: npm-cache-dir 21 | run: | 22 | echo "::set-output name=dir::$(npm config get cache)" 23 | - uses: actions/cache@v2 24 | id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true' 25 | with: 26 | path: ${{ steps.npm-cache-dir.outputs.dir }} 27 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 28 | restore-keys: | 29 | ${{ runner.os }}-node- 30 | - name: Build/release Electron app 31 | uses: samuelmeuli/action-electron-builder@v1 32 | with: 33 | # GitHub token, automatically provided to the action 34 | # (No need to define this secret in the repo settings) 35 | github_token: ${{ secrets.github_token }} 36 | 37 | # If the commit is tagged with a version (e.g. "v1.0.0"), 38 | # release the app after building 39 | release: ${{ startsWith(github.ref, 'refs/tags/v') }} 40 | 41 | use_vue_cli: true 42 | 43 | mac_certs: ${{ secrets.mac_certs }} 44 | mac_certs_password: ${{ secrets.mac_certs_password }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Tests 5 | 6 | on: 7 | push: 8 | branches: '*' 9 | pull_request: 10 | branches: '*' 11 | 12 | jobs: 13 | test: 14 | 15 | runs-on: ${{ matrix.os }} 16 | 17 | strategy: 18 | matrix: 19 | os: [macos-latest, ubuntu-latest, windows-latest] 20 | node-version: [12.x] 21 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v1 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - name: Get npm cache directory 30 | id: npm-cache-dir 31 | run: | 32 | echo "::set-output name=dir::$(npm config get cache)" 33 | - uses: actions/cache@v2 34 | id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true' 35 | with: 36 | path: ${{ steps.npm-cache-dir.outputs.dir }} 37 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 38 | restore-keys: | 39 | ${{ runner.os }}-node- 40 | - run: npm ci 41 | - name: tests 42 | run: npm run test:unit 43 | if: matrix.os != 'ubuntu-latest' 44 | - name: ubuntu tests 45 | run: "xvfb-run --auto-servernum npm run test:unit" 46 | if: matrix.os == 'ubuntu-latest' 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .npmrc 4 | /dist 5 | *.sql 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | #Electron-builder output 27 | /dist_electron -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Michael Fischer 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Trader Bot 2 | A stock trading bot that can automate trades based on elected trade strategies. Trade strategies are plugins that are provided out of the box, created by the end user, or provided by the community. 3 | 4 | ## TL;DR 5 | Install from [here](#Installation) and enjoy! 6 | 7 | ## Acknowledgements 8 | - This bot and the reverse-spit strategy were initially inspired by the work of [@ReverseSplitArb](https://twitter.com/ReverseSplitArb), send some appreciation that way. 9 | - This project has been built on top of the work of other open source initiatives; for more information, please see the dependencies listed in the [package.json](package.json). 10 | 11 | ## Warnings 12 | - This software should be considered experimental! 13 | - You are giving this experimental software access to your stock broker(s). **THERE IS POTENTIAL FOR FINANCIAL LOSS!!** 14 | - All data is stored on your local file system in SQLite databases. 15 | - Your credentials will be encrypted and stored in SQLite. 16 | - Anonymous usage statistics may be sent over the network if you chose to allow it. 17 | 18 | ## License 19 | This software is licensed under the ISC license. See [LICENSE](LICENSE) for full details. 20 | 21 | ## Usage 22 | 23 | ### Installation 24 | - Download the installer from [TO DO](#) 25 | 26 | ### Setup 27 | 28 | #### Pre Requisites 29 | - For access to twitter data, [apply](https://developer.twitter.com/en/apply-for-access) for a twitter developer account 30 | - create an account with a supported broker (support further development by using below links to open an account): 31 | - **robinhood**: https://join.robinhood.com/michaef30 32 | - Please review Robinhood's [TOS](https://cdn.robinhood.com/assets/robinhood/legal/Customer%20Agreement.pdf) before using this software. 33 | - **webull**: *coming next, soon..* 34 | - **more to come** 35 | 36 | #### First Run 37 | - Start the application. 38 | - You will be prompted for your broker and twitter developer credentials. 39 | - You will be prompted for a password to encrypt and decrypt your credentials. 40 | - Elect one or more trading strategies. 41 | - The bot will now make trades according to your elected strategitesl 42 | 43 | #### Subsequent Runs 44 | - Start the application. 45 | - You will be prompted for a password to encrypt and decrypt your credentials. 46 | - All of your settings are persited in a local database. The application will run as previously configured. 47 | - Leave running, Trader Bot must be running in order to make trades. 48 | 49 | ## Development 50 | 51 | ### Tech Stack & Docs 52 | - [NodeJS](https://nodejs.org/en/docs/) 53 | - [Electron](https://www.electronjs.org/) 54 | - [Vue](https://vuejs.org/) 55 | - [Vue CLI](https://cli.vuejs.org/) 56 | - [vue-cli-plugin-electron-builder](https://nklayman.github.io/vue-cli-plugin-electron-builder/) 57 | - [electron-builder](https://www.electron.build/) 58 | 59 | ### Environment Setup 60 | #### Windows 61 | For easy setup, you may use chocolatey as outlined below. Otherwise, if you know what you are doing, feel free to install the below dependencies however you see fit. 62 | ``` powershell 63 | # run powershell as Administrator 64 | # install chocloatey - https://chocolatey.org/install 65 | Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1')) 66 | # install NodeJS - https://nodejs.org/ 67 | choco install nodejs-lts 68 | # install SQLite 69 | choco install sqlite 70 | # install git 71 | choco install git 72 | # install windows build tools - needed for node-gyp 73 | choco install microsoft-build-tools 74 | # install python 3 - needed for node-gyp 75 | choco install python 76 | # install python 2 - needed for node-sass 77 | choco install python2 78 | # install node-gyp 79 | npm i -g node-gyp 80 | ``` 81 | #### Mac 82 | For easy setup, you may use brew as outlined below. Otherwise, if you know what you are doing, feel free to install the below dependencies however you see fit. 83 | ``` bash 84 | # install brew - https://brew.sh/ 85 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 86 | # install NodeJS -https://nodejs.org/ 87 | brew install node 88 | # install SQLite https://www.sqlite.org/index.html 89 | brew install sqlite 90 | ``` 91 | #### Other 92 | It is presumed that you know what you are doing. 93 | - Install node and sqlite 94 | #### All 95 | ``` bash 96 | # clone project 97 | git clone https://github.com/mafischer/trader-bot.git 98 | # change directory into project 99 | cd trader-bot 100 | # install dependencies 101 | npm ci 102 | # serve application 103 | npm run electron:serve 104 | ``` 105 | 106 | ## Further Development 107 | 108 | ### Long term vision 109 | - This project aims to provide a consistent broker api to simplify building a trading strategy. In order for a new broker to be added, a wrapper api must be developed to normalize the broker api with what is currently available 110 | - Tensorflow will be added to support strategies that want to use Machine Learning. 111 | - A repository of plugins will allow the community to share strategies. 112 | 113 | ### Contributing 114 | - Have a look at the [issues](https://github.com/mafischer/trader-bot/issues) and feel free to submit a PR 115 | - Submit an issue if you find a bug or have an idea to make the project better. 116 | - Throw me some spare change if you found this software useful. 117 | 118 | #### Donations 119 | Become a patron by clicking below: 120 | 121 | [![](https://c5.patreon.com/external/logo/become_a_patron_button.png)](https://www.patreon.com/trader_bot) 122 | 123 | Send a one-time donation via paypal: 124 | 125 | [![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donate_LG.gif)](https://www.paypal.me/michaelmab88/5) 126 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset', 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /build/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/build/icons/icon.icns -------------------------------------------------------------------------------- /build/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/build/icons/icon.png -------------------------------------------------------------------------------- /build/icons/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/build/icons/icon_128x128.png -------------------------------------------------------------------------------- /build/icons/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/build/icons/icon_128x128@2x.png -------------------------------------------------------------------------------- /build/icons/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/build/icons/icon_16x16.png -------------------------------------------------------------------------------- /build/icons/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/build/icons/icon_16x16@2x.png -------------------------------------------------------------------------------- /build/icons/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/build/icons/icon_256x256.png -------------------------------------------------------------------------------- /build/icons/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/build/icons/icon_256x256@2x.png -------------------------------------------------------------------------------- /build/icons/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/build/icons/icon_32x32.png -------------------------------------------------------------------------------- /build/icons/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/build/icons/icon_32x32@2x.png -------------------------------------------------------------------------------- /build/icons/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/build/icons/icon_512x512.png -------------------------------------------------------------------------------- /build/icons/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/build/icons/icon_512x512@2x.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trader-bot", 3 | "version": "1.0.0-alpha.1", 4 | "private": true, 5 | "description": "Stock trader bot", 6 | "author": { 7 | "name": "Michael Fischer" 8 | }, 9 | "scripts": { 10 | "build": "npm ci", 11 | "test:unit": "vue-cli-service test:unit", 12 | "lint": "vue-cli-service lint", 13 | "electron:build": "vue-cli-service electron:build", 14 | "electron:serve": "vue-cli-service electron:serve", 15 | "postinstall": "electron-builder install-app-deps", 16 | "postuninstall": "electron-builder install-app-deps" 17 | }, 18 | "main": "background.js", 19 | "dependencies": { 20 | "@mdi/font": "^5.9.55", 21 | "async-es": "^3.2.0", 22 | "chart.js": "^2.9.4", 23 | "chartjs-adapter-luxon": "^0.2.2", 24 | "core-js": "^3.9.1", 25 | "cryptr": "^6.0.2", 26 | "deep-equal": "^2.0.5", 27 | "electron-prompt": "^1.6.2", 28 | "luxon": "^1.26.0", 29 | "memoizee": "^0.4.15", 30 | "robinhood": "github:mafischer/robinhood-node#_login", 31 | "roboto-fontface": "^0.10.0", 32 | "sqlite": "^4.0.19", 33 | "sqlite3": "^5.0.2", 34 | "twitter-v2": "^1.0.7", 35 | "vex-js": "^4.1.0", 36 | "vue": "^2.6.11", 37 | "vue-chartjs": "^3.5.1", 38 | "vue-router": "^3.2.0", 39 | "vuetify": "^2.4.8", 40 | "vuex": "^3.4.0", 41 | "winston": "^3.3.3", 42 | "winston-daily-rotate-file": "^4.5.1" 43 | }, 44 | "devDependencies": { 45 | "@vue/cli-plugin-babel": "~4.5.12", 46 | "@vue/cli-plugin-eslint": "~4.5.12", 47 | "@vue/cli-plugin-router": "~4.5.0", 48 | "@vue/cli-plugin-unit-mocha": "~4.5.0", 49 | "@vue/cli-plugin-vuex": "~4.5.12", 50 | "@vue/cli-service": "~4.5.15", 51 | "@vue/eslint-config-airbnb": "^5.0.2", 52 | "@vue/test-utils": "^1.1.3", 53 | "babel-eslint": "^10.1.0", 54 | "chai": "^4.3.4", 55 | "chai-as-promised": "^7.1.1", 56 | "electron": "^11.2.3", 57 | "electron-builder": "^22.10.5", 58 | "electron-devtools-installer": "^3.1.0", 59 | "eslint": "^6.7.2", 60 | "eslint-plugin-import": "^2.20.2", 61 | "eslint-plugin-vue": "^6.2.2", 62 | "lint-staged": "^10.5.4", 63 | "node-sass": "^4.12.0", 64 | "sass": "^1.32.8", 65 | "sass-loader": "^8.0.2", 66 | "spectron": "^13.0.0", 67 | "vue-cli-plugin-electron-builder": "~2.0.0-rc.6", 68 | "vue-cli-plugin-vuetify": "^2.3.1", 69 | "vue-template-compiler": "^2.6.11", 70 | "vuetify-loader": "^1.7.2" 71 | }, 72 | "_id": "trader-bot@1.0.0", 73 | "gitHooks": { 74 | "pre-commit": "lint-staged" 75 | }, 76 | "keywords": [ 77 | "bot", 78 | "stock", 79 | "nyse", 80 | "nasdaq", 81 | "robinhood" 82 | ], 83 | "license": "ISC", 84 | "lint-staged": { 85 | "*.{js,jsx,vue}": [ 86 | "vue-cli-service lint", 87 | "git add" 88 | ] 89 | }, 90 | "readme": "ERROR: No README data found!" 91 | } 92 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/public/favicon.ico -------------------------------------------------------------------------------- /public/iconTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/public/iconTemplate.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /public/worker.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Hidden Worker 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 122 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafischer/trader-bot/168955a7b0987594c23a226e5d58ba4b7ce81aeb/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | Artboard 46 2 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { 3 | app, 4 | protocol, 5 | BrowserWindow, 6 | ipcMain, 7 | Tray, 8 | Menu, 9 | } from 'electron'; 10 | import path from 'path'; 11 | import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'; 12 | import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'; 13 | import { version, name, author } from '../package.json'; 14 | import initDb from './lib/initDb'; 15 | 16 | const isDevelopment = process.env.NODE_ENV !== 'production'; 17 | let tray = null; 18 | 19 | // set app about 20 | app.setAboutPanelOptions({ 21 | applicationName: name, 22 | applicationVersion: version, 23 | version, 24 | copyright: 'Michael Fischer 2021', 25 | authors: [author], 26 | }); 27 | 28 | // get the app root directory 29 | const home = app.getPath('userData'); 30 | 31 | // Scheme must be registered before the app is ready 32 | protocol.registerSchemesAsPrivileged([ 33 | { scheme: 'app', privileges: { secure: true, standard: true } }, 34 | ]); 35 | 36 | async function createWindow(options, html) { 37 | // Create the browser window. 38 | const win = new BrowserWindow(options); 39 | 40 | if (process.env.WEBPACK_DEV_SERVER_URL) { 41 | // Load the url of the dev server if in development mode 42 | await win.loadURL(`${process.env.WEBPACK_DEV_SERVER_URL}${html}`); 43 | if (!process.env.IS_TEST) win.webContents.openDevTools(); 44 | } else { 45 | createProtocol('app'); 46 | // Load the index.html when not in development 47 | win.loadURL(`app://.${html}`); 48 | } 49 | 50 | win.on('minimize', (event) => { 51 | event.preventDefault(); 52 | win.hide(); 53 | }); 54 | 55 | win.on('close', (event) => { 56 | // TODO: store quit state in variable 57 | if (!app.isQuiting) { 58 | event.preventDefault(); 59 | win.hide(); 60 | } 61 | 62 | return false; 63 | }); 64 | 65 | return win; 66 | } 67 | 68 | // This method will be called when Electron has finished 69 | // initialization and is ready to create browser windows. 70 | // Some APIs can only be used after this event occurs. 71 | app.on('ready', async () => { 72 | if (isDevelopment && !process.env.IS_TEST) { 73 | // Install Vue Devtools 74 | try { 75 | await installExtension(VUEJS_DEVTOOLS); 76 | } catch (e) { 77 | console.error('Vue Devtools failed to install:', e.toString()); 78 | } 79 | } 80 | 81 | // initialize local db's 82 | try { 83 | await initDb(home); 84 | } catch (e) { 85 | console.error(e.message); 86 | } 87 | 88 | tray = new Tray(path.resolve(__static, 'iconTemplate.png')); 89 | tray.setToolTip('Trader Bot'); 90 | 91 | const ui = await createWindow({ 92 | title: name, 93 | width: 800, 94 | height: 600, 95 | webPreferences: { 96 | // Use pluginOptions.nodeIntegration, leave this alone 97 | // eslint-disable-next-line max-len 98 | // See nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration for more info 99 | nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION, 100 | enableRemoteModule: true, 101 | webSecurity: false, 102 | }, 103 | }, '/index.html'); 104 | ui.maximize(); 105 | 106 | // create hidden window for main applicaiton code 107 | const main = await createWindow({ 108 | show: false, 109 | title: 'hidden window', 110 | webPreferences: { 111 | // Use pluginOptions.nodeIntegration, leave this alone 112 | // eslint-disable-next-line max-len 113 | // See nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration for more info 114 | nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION, 115 | enableRemoteModule: true, 116 | webSecurity: false, 117 | }, 118 | }, '/worker.html'); 119 | 120 | const contextMenu = Menu.buildFromTemplate([ 121 | { 122 | label: 'Configure', 123 | click: () => { 124 | ui.show(); 125 | }, 126 | }, 127 | { 128 | label: 'Quit', 129 | click: () => { 130 | app.isQuiting = true; 131 | main.webContents.send('quit'); 132 | // force quite after 5 seconds 133 | setTimeout(() => { 134 | app.quit(); 135 | }, 5000); 136 | }, 137 | }, 138 | ]); 139 | 140 | tray.setContextMenu(contextMenu); 141 | 142 | // relay strategy event to main process 143 | ipcMain.on('strategy', (event, payload) => { 144 | main.webContents.send('strategy', payload); 145 | }); 146 | 147 | // send login event to hidden window (data ) 148 | ipcMain.on('login', (event, payload) => { 149 | main.webContents.send('login', payload); 150 | }); 151 | // send log event to main window 152 | ipcMain.on('worker-log', (event, log) => { 153 | ui.webContents.send('worker-log', log); 154 | }); 155 | 156 | // quit on exit signal 157 | ipcMain.on('exit', () => { 158 | app.quit(); 159 | }); 160 | }); 161 | 162 | // Exit cleanly on request from parent process in development mode. 163 | if (isDevelopment) { 164 | if (process.platform === 'win32') { 165 | process.on('message', (data) => { 166 | if (data === 'graceful-exit') { 167 | app.quit(); 168 | } 169 | }); 170 | } else { 171 | process.on('SIGTERM', () => { 172 | app.quit(); 173 | }); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 93 | 94 | 152 | -------------------------------------------------------------------------------- /src/components/Portfolio.vue: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /src/components/StrategySelector.vue: -------------------------------------------------------------------------------- 1 | 114 | 115 | 201 | -------------------------------------------------------------------------------- /src/lib/api/index.js: -------------------------------------------------------------------------------- 1 | import Twitter from './twitter'; 2 | 3 | export default async (settings) => ({ 4 | twitter: await Twitter(settings.twitter), 5 | }); 6 | -------------------------------------------------------------------------------- /src/lib/api/twitter.js: -------------------------------------------------------------------------------- 1 | import Twitter from 'twitter-v2'; 2 | 3 | // initialize the twitter client 4 | export default (config) => (new Twitter(config)); 5 | -------------------------------------------------------------------------------- /src/lib/brokers/index.js: -------------------------------------------------------------------------------- 1 | import robinhood from './robinhood/index'; 2 | 3 | export default async (log, settings) => ({ 4 | robinhood: await robinhood(log, settings.robinhood), 5 | }); 6 | -------------------------------------------------------------------------------- /src/lib/brokers/robinhood/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | /* eslint-disable no-await-in-loop */ 3 | import { promisify } from 'util'; 4 | import { eachLimit, each } from 'async-es'; 5 | import Robinhood from 'robinhood'; 6 | import { DateTime } from 'luxon'; 7 | import memoize from 'memoizee'; 8 | 9 | async function getPortfolioHistoricals({ interval, span }) { 10 | const historicals = {}; 11 | const positions = await this.getPositions(); 12 | await eachLimit(positions, 10, async (position, positionCb) => { 13 | try { 14 | const qty = parseFloat(position.quantity); 15 | if (qty > 0) { 16 | const { body } = await this.p_historicals(position.instrument.symbol, interval, span); 17 | historicals[position.instrument.symbol] = body; 18 | } 19 | } catch (err) { 20 | this.log({ 21 | level: 'error', 22 | log: JSON.stringify({ 23 | message: err.message, 24 | stack: err.stack, 25 | }), 26 | }); 27 | throw err; 28 | } 29 | // quirky hack for inconsistent async-es library 30 | if (positionCb) { 31 | return positionCb(); 32 | } 33 | return null; 34 | }); 35 | return historicals; 36 | } 37 | 38 | async function getPositions(db) { 39 | let positions = []; 40 | const { body } = await this.p_positions(); 41 | if (Object.prototype.hasOwnProperty.call(body, 'results')) { 42 | positions = body.results; 43 | await eachLimit(positions, 10, async (position, positionCb) => { 44 | try { 45 | const instrument = await this.m_url(position.instrument); 46 | position.instrument = instrument.body; 47 | // update db if/when db object is provided 48 | if (db !== undefined) { 49 | if (Object.prototype.hasOwnProperty.call(position.instrument, 'splits')) { 50 | const rsp = await this.m_url(position.instrument.splits); 51 | // const rsp = await this.m_splits(position.instrument.symbol); 52 | const splits = rsp.body; 53 | await each(splits.results, async ({ url }, urlCb) => { 54 | const splt = await this.m_url(url); 55 | const split = splt.body; 56 | await db.run(` 57 | INSERT OR IGNORE INTO splits 58 | (symbol, date, multiplier, divisor) 59 | values ($symbol, $date, $multiplier, $divisor); 60 | `, { 61 | $symbol: position.instrument.symbol, 62 | $divisor: split.divisor, 63 | $multiplier: split.multiplier, 64 | $date: split.execution_date, 65 | }); 66 | 67 | // quirky hack for inconsistent async-es library 68 | if (urlCb) { 69 | return urlCb(); 70 | } 71 | return null; 72 | }); 73 | } 74 | 75 | await db.run(` 76 | INSERT INTO positions (broker, symbol, qty, average_cost, raw) 77 | values ($broker, $symbol, $qty, $average_cost, $raw) 78 | ON CONFLICT(broker, symbol) DO UPDATE 79 | SET qty = $qty, average_cost = $average_cost, raw = $raw; 80 | `, { 81 | $broker: 'robinhood', 82 | $symbol: position.instrument.symbol, 83 | $qty: position.quantity, 84 | $average_cost: position.average_buy_price, 85 | $raw: JSON.stringify(position), 86 | }); 87 | } 88 | } catch (err) { 89 | this.log({ 90 | level: 'error', 91 | log: JSON.stringify({ 92 | message: err.message, 93 | stack: err.stack, 94 | }), 95 | }); 96 | throw err; 97 | } 98 | // quirky hack for inconsistent async-es library 99 | if (positionCb) { 100 | return positionCb(); 101 | } 102 | return null; 103 | }); 104 | } 105 | return positions; 106 | } 107 | 108 | async function getAccounts(db) { 109 | let accounts = []; 110 | const { body } = await this.p_accounts(); 111 | if (Object.prototype.hasOwnProperty.call(body, 'results')) { 112 | accounts = body.results; 113 | await eachLimit(accounts, 10, async (account, accountCb) => { 114 | try { 115 | await db.run(` 116 | INSERT INTO accounts (broker, id, name, type, buying_power, portfolio_cash, raw) 117 | values ($broker, $id, $name, $type, $buying_power, $portfolio_cash, $raw) 118 | ON CONFLICT(broker, id) DO UPDATE 119 | SET name = $name, type = $type, buying_power = $buying_power, portfolio_cash = $portfolio_cash, raw = $raw; 120 | `, { 121 | $broker: 'robinhood', 122 | $id: account.account_number, 123 | $name: account.account_number, 124 | $type: account.type, 125 | $buying_power: account.buying_power, 126 | $portfolio_cash: account.portfolio_cash, 127 | $raw: JSON.stringify(account), 128 | }); 129 | } catch (err) { 130 | this.log({ 131 | level: 'error', 132 | log: JSON.stringify({ 133 | message: err.message, 134 | stack: err.stack, 135 | }), 136 | }); 137 | throw err; 138 | } 139 | // quirky hack for inconsistent async-es library 140 | if (accountCb) { 141 | return accountCb(); 142 | } 143 | return null; 144 | }); 145 | } 146 | return accounts; 147 | } 148 | 149 | async function orderHistory(db, fromDate) { 150 | let latestCached; 151 | if (fromDate) { 152 | latestCached = fromDate; 153 | } else { 154 | // get most recent latest order from 155 | try { 156 | const order = await db.get('select updated_at from orders where created_at = (select MAX(created_at) from orders where broker = $broker) and broker = $broker limit 1;', { $broker: 'robinhood' }); 157 | if (order) { 158 | latestCached = DateTime.fromISO(order.updated_at).plus({ milliseconds: 1 }).toUTC().toISO(); 159 | } 160 | } catch (err) { 161 | this.log({ 162 | level: 'error', 163 | log: JSON.stringify({ 164 | message: err.message, 165 | stack: err.stack, 166 | }), 167 | }); 168 | } 169 | } 170 | 171 | // get order history 172 | let orders = []; 173 | let cursor = null; 174 | let next = null; 175 | do { 176 | try { 177 | const options = {}; 178 | if (cursor) { 179 | options.cursor = cursor; 180 | } 181 | if (latestCached) { 182 | options.updated_at = latestCached; 183 | } 184 | const { body } = await this.p_orders(options); 185 | if (Array.isArray(body.results)) { 186 | orders = [...orders, ...body.results]; 187 | } 188 | if (body.next) { 189 | next = new URL(body.next); 190 | cursor = next.searchParams.get('cursor'); 191 | } else { 192 | next = null; 193 | } 194 | } catch (err) { 195 | this.log({ 196 | level: 'error', 197 | log: JSON.stringify({ 198 | message: err.message, 199 | stack: err.stack, 200 | }), 201 | }); 202 | next = null; 203 | } 204 | } while (next !== null); 205 | 206 | await eachLimit(orders, 10, async (order) => { 207 | try { 208 | const instrument = await this.m_url(order.instrument); 209 | order.instrument = instrument.body; 210 | let price = null; 211 | if (order.average_price) { 212 | price = parseFloat(order.average_price); 213 | } 214 | if (order.price) { 215 | price = parseFloat(order.price); 216 | } 217 | await db.run(` 218 | INSERT INTO orders (broker, account, symbol, created_at, updated_at, state, type, quantity, price, action, raw) 219 | VALUES ($broker, $account, $symbol, $created_at, $updated_at, $state, $type, $quantity, $price, $action, $raw) 220 | ON CONFLICT(broker, account, symbol, created_at) DO UPDATE SET 221 | updated_at = $updated_at, state = $state, price = $price, raw = $raw; 222 | `, 223 | { 224 | $broker: 'robinhood', 225 | $account: order.account, 226 | $symbol: order.instrument.symbol, 227 | $created_at: order.created_at, 228 | $updated_at: order.updated_at, 229 | $state: order.state, 230 | $price: price, 231 | $action: order.side, 232 | $type: order.type, 233 | $quantity: order.quantity, 234 | $raw: JSON.stringify(order), 235 | }); 236 | } catch (err) { 237 | this.log({ 238 | level: 'error', 239 | log: JSON.stringify({ 240 | message: err.message, 241 | stack: err.stack, 242 | }), 243 | }); 244 | } 245 | return null; 246 | }); 247 | 248 | return orders; 249 | } 250 | 251 | export default (log, token) => ( 252 | new Promise((resolve, reject) => { 253 | const rh = Robinhood(token, (err) => { 254 | if (err) { 255 | reject(err); 256 | } 257 | // set log function 258 | rh.log = log; 259 | 260 | // set async functions 261 | // auth_token() 262 | rh.p_auth_token = promisify(rh.auth_token); 263 | // expire_token(callback) 264 | rh.p_expire_token = promisify(rh.expire_token); 265 | // investment_profile(callback) 266 | rh.p_investment_profile = promisify(rh.investment_profile); 267 | // instruments(symbol, callback) 268 | rh.p_insturments = promisify(rh.instruments); 269 | // quote_data(stock, callback) 270 | rh.p_quote_data = promisify(rh.quote_data); 271 | // accounts(callback) 272 | rh.p_accounts = promisify(rh.accounts); 273 | // user(callback) 274 | rh.p_user = promisify(rh.user); 275 | // dividends(callback) 276 | rh.p_dividends = promisify(rh.dividends); 277 | // earnings(option, callback) 278 | rh.p_earnings = promisify(rh.earnings); 279 | // orders(options, callback) 280 | rh.p_orders = promisify(rh.orders); 281 | // positions(callback) 282 | rh.p_positions = promisify(rh.positions); 283 | // nonzero_positions(callback) 284 | rh.p_nonzero_positions = promisify(rh.nonzero_positions); 285 | // place_buy_order(options, callback) 286 | rh.p_place_buy_order = promisify(rh.place_buy_order); 287 | // place_sell_order(options, callback) 288 | rh.p_place_sell_order = promisify(rh.place_sell_order); 289 | // fundamentals(symbol, callback) 290 | rh.p_fundamentals = promisify(rh.fundamentals); 291 | // cancel_order(order, callback) 292 | rh.p_cancel_order = promisify(rh.cancel_order); 293 | // watchlists(name, callback) 294 | rh.p_watchlists = promisify(rh.watchlists); 295 | // create_watch_list(name, callback) 296 | rh.p_create_watch_list = promisify(rh.create_watch_list); 297 | // sp500_up(callback) 298 | rh.p_sp500_up = promisify(rh.sp500_up); 299 | // sp500_down(callback) 300 | rh.p_sp500_down = promisify(rh.sp500_down); 301 | // splits(instrument, callback) 302 | rh.p_splits = promisify(rh.splits); 303 | // historicals(symbol, intv, span, callback) 304 | rh.p_historicals = promisify(rh.historicals); 305 | // url(url, callback) 306 | rh.p_url = promisify(rh.url); 307 | // news(symbol, callback) 308 | rh.p_news = promisify(rh.news); 309 | // tag(tag, callback) 310 | rh.p_tag = promisify(rh.tag); 311 | // popularity(symbol, callback) 312 | rh.p_popularity = promisify(rh.popularity); 313 | // options_positions 314 | rh.p_options_positions = promisify(rh.options_positions); 315 | 316 | // memoized functions 317 | // investment_profile(callback) 318 | // rh.m_investment_profile = memoize(rh.p_investment_profile, { promise: true, maxAge: 14400000 }); 319 | // instruments(symbol, callback) 320 | // rh.m_insturments = memoize(rh.p_instruments, { promise: true, maxAge: 14400000 }); 321 | // user(callback) 322 | // rh.m_user = memoize(rh.p_user, { promise: true, maxAge: 14400000 }); 323 | // dividends(callback) 324 | // rh.m_dividends = memoize(rh.p_dividends, { promise: true, maxAge: 14400000 }); 325 | // splits(instrument, callback) 326 | rh.m_splits = memoize(rh.p_splits, { promise: true, maxAge: 14400000 }); 327 | // historicals(symbol, intv, span, callback) 328 | rh.m_historicals = memoize(rh.p_historicals, { promise: true, maxAge: 300000 }); 329 | // url(url, callback) 330 | rh.m_url = memoize(rh.p_url, { promise: true, maxAge: 60000 }); 331 | // tag(tag, callback) 332 | // rh.m_tag = memoize(rh.p_tag, { promise: true, maxAge: 14400000 }); 333 | 334 | // custom functions 335 | // orderHistory 336 | rh.orderHistory = orderHistory; 337 | // getAccounts 338 | rh.getAccounts = getAccounts; 339 | // getPositions 340 | rh.getPositions = getPositions; 341 | // getPortfolioHistoricals 342 | rh.getPortfolioHistoricals = getPortfolioHistoricals; 343 | 344 | // return robinhood object 345 | resolve(rh); 346 | }); 347 | }) 348 | ); 349 | -------------------------------------------------------------------------------- /src/lib/db.js: -------------------------------------------------------------------------------- 1 | import sqlite3 from 'sqlite3'; 2 | import { open } from 'sqlite'; 3 | 4 | export async function attachDb(db, filename) { 5 | await db.run(`ATTACH DATABASE '${filename}' AS twitter;`); 6 | return db; 7 | } 8 | 9 | export async function openDb(filename) { 10 | const db = await open({ 11 | filename, 12 | driver: sqlite3.cached.Database, 13 | }); 14 | return db; 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/initDb.js: -------------------------------------------------------------------------------- 1 | import sqlite3 from 'sqlite3'; 2 | import { open } from 'sqlite'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import { eachSeries } from 'async-es'; 6 | 7 | export default async function initDb(home) { 8 | console.log('starting db initialization'); 9 | 10 | if (!fs.existsSync(path.resolve(home, 'trader.db'))) { 11 | // create trader database 12 | const traderDb = await open({ 13 | filename: path.resolve(home, 'trader.db'), 14 | driver: sqlite3.cached.Database, 15 | }); 16 | // initialize trader database 17 | const queries = ` 18 | PRAGMA foreign_keys=OFF; 19 | BEGIN TRANSACTION; 20 | CREATE TABLE IF NOT EXISTS "strategies" ( 21 | "id" INTEGER NOT NULL, 22 | "name" TEXT NOT NULL UNIQUE, 23 | "description" TEXT NOT NULL, 24 | "class" TEXT NOT NULL, 25 | PRIMARY KEY("id" AUTOINCREMENT) 26 | ); 27 | INSERT INTO strategies VALUES(1,'Reverse Stock Split Arbitrage','Schedule the purchase of 1 share of stock 10 minutes prior to the market close on last trading day before said stock is scheduled to undergo a reverse stock split.','ReverseSplitArbitrage'); 28 | CREATE TABLE IF NOT EXISTS "credentials" ( 29 | "id" INTEGER NOT NULL CHECK(id=1), 30 | "credentials" TEXT NOT NULL, 31 | PRIMARY KEY("id" AUTOINCREMENT) 32 | ); 33 | CREATE TABLE IF NOT EXISTS "actions" ( 34 | "id" INTEGER NOT NULL, 35 | "name" TEXT NOT NULL UNIQUE, 36 | PRIMARY KEY("id" AUTOINCREMENT) 37 | ); 38 | INSERT INTO actions VALUES(1,'BUY'); 39 | INSERT INTO actions VALUES(2,'SELL'); 40 | CREATE TABLE IF NOT EXISTS "status" ( 41 | "id" INTEGER NOT NULL, 42 | "name" TEXT NOT NULL UNIQUE, 43 | PRIMARY KEY("id" AUTOINCREMENT) 44 | ); 45 | INSERT INTO status VALUES(1,'PENDING'); 46 | INSERT INTO status VALUES(2,'FILLED'); 47 | INSERT INTO status VALUES(3,'CONFIRMED'); 48 | INSERT INTO status VALUES(4,'NOT_TRIGGERED'); 49 | CREATE TABLE IF NOT EXISTS "brokers" ( 50 | "id" INTEGER NOT NULL, 51 | "name" TEXT NOT NULL UNIQUE, 52 | "supported" INTEGER NOT NULL DEFAULT 0, 53 | PRIMARY KEY("id" AUTOINCREMENT) 54 | ); 55 | INSERT INTO brokers VALUES(1,'robinhood',1); 56 | INSERT INTO brokers VALUES(2,'webull',0); 57 | INSERT INTO brokers VALUES(3,'fidelity',0); 58 | CREATE TABLE IF NOT EXISTS "api" ( 59 | "id" INTEGER NOT NULL, 60 | "service" TEXT NOT NULL UNIQUE, 61 | "inputs" TEXT NOT NULL, 62 | PRIMARY KEY("id" AUTOINCREMENT) 63 | ); 64 | INSERT INTO api VALUES(1,'robinhood','["username","password"]'); 65 | INSERT INTO api VALUES(2,'twitter','["bearer_token","client_secret","client_token"]'); 66 | CREATE TABLE IF NOT EXISTS "orders" ( 67 | "account" TEXT NOT NULL, 68 | "broker" TEXT NOT NULL, 69 | "symbol" TEXT NOT NULL, 70 | "created_at" INTEGER NOT NULL, 71 | "updated_at" TIMESTAMP, 72 | "state" TEXT NOT NULL, 73 | "type" TEXT NOT NULL, 74 | "action" TEXT, 75 | "price" NUMERIC, 76 | "quantity" INTEGER NOT NULL, 77 | "raw" TEXT NOT NULL, 78 | PRIMARY KEY("broker","account","symbol","created_at") 79 | ); 80 | CREATE TABLE IF NOT EXISTS "accounts" ( 81 | "broker" TEXT NOT NULL, 82 | "id" TEXT NOT NULL, 83 | "type" TEXT NOT NULL, 84 | "name" TEXT NOT NULL, 85 | "buying_power" REAL, 86 | "portfolio_cash" REAL, 87 | "raw" TEXT NOT NULL, 88 | PRIMARY KEY("broker","id") 89 | ); 90 | CREATE TABLE IF NOT EXISTS "elected_strategies" ( 91 | "id" INTEGER NOT NULL, 92 | "active" INTEGER NOT NULL, 93 | "allocation" REAL NOT NULL DEFAULT 0, 94 | "allocations" TEXT NOT NULL DEFAULT '[]', 95 | "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 96 | PRIMARY KEY("id") 97 | ); 98 | CREATE TABLE IF NOT EXISTS "positions" ( 99 | "broker" TEXT NOT NULL, 100 | "symbol" TEXT NOT NULL, 101 | "qty" INTEGER NOT NULL, 102 | "average_cost" NUMERIC NOT NULL, 103 | "raw" TEXT NOT NULL, 104 | PRIMARY KEY("broker","symbol") 105 | ); 106 | DELETE FROM sqlite_sequence; 107 | INSERT INTO sqlite_sequence VALUES('strategies',2); 108 | INSERT INTO sqlite_sequence VALUES('credentials',1); 109 | INSERT INTO sqlite_sequence VALUES('actions',2); 110 | INSERT INTO sqlite_sequence VALUES('status',4); 111 | INSERT INTO sqlite_sequence VALUES('brokers',3); 112 | INSERT INTO sqlite_sequence VALUES('api',2); 113 | COMMIT 114 | `.split(';'); 115 | await eachSeries(queries, async (query) => { 116 | await traderDb.run(query); 117 | }); 118 | 119 | await traderDb.close(); 120 | 121 | console.log('sucessfully initialized trader database'); 122 | } 123 | 124 | if (!fs.existsSync(path.resolve(home, 'twitter.db'))) { 125 | // create twitter db 126 | const twitterDb = await open({ 127 | filename: path.resolve(home, 'twitter.db'), 128 | driver: sqlite3.cached.Database, 129 | }); 130 | // initialize twitter database 131 | const queries = ` 132 | PRAGMA foreign_keys=OFF; 133 | BEGIN TRANSACTION; 134 | CREATE TABLE IF NOT EXISTS "tweets" ( 135 | "id" TEXT NOT NULL UNIQUE, 136 | "author_id" TEXT NOT NULL, 137 | "created_at" TIMESTAMP NOT NULL, 138 | "text" TEXT NOT NULL, 139 | "entities" TEXT, 140 | "public_metrics" TEXT, 141 | "context_annotations" TEXT, 142 | "withheld" TEXT, 143 | "geo" TEXT, 144 | PRIMARY KEY("id") 145 | ); 146 | CREATE TABLE IF NOT EXISTS "following" ( 147 | "id" TEXT NOT NULL, 148 | "name" TEXT NOT NULL, 149 | "username" TEXT NOT NULL, 150 | PRIMARY KEY("id") 151 | ); 152 | INSERT INTO "following" VALUES('44196397','Elon Musk','elonmusk'); 153 | INSERT INTO "following" VALUES('1332370385921306631','Reverse Split Arbitrage','ReverseSplitArb'); 154 | INSERT INTO "following" VALUES('898021206967951360','Tesla Daily','TeslaPodcast'); 155 | COMMIT 156 | `.split(';'); 157 | await eachSeries(queries, async (query) => { 158 | await twitterDb.run(query); 159 | }); 160 | 161 | await twitterDb.close(); 162 | 163 | console.log('sucessfully initialized twitter database'); 164 | } 165 | 166 | console.log('SQLite databases are initialized.'); 167 | } 168 | -------------------------------------------------------------------------------- /src/lib/login.js: -------------------------------------------------------------------------------- 1 | import sqlite3 from 'sqlite3'; 2 | import { open } from 'sqlite'; 3 | import Cryptr from 'cryptr'; 4 | import Robinhood from 'robinhood'; 5 | 6 | const internal = { 7 | credentials: null, 8 | cryptr: null, 9 | log: console.log, 10 | }; 11 | 12 | const supported = ['robinhood', 'twitter']; 13 | 14 | async function retrieveCredentials(db) { 15 | // retreive encrypted credentials 16 | let creds; 17 | creds = await db.get('select credentials from credentials where id = 1;'); 18 | if (creds === undefined || creds.credentials === '') { 19 | return {}; 20 | } 21 | 22 | try { 23 | // decrypt credentials 24 | creds = internal.cryptr.decrypt(creds.credentials); 25 | 26 | // parse credentials 27 | creds = JSON.parse(creds); 28 | 29 | internal.log({ 30 | level: 'info', 31 | log: 'Credentials successfully decrypted!', 32 | }); 33 | 34 | return creds; 35 | } catch (err) { 36 | internal.cryptr = undefined; 37 | internal.log({ 38 | level: 'error', 39 | log: JSON.stringify({ 40 | message: err.message, 41 | stack: err.stack, 42 | }), 43 | }); 44 | throw new Error('invalid password!'); 45 | } 46 | } 47 | 48 | export function missingCredentials(creds) { 49 | // validate all services are signed in 50 | let missing = []; 51 | supported.forEach((service) => { 52 | if (Object.prototype.hasOwnProperty.call(creds, service)) { 53 | missing = missing.filter((e) => e !== service); 54 | } else if (missing.indexOf(service) === -1) { 55 | missing.push(service); 56 | } 57 | }); 58 | return missing; 59 | } 60 | 61 | export async function login({ filename, password, log }) { 62 | // configure log function 63 | if (log) { 64 | internal.log = log; 65 | } 66 | 67 | // open the sqlite database 68 | // no try catch because we want app to fail if this isn't working. 69 | const db = await open({ 70 | filename, 71 | driver: sqlite3.cached.Database, 72 | }); 73 | 74 | // set password 75 | internal.cryptr = new Cryptr(password); 76 | 77 | // grab encrypted credentials from the database 78 | internal.credentials = await retrieveCredentials(db); 79 | 80 | // validate user has signed into all required services 81 | const missing = missingCredentials(internal.credentials); 82 | 83 | // not closing db as we are running the appliation after login 84 | // db.close(); 85 | 86 | return { 87 | cryptr: internal.cryptr, 88 | credentials: internal.credentials, 89 | db, 90 | missing, 91 | }; 92 | } 93 | 94 | export async function updateCredentials(db, cryptr, credentials) { 95 | await db.run(` 96 | insert into credentials (id, credentials) 97 | values (1, $credentials) 98 | on conflict(id) do update set credentials = $credentials; 99 | `, { $credentials: cryptr.encrypt(JSON.stringify(credentials)) }); 100 | } 101 | 102 | export function robinhood(username, password) { 103 | return new Promise((resolve, reject) => { 104 | const rh = Robinhood({ username, password }, (err, data) => { 105 | if (err) { 106 | reject(err); 107 | } else { 108 | resolve({ rh, data }); 109 | } 110 | }); 111 | }); 112 | } 113 | -------------------------------------------------------------------------------- /src/lib/strategies/ReverseSplitArbitrage.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | import { DateTime } from 'luxon'; 3 | import { eachSeries } from 'async-es'; 4 | import Strategy from './Strategy'; 5 | 6 | export default class ReverseSplitArbitrage extends Strategy { 7 | constructor(config) { 8 | super(config); 9 | 10 | this.name = 'Reverse Split Arbitrage'; 11 | 12 | // rsa regex 13 | this.rsa = /^.*I'm buying (?\d+) shares? of \$(?[a-z]{1,5}) by market close on (?[a-z]{3} \d{1,2}, \d{4})\..*$/im; 14 | // stock ticker regex 15 | this.ticker = /\$(?[A-Z]{1,5})/ig; 16 | } 17 | 18 | async main() { 19 | const self = this; 20 | let following; 21 | try { 22 | following = await self.db.all('select * from twitter.following where username = \'ReverseSplitArb\';'); 23 | } catch (err) { 24 | self.log({ 25 | level: 'error', 26 | log: JSON.stringify({ 27 | message: err.message, 28 | stack: err.stack, 29 | }), 30 | }); 31 | } 32 | 33 | await eachSeries(following, async (tweeter, tweeterCb) => { 34 | let cache; 35 | try { 36 | cache = await self.db.get('select * from twitter.tweets where created_at = (select MAX(created_at) from twitter.tweets where author_id = $author_id) and author_id = $author_id limit 1;', { $author_id: tweeter.id }); 37 | } catch (err) { 38 | self.log({ 39 | level: 'error', 40 | log: JSON.stringify({ 41 | message: err.message, 42 | stack: err.stack, 43 | }), 44 | }); 45 | } 46 | 47 | // fetch latest data for strategy 48 | const queryParams = { 49 | start_time: `${DateTime.utc().minus({ years: 1 }).toISO()}`, 50 | exclude: 'retweets,replies', 51 | 'tweet.fields': 'id,text,created_at,context_annotations,entities,withheld,public_metrics,geo,author_id', 52 | }; 53 | // set since_id to avoide duplicate data 54 | if (cache && cache.id) { 55 | queryParams.since_id = cache.id; 56 | } 57 | let tweets = []; 58 | try { 59 | let response; 60 | do { 61 | response = await self.api.twitter.get(`users/${tweeter.id}/tweets`, queryParams); 62 | if (response.meta.result_count > 0) { 63 | tweets = [...tweets, ...response.data]; 64 | } 65 | queryParams.pagination_token = response.meta.next_token; 66 | } while (response.meta.next_token !== undefined); 67 | } catch (err) { 68 | self.log({ 69 | level: 'error', 70 | log: JSON.stringify({ 71 | message: err.message, 72 | stack: err.stack, 73 | }), 74 | }); 75 | } 76 | 77 | // analyze new tweets and store in db 78 | if (tweets.length > 0) { 79 | self.log({ 80 | level: 'info', 81 | log: `${tweeter.name} has tweeted since we last checked!!\n`, 82 | }); 83 | } 84 | 85 | await eachSeries(tweets, async (tweet, tweetCb) => { 86 | self.log({ 87 | level: 'info', 88 | log: `${tweeter.name}: ${tweet.text}\n`, 89 | }); 90 | 91 | try { 92 | await self.db.run(` 93 | insert into twitter.tweets 94 | (id, author_id, created_at, text, entities, public_metrics, context_annotations, withheld, geo) 95 | values 96 | ($id, $author_id, $created_at, $text, $entities, $public_metrics, $context_annotations, $withheld, $geo); 97 | `, { 98 | $id: tweet.id || null, 99 | $author_id: tweet.author_id || null, 100 | $created_at: tweet.created_at || null, 101 | $text: tweet.text || null, 102 | $entities: tweet.entities !== undefined ? JSON.stringify(tweet.entities) : null, 103 | $public_metrics: tweet.public_metrics !== undefined ? JSON.stringify(tweet.public_metrics) : null, 104 | $context_annotations: tweet.context_annotations !== undefined ? JSON.stringify(tweet.context_annotations) : null, 105 | $withheld: tweet.withheld !== undefined ? JSON.stringify(tweet.withheld) : null, 106 | $geo: tweet.geo !== undefined ? JSON.stringify(tweet.geo) : null, 107 | }); 108 | } catch (err) { 109 | self.log({ 110 | level: 'error', 111 | log: JSON.stringify({ 112 | message: err.message, 113 | stack: err.stack, 114 | }), 115 | }); 116 | } 117 | 118 | // check for rsa match 119 | // look for stock ticker symbols in tweet 120 | if (tweeter.username === 'ReverseSplitArb' && typeof tweet.text === 'string') { 121 | const match = self.rsa.exec(tweet.text); 122 | if (match) { 123 | self.log({ 124 | level: 'info', 125 | log: `@${tweeter.username} said to buy ${match.groups.qty} of ${match.groups.ticker} by the close of ${DateTime.fromFormat(match.groups.date, 'LLL dd, yyyy', { zone: 'America/New_York', hour: 16 }).toISO()}!!`, 126 | }); 127 | // TODO: execute market buy for each enabled broker 128 | // TODO: ensure orders do not exceed capital limit per broker. 129 | try { 130 | const response = await self.brokers.robinhood.p_quote_data(match.groups.ticker); 131 | const { body } = response; 132 | self.log({ 133 | level: 'info', 134 | log: `\nquote for ${match.groups.ticker}:\n${JSON.stringify(body)}\n`, 135 | }); 136 | if (body === undefined || (Object.prototype.hasOwnProperty.call(body, 'missing_instruments') && (Array.isArray(body.missing_instruments) && body.missing_instruments.indexOf(match.groups.ticker) >= 0))) { 137 | self.log({ 138 | level: 'warn', 139 | log: `Broker: ${'robinhood'} does not support ticker: ${match.groups.ticker}`, 140 | }); 141 | } else { 142 | const { results } = body; 143 | if (Array.isArray(results) && results.length > 0) { 144 | self.log({ 145 | level: 'info', 146 | log: `Ask price for ${results[0].symbol} is ${results[0].ask_price}, executing limit buy.`, 147 | }); 148 | const resp = await self.brokers.robinhood.p_place_buy_order({ 149 | type: 'limit', 150 | quantity: 1, 151 | bid_price: results[0].ask_price, 152 | instrument: { 153 | url: results[0].instrument, 154 | symbol: match.groups.ticker, 155 | }, 156 | }); 157 | self.log({ 158 | level: 'info', 159 | log: `Requested limit order of ${match.groups.ticker} on ${'robinhood'}.\n${JSON.stringify(resp)}`, 160 | }); 161 | } else { 162 | self.log({ 163 | level: 'debug', 164 | log: `Unable to buy of ${match.groups.ticker} on ${'robinhood'}. quote response:\n${JSON.stringify(body)}`, 165 | }); 166 | } 167 | } 168 | } catch (err) { 169 | self.log({ 170 | level: 'error', 171 | log: JSON.stringify({ 172 | message: err.message, 173 | stack: err.stack, 174 | }), 175 | }); 176 | } 177 | } 178 | } 179 | if (tweetCb) { 180 | return tweetCb(); 181 | } 182 | return null; 183 | }); 184 | if (tweeterCb) { 185 | return tweeterCb(); 186 | } 187 | return null; 188 | }); 189 | } 190 | } 191 | 192 | // look for stock ticker symbols in tweet 193 | // if (typeof tweet.text === 'string') { 194 | // let match; 195 | // do { 196 | // match = this.ticker.exec(tweet.text); 197 | // if (match) { 198 | // const stock = match.groups.ticker; 199 | // this.log({ 200 | // level: 'info', 201 | // log: `${tweeter.name} tweeted about ticker symbol ${stock} in their tweet!!`, 202 | // }); 203 | 204 | // // quote stock 205 | // try { 206 | // const response = await promisify(this.robinhood.quote_data)(stock); 207 | // this.log({ 208 | // level: 'info', 209 | // log: `\nquote for ${stock}:\n${JSON.stringify(response.body)}\n`, 210 | // }); 211 | // } catch (err) { 212 | // this.log({ 213 | // level: 'error', 214 | // log: err.message, 215 | // }); 216 | // } 217 | 218 | // // execute trade (ask for permission from owner if low probability score) 219 | 220 | // // add trade to watch 221 | // } 222 | // // eslint-disable-next-line no-cond-assign 223 | // } while ((match = this.ticker.exec(tweet.text)) !== null); 224 | // } 225 | -------------------------------------------------------------------------------- /src/lib/strategies/Strategy.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | export default class Strategy { 3 | constructor(config) { 4 | // validate config 5 | if (!(config instanceof Map)) { 6 | throw new Error('config param must be of type "Map"'); 7 | } 8 | 9 | this.home = config.get('home'); 10 | this.brokers = config.get('brokers'); 11 | this.api = config.get('api'); 12 | this.db = config.get('db'); 13 | this.name = 'strategy'; 14 | this.log = config.get('log'); 15 | if (this.log === undefined) { 16 | this.log = ({ level, log }) => { 17 | console.log(level, log); 18 | }; 19 | } 20 | } 21 | 22 | async main() { 23 | throw new Error('main function must be implemented'); 24 | } 25 | 26 | loop() { 27 | this.running = this.main(); 28 | this.running.then(() => { 29 | this.running = undefined; 30 | }).catch((err) => { 31 | this.running = undefined; 32 | this.log({ 33 | level: 'error', 34 | log: JSON.stringify({ 35 | message: err.message, 36 | stack: err.stack, 37 | }), 38 | }); 39 | }); 40 | } 41 | 42 | start(interval) { 43 | this.log({ 44 | level: 'info', 45 | log: `Starting strategy ${this.name}`, 46 | }); 47 | // run first loop 48 | this.loop(); 49 | // set interval for continuous looping 50 | this.mainInterval = setInterval(() => { 51 | if (this.running === undefined) { 52 | this.loop(); 53 | } 54 | }, interval); 55 | } 56 | 57 | async stop() { 58 | this.log({ 59 | level: 'info', 60 | log: `Stopping strategy ${this.name}`, 61 | }); 62 | clearInterval(this.mainInterval); 63 | if (this.running) { 64 | await this.running; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/lib/trader.js: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | import { eachOf } from 'async-es'; 3 | import sqlite3 from 'sqlite3'; 4 | import { open } from 'sqlite'; 5 | import path from 'path'; 6 | import api from './api'; 7 | import brokers from './brokers'; 8 | import ReverseSplitArbitrage from './strategies/ReverseSplitArbitrage'; 9 | 10 | // declare db variable in the highest scope 11 | let db; 12 | 13 | // exit boolean determines whether we should abort at the end of a loop. 14 | // paused boolean indicates whether the loop is executing or sleeping. 15 | let initialized = false; 16 | const internal = { 17 | log: console.log, 18 | }; 19 | 20 | // keep tabs on the process 21 | const watch = setInterval(() => { 22 | internal.log({ 23 | level: 'debug', 24 | log: `\n${DateTime.local().toISO()} - process stats:\ncpu:\n${JSON.stringify(process.cpuUsage())}\nmemory:\n${JSON.stringify(process.memoryUsage())}`, 25 | }); 26 | }, 600000); 27 | 28 | export function strategyAction({ action, strategy }) { 29 | if (Object.prototype.hasOwnProperty.call(internal.strategies, strategy.class)) { 30 | switch (action) { 31 | case 'add': 32 | case 'resume': 33 | internal.strategies[strategy.class].start(10000); 34 | break; 35 | case 'remove': 36 | case 'pause': 37 | internal.strategies[strategy.class].stop(); 38 | break; 39 | default: 40 | break; 41 | } 42 | } 43 | } 44 | 45 | // graceful shutdown 46 | export async function gracefulShutdown() { 47 | internal.log({ 48 | level: 'info', 49 | log: 'preparing for shutddown..', 50 | }); 51 | await eachOf(internal.strategies, async (strategy, key, strategyCb) => { 52 | await strategy.stop(); 53 | if (strategyCb) { 54 | return strategyCb(); 55 | } 56 | return null; 57 | }); 58 | clearInterval(watch); 59 | if (db && typeof db.close === 'function') { 60 | try { 61 | await db.close(); 62 | internal.log({ 63 | level: 'info', 64 | log: 'ready for shutdown.', 65 | }); 66 | } catch (err) { 67 | internal.log({ 68 | level: 'info', 69 | log: `forcing shutdown shutdown because of:\n${err.message}`, 70 | }); 71 | } 72 | } 73 | } 74 | 75 | // TODO: create a stategy base class / interface <-- this!! 76 | /** 77 | * Name 78 | * Description 79 | * project_directory 80 | */ 81 | // strategy must inherit base strategy class 82 | // Object.getPrototypeOf(CustomStrategy.constructor) === Strategy; 83 | 84 | // TODO: create an electron ui to monitor, config, review, and etc. 85 | // TODO: throw in some error handling 86 | 87 | export async function main(settings) { 88 | if (initialized) { 89 | return; 90 | } 91 | initialized = true; 92 | internal.log = settings.log; 93 | internal.log({ 94 | level: 'info', 95 | log: 'initializing..\n', 96 | }); 97 | 98 | // open the sqlite database 99 | // no try catch because we want app to fail if this isn't working. 100 | db = await open({ 101 | filename: path.resolve(settings.home, 'trader.db'), 102 | driver: sqlite3.cached.Database, 103 | }); 104 | 105 | // attach secondary databases 106 | const databases = (await db.all('PRAGMA database_list;')).map((o) => (o.name)); 107 | if (databases.indexOf('twitter') === -1) { 108 | await db.run(`ATTACH DATABASE '${path.resolve(settings.home, 'twitter.db')}' AS twitter;`); 109 | } 110 | 111 | // wait for api initialization 112 | const API = await api(settings.credentials); 113 | internal.log({ 114 | level: 'info', 115 | log: 'initialized api\'s', 116 | }); 117 | 118 | // wait for broker initialization 119 | const Brokers = await brokers(internal.log, settings.credentials); 120 | internal.log({ 121 | level: 'info', 122 | log: 'initialized brokers', 123 | }); 124 | 125 | // get accounts 126 | const accounts = await Brokers.robinhood.getAccounts(db); 127 | internal.log({ 128 | level: 'info', 129 | log: `Pulled data for ${accounts.length} robinhood accounts`, 130 | }); 131 | 132 | // get order history 133 | const orders = await Brokers.robinhood.orderHistory(db); 134 | internal.log({ 135 | level: 'info', 136 | log: `Transaction history downloaded... found ${orders.length} new records.`, 137 | }); 138 | 139 | // get current positions 140 | const positions = await Brokers.robinhood.getPositions(db); 141 | internal.log({ 142 | level: 'info', 143 | log: `Positions updated... ${(positions.filter((p) => (parseFloat(p.quantity) > 0))).length} stock(s) in portfolio.`, 144 | }); 145 | 146 | // get portfolio historicals 147 | const historicals = await Brokers.robinhood.getPortfolioHistoricals({ 148 | span: 'week', 149 | interval: '5minute', 150 | }); 151 | console.log(historicals); 152 | 153 | // load user's elected strategies 154 | const elected = await db.all(` 155 | SELECT s.*, es.active, es.updated_at FROM elected_strategies es 156 | JOIN strategies s 157 | ON es.id = s.id; 158 | `); 159 | 160 | // TODO: run trading logic based on elected strategies 161 | const rsaConfig = new Map(); 162 | rsaConfig.set('brokers', Brokers); 163 | rsaConfig.set('api', API); 164 | rsaConfig.set('home', settings.home); 165 | rsaConfig.set('db', db); 166 | rsaConfig.set('log', internal.log); 167 | 168 | internal.strategies = { 169 | ReverseSplitArbitrage: new ReverseSplitArbitrage(rsaConfig), 170 | }; 171 | 172 | // start active strategies 173 | elected.forEach((electee) => { 174 | if (Object.prototype.hasOwnProperty.call(electee, 'active') 175 | && electee.active === 1) { 176 | if (Object.prototype.hasOwnProperty.call(electee, 'class') 177 | && Object.prototype.hasOwnProperty.call(internal.strategies, electee.class)) { 178 | internal.strategies[electee.class].start(10000); 179 | } 180 | } 181 | }); 182 | } 183 | -------------------------------------------------------------------------------- /src/logger/index.js: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports } from 'winston'; 2 | import DailyRotateFile from 'winston-daily-rotate-file'; 3 | import path from 'path'; 4 | 5 | function getLogger(logDirectory) { 6 | const transport = [ 7 | new DailyRotateFile({ 8 | filename: path.resolve(logDirectory, 'info-%DATE%.log'), 9 | datePattern: 'YYYY-MM-DD', 10 | zippedArchive: true, 11 | maxSize: '20m', 12 | maxFiles: '14d', 13 | }), 14 | ]; 15 | 16 | if (process.env.NODE_ENV !== 'production') { 17 | transport.push( 18 | new transports.Console({ 19 | format: format.combine( 20 | format.colorize(), 21 | format.simple(), 22 | ), 23 | }), 24 | ); 25 | } 26 | 27 | return createLogger({ 28 | level: 'silly', 29 | format: format.combine( 30 | format.timestamp({ 31 | format: 'YYYY-MM-DD HH:mm:ss.SSS', 32 | }), 33 | // wating on https://github.com/winstonjs/winston/issues/1724, see also https://github.com/winstonjs/logform/pull/106 34 | // format.errors({ stack: true }), 35 | format.splat(), 36 | format.json(), 37 | ), 38 | defaultMeta: { service: 'trader-bot' }, 39 | transports: transport, 40 | }); 41 | } 42 | 43 | export default getLogger; 44 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import 'roboto-fontface/css/roboto/roboto-fontface.css'; 3 | import '@mdi/font/css/materialdesignicons.css'; 4 | import vuetify from './plugins/vuetify'; 5 | import App from './App.vue'; 6 | import router from './router'; 7 | import store from './store'; 8 | 9 | Vue.config.productionTip = false; 10 | 11 | new Vue({ 12 | router, 13 | store, 14 | vuetify, 15 | render: (h) => h(App), 16 | }).$mount('#app'); 17 | -------------------------------------------------------------------------------- /src/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuetify from 'vuetify/lib/framework'; 3 | 4 | Vue.use(Vuetify); 5 | 6 | export default new Vuetify({ 7 | }); 8 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRouter from 'vue-router'; 3 | import Home from '../views/Home.vue'; 4 | import Strategies from '../views/Strategies.vue'; 5 | import Logs from '../views/Logs.vue'; 6 | import Login from '../views/Login.vue'; 7 | import Logout from '../views/Logout.vue'; 8 | 9 | Vue.use(VueRouter); 10 | 11 | const routes = [ 12 | { 13 | path: '/', 14 | name: 'Home', 15 | component: Home, 16 | }, 17 | { 18 | path: '/strategies', 19 | name: 'Strategies', 20 | component: Strategies, 21 | }, 22 | { 23 | path: '/logs', 24 | name: 'Logs', 25 | component: Logs, 26 | }, 27 | { 28 | path: '/login', 29 | name: 'Login', 30 | component: Login, 31 | }, 32 | { 33 | path: '/logout', 34 | name: 'Logout', 35 | component: Logout, 36 | }, 37 | ]; 38 | 39 | const router = new VueRouter({ 40 | mode: 'history', 41 | base: process.env.BASE_URL, 42 | routes, 43 | }); 44 | 45 | export default router; 46 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import equal from 'deep-equal'; 4 | 5 | Vue.use(Vuex); 6 | 7 | export default new Vuex.Store({ 8 | state: { 9 | cryptr: null, 10 | credentials: null, 11 | db: null, 12 | log: null, 13 | home: null, 14 | brokers: {}, 15 | accounts: [], 16 | positions: [], 17 | }, 18 | mutations: { 19 | updateCredentials(state, credentials) { 20 | state.credentials = credentials; 21 | }, 22 | updateCryptr(state, cryptr) { 23 | state.cryptr = cryptr; 24 | }, 25 | updateDb(state, db) { 26 | state.db = db; 27 | }, 28 | updateLog(state, log) { 29 | state.log = log; 30 | }, 31 | updateHome(state, home) { 32 | state.home = home; 33 | }, 34 | updateAccounts(state, accounts) { 35 | if (!equal(state.accounts, accounts)) { 36 | state.accounts = accounts; 37 | } 38 | }, 39 | updatePositions(state, positions) { 40 | if (!equal(state.postions, positions)) { 41 | state.positions = positions; 42 | } 43 | }, 44 | addBroker(state, broker) { 45 | const brokers = { 46 | ...state.brokers, 47 | }; 48 | brokers[broker.name] = broker.broker; 49 | state.brokers = brokers; 50 | }, 51 | }, 52 | actions: { 53 | }, 54 | modules: { 55 | }, 56 | }); 57 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 92 | -------------------------------------------------------------------------------- /src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 177 | -------------------------------------------------------------------------------- /src/views/Logout.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /src/views/Logs.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 79 | -------------------------------------------------------------------------------- /src/views/Strategies.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 141 | -------------------------------------------------------------------------------- /src/worker/Worker.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 62 | -------------------------------------------------------------------------------- /src/worker/worker.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Worker from './Worker.vue'; 3 | 4 | Vue.config.productionTip = false; 5 | 6 | new Vue({ 7 | render: (h) => h(Worker), 8 | }).$mount('#worker'); 9 | -------------------------------------------------------------------------------- /tests/unit/electron.spec.js: -------------------------------------------------------------------------------- 1 | import testWithSpectron from 'vue-cli-plugin-electron-builder/lib/testWithSpectron'; 2 | import { expect } from 'chai'; 3 | const spectron = __non_webpack_require__('spectron'); 4 | 5 | describe('Application launch', function () { 6 | this.timeout(120000); 7 | 8 | beforeEach(async function () { 9 | const instance = await testWithSpectron(spectron) 10 | this.app = instance.app; 11 | this.stopServe = instance.stopServe; 12 | }); 13 | 14 | afterEach(function () { 15 | if (this.app && this.app.isRunning()) { 16 | return this.app.mainProcess.exit(0); 17 | } 18 | }); 19 | 20 | it('opens a window', async function () { 21 | await this.app.client.waitUntilWindowLoaded(); 22 | expect(await this.app.client.getWindowCount()).to.be.at.least(2); 23 | expect(await this.app.browserWindow.isMinimized()).to.be.false; 24 | expect(await this.app.browserWindow.isVisible()).to.be.true; 25 | const { width, height } = await this.app.browserWindow.getBounds(); 26 | expect(width).to.be.above(0); 27 | expect(height).to.be.above(0); 28 | }); 29 | 30 | }); 31 | -------------------------------------------------------------------------------- /tests/unit/strategy-selector.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { shallowMount, createLocalVue } from '@vue/test-utils'; 3 | import Vuex from 'vuex' 4 | import StrategySelector from '@/components/StrategySelector.vue'; 5 | 6 | const localVue = createLocalVue(); 7 | localVue.use(Vuex); 8 | 9 | describe('StrategySelector.vue', () => { 10 | let store; 11 | 12 | beforeEach(() => { 13 | store = new Vuex.Store({ 14 | state: { 15 | cryptr: null, 16 | credentials: null, 17 | db: null, 18 | log: null, 19 | home: null, 20 | accounts: [], 21 | }, 22 | }); 23 | }); 24 | 25 | it('renders a div', () => { 26 | const wrapper = shallowMount(StrategySelector, { 27 | propsData: { strategies: [], elected: [] }, 28 | store, 29 | localVue, 30 | }); 31 | expect(wrapper.contains('div')).to.true; 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | // vue.config.js 3 | // const TerserPlugin = require('terser-webpack-plugin'); 4 | 5 | module.exports = { 6 | // options... 7 | pages: { 8 | index: 'src/main.js', 9 | worker: 'src/worker/worker.js', 10 | }, 11 | pluginOptions: { 12 | electronBuilder: { 13 | externals: ['sqlite3', 'aws-sdk', 'electron-prompt'], 14 | nodeIntegration: true, 15 | builderOptions: { 16 | // options placed here will be merged with 17 | // default configuration and passed to electron-builder 18 | appId: 'com.fischerapps.trader-bot', 19 | productName: 'Trader Bot', 20 | }, 21 | }, 22 | }, 23 | configureWebpack: { 24 | // devtool: 'source-map', 25 | devtool: 'inline-source-map', 26 | // optimization: { 27 | // minimize: true, 28 | // minimizer: [ 29 | // new TerserPlugin({ 30 | // terserOptions: { 31 | // mangle: false, 32 | // }, 33 | // }), 34 | // ], 35 | // }, 36 | }, 37 | transpileDependencies: [ 38 | 'vuetify', 39 | ], 40 | }; 41 | --------------------------------------------------------------------------------