├── .github ├── FUNDING.yml └── workflows │ └── deploy.yml ├── .gitignore ├── LICENSE ├── README.md ├── requirements.txt ├── setup.py └── webull ├── __init__.py ├── endpoints.py ├── streamconn.py ├── tests └── test_webull.py └── webull.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: tedchou12 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: ['https://www.buymeacoffee.com/tedchou12'] 13 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Github Webull Deploy 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the dev branch 8 | push: 9 | branches: [ master ] 10 | 11 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 12 | jobs: 13 | deployment: 14 | name: Github Webull Deploy 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out repo 18 | uses: actions/checkout@v2 19 | 20 | - name: Set up Python 3.8 21 | uses: actions/setup-python@v1 22 | with: 23 | python-version: 3.8 24 | 25 | - name: Install twine, pipenv and libpq 26 | run: | 27 | sudo apt-get install libpq-dev -y 28 | python3 -m pip install pipenv wheel twine 29 | - name: Cache pipenv virtualenv 30 | id: cache-pipenv 31 | uses: actions/cache@v1 32 | with: 33 | path: ~/.local/share/virtualenvs 34 | key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }} 35 | 36 | - name: Install dependencies 37 | if: steps.cache-pipenv.outputs.cache-hit != 'true' 38 | run: | 39 | pipenv install pip==20.1.1 setuptools==47.3.1 40 | 41 | - name: pypi deployments 42 | run: | 43 | python setup.py sdist 44 | twine upload --skip-existing dist/webull-*.tar.gz -u ted_chou12 -p ${{ secrets.PYPI_PASSWORD }} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .DS_Store 3 | env/ 4 | .gitignore 5 | did.bin 6 | .vscode 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | cover/ 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | db.sqlite3 69 | db.sqlite3-journal 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | 81 | # PyBuilder 82 | .pybuilder/ 83 | target/ 84 | 85 | # Jupyter Notebook 86 | .ipynb_checkpoints 87 | 88 | # IPython 89 | profile_default/ 90 | ipython_config.py 91 | 92 | # pyenv 93 | # For a library or package, you might want to ignore these files since the code is 94 | # intended to run in multiple environments; otherwise, check them in: 95 | # .python-version 96 | 97 | # pipenv 98 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 99 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 100 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 101 | # install all needed dependencies. 102 | #Pipfile.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ted Chou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Webull 2 | APIs for webull, you are free to use, but code not extensively checked and Webull may update the APIs or the endpoints at any time. 3 | https://www.webull.com/ 4 | 5 | Feel free to sign-up for a webull account through here, you will be able to help me to get referral stocks. You can also get 2 stocks for free: 6 | 7 | https://www.webull.com/activity?inviteCode=oqJvTY3rJNyR&source=invite_gw&inviteSource=wb_oversea 8 | 9 | Sorry for procrastinating in answering the questions and updating the packages. But if you really like the package or really like to motivate me. Materialist appreciations would really motivate me to responding you faster 😂: 10 | 11 | [](https://www.buymeacoffee.com/tedchou12) 12 | 13 | 14 | # Install 15 | 16 | ``` 17 | pip install webull 18 | 19 | or 20 | 21 | python3 -m pip install webull 22 | ``` 23 | 24 | # Run tests 25 | 26 | ``` 27 | pip install pytest requests_mock 28 | python -m pytest -v 29 | ``` 30 | 31 | # Usage 32 | 33 | How to login with your email 34 | 35 | Webull has made Multi-Factor Authentication (MFA) mandatory since 2020/05/28, if you are having issues, take a look at here: 36 | https://github.com/tedchou12/webull/wiki/MFA-&-Security 37 | 38 | Or Authenticate without Login completely 2021/02/14: 39 | https://github.com/tedchou12/webull/wiki/Workaround-for-Login 40 | 41 | ``` 42 | from webull import webull # for paper trading, import 'paper_webull' 43 | 44 | wb = webull() 45 | wb.login('test@test.com', 'pa$$w0rd') 46 | 47 | ``` 48 | 49 | How to login with your mobile 50 | ``` 51 | from webull import webull # for paper trading, import 'paper_webull' 52 | 53 | wb = webull() 54 | wb.login('+1-1112223333', 'pa$$w0rd') # phone must be in format +[country_code]-[your number] 55 | 56 | ``` 57 | 58 | How to order stock 59 | ``` 60 | from webull import webull 61 | wb = webull() 62 | wb.login('test@test.com', 'pa$$w0rd') 63 | 64 | wb.get_trade_token('123456') 65 | wb.place_order(stock='AAPL', price=90.0, qty=2) 66 | ``` 67 | 68 | How to check standing orders 69 | ``` 70 | from webull import webull 71 | wb = webull() 72 | wb.login('test@test.com', 'pa$$w0rd') 73 | 74 | wb.get_trade_token('123456') 75 | orders = wb.get_current_orders() 76 | ``` 77 | 78 | How to cancel standing orders 79 | ``` 80 | from webull import webull 81 | wb = webull() 82 | wb.login('test@test.com', 'pa$$w0rd') 83 | 84 | wb.get_trade_token('123456') 85 | wb.cancel_all_orders() 86 | ``` 87 | 88 | # FAQ 89 | Thank you so much, I have received Emails and messages on reddit from many traders/developers that liked this project. Thanks to many that helped and contributed to this project too! There are quite a few repeated questions on the same topic, so I have utilized the Wiki section for them. If you have troubles regarding *Login/MFA Logins*, *Real Time Quote Data*, *What is Trade PIN/Trade Token*, or *How to buy me a coffee* please take a look at the Wiki pages first. https://github.com/tedchou12/webull/wiki 90 | 91 | # Stream Quotes 92 | https://github.com/tedchou12/webull/wiki/How-to-use-Streaming-Quotes%3F 93 | 94 | # Disclaimer 95 | This software is not extensively tested, please use at your own risk. 96 | 97 | # Developers 98 | If you are interested to join and help me improve this, feel free to message me. 99 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2022.12.7 2 | chardet==3.0.4 3 | idna==2.9 4 | numpy==1.22.0 5 | pandas==0.25.3 6 | python-dateutil==2.8.1 7 | pytz==2020.1 8 | requests==2.23.0 9 | six==1.14.0 10 | urllib3==1.26.5 11 | email-validator==1.1.0 12 | paho-mqtt>=1.6.0 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="webull", 8 | version="0.6.1", 9 | author="ted chou", 10 | description="The unofficial python interface for the WeBull API", 11 | license='MIT', 12 | author_email="ted.chou12@gmail.com", 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/tedchou12/webull.git", 16 | packages=setuptools.find_packages(), 17 | install_requires=[ 18 | "certifi>=2020.4.5.1", 19 | "chardet>=3.0.4", 20 | "idna>=2.9", 21 | "numpy>=1.18.4", 22 | "pandas>=0.25.3", 23 | "python-dateutil>=2.8.1", 24 | "pytz>=2020.1", 25 | "requests>=2.23.0", 26 | "six>=1.14.0", 27 | "urllib3>=1.25.9", 28 | "email-validator>=1.1.0", 29 | "paho-mqtt>=1.6.0" 30 | ], 31 | classifiers=[ 32 | "Programming Language :: Python :: 3", 33 | "License :: OSI Approved :: MIT License", 34 | "Operating System :: OS Independent", 35 | ], 36 | python_requires='>=3.6', 37 | ) 38 | -------------------------------------------------------------------------------- /webull/__init__.py: -------------------------------------------------------------------------------- 1 | from .webull import webull, paper_webull 2 | from .streamconn import StreamConn 3 | -------------------------------------------------------------------------------- /webull/endpoints.py: -------------------------------------------------------------------------------- 1 | class urls : 2 | def __init__(self) : 3 | self.base_info_url = 'https://infoapi.webull.com/api' 4 | self.base_options_url = 'https://quoteapi.webullbroker.com/api' 5 | self.base_options_gw_url = 'https://quotes-gw.webullbroker.com/api' 6 | self.base_paper_url = 'https://act.webullbroker.com/webull-paper-center/api' 7 | self.base_quote_url = 'https://quoteapi.webullbroker.com/api' 8 | self.base_securities_url = 'https://securitiesapi.webullbroker.com/api' 9 | self.base_trade_url = 'https://tradeapi.webullbroker.com/api/trade' 10 | self.base_user_url = 'https://userapi.webull.com/api' 11 | self.base_userbroker_url = 'https://userapi.webullbroker.com/api' 12 | self.base_ustrade_url = 'https://ustrade.webullfinance.com/api' 13 | self.base_paperfintech_url = 'https://act.webullfintech.com/webull-paper-center/api' 14 | self.base_fintech_gw_url = 'https://quotes-gw.webullfintech.com/api' 15 | self.base_userfintech_url = 'https://u1suser.webullfintech.com/api' 16 | self.base_new_trade_url = 'https://trade.webullfintech.com/api' 17 | self.base_ustradebroker_url = 'https://ustrade.webullbroker.com/api' 18 | self.base_securitiesfintech_url = 'https://securitiesapi.webullfintech.com/api' 19 | 20 | def account(self, account_id): 21 | return f'{self.base_trade_url}/v3/home/{account_id}' 22 | 23 | def account_id(self): 24 | return f'{self.base_trade_url}/account/getSecAccountList/v5' 25 | 26 | def account_activities(self, account_id): 27 | return f'{self.base_ustrade_url}/trade/v2/funds/{account_id}/activities' 28 | 29 | def active_gainers_losers(self, direction, region_code, rank_type, num) : 30 | if direction == 'gainer' : 31 | url = 'topGainers' 32 | elif direction == 'loser' : 33 | url = 'dropGainers' 34 | else : 35 | url = 'topActive' 36 | return f'{self.base_fintech_gw_url}/wlas/ranking/{url}?regionId={region_code}&rankType={rank_type}&pageIndex=1&pageSize={num}' 37 | 38 | def add_alert(self): 39 | return f'{self.base_userbroker_url}/user/warning/v2/manage/overlap' 40 | 41 | def analysis(self, stock): 42 | return f'{self.base_securities_url}/securities/ticker/v5/analysis/{stock}' 43 | 44 | def analysis_shortinterest(self, stock): 45 | return f'{self.base_securities_url}/securities/stock/{stock}/shortInterest' 46 | 47 | def analysis_institutional_holding(self, stock): 48 | return f'{self.base_securities_url}/securities/stock/v5/{stock}/institutionalHolding' 49 | 50 | def analysis_etf_holding(self, stock, has_num, page_size): 51 | return f'{self.base_securities_url}/securities/stock/v5/{stock}/belongEtf?hasNum={has_num}&pageSize={page_size}' 52 | 53 | def analysis_capital_flow(self, stock, show_hist): 54 | return f'{self.base_securities_url}/wlas/capitalflow/ticker?tickerId={stock}&showHis={show_hist}' 55 | 56 | def bars(self, stock, interval='d1', count=1200, timestamp=None): 57 | #new 58 | return f'{self.base_fintech_gw_url}/quote/charts/query?tickerIds={stock}&type={interval}&count={count}×tamp={timestamp}' 59 | #old 60 | return f'{self.base_quote_url}/quote/tickerChartDatas/v5/{stock}' 61 | 62 | def bars_crypto(self, stock): 63 | return f'{self.base_fintech_gw_url}/crypto/charts/query?tickerIds={stock}' 64 | 65 | def cancel_order(self, account_id): 66 | return f'{self.base_ustrade_url}/trade/order/{account_id}/cancelStockOrder/' 67 | 68 | def modify_otoco_orders(self, account_id): 69 | return f'{self.base_ustrade_url}/trade/v2/corder/stock/modify/{account_id}' 70 | 71 | def cancel_otoco_orders(self, account_id, combo_id): 72 | return f'{self.base_ustrade_url}/trade/v2/corder/stock/cancel/{account_id}/{combo_id}' 73 | 74 | def check_otoco_orders(self, account_id): 75 | return f'{self.base_ustrade_url}/trade/v2/corder/stock/check/{account_id}' 76 | 77 | def place_otoco_orders(self, account_id): 78 | return f'{self.base_ustrade_url}/trade/v2/corder/stock/place/{account_id}' 79 | 80 | def dividends(self, account_id): 81 | return f'{self.base_trade_url}/v2/account/{account_id}/dividends?direct=in' 82 | 83 | def fundamentals(self, stock): 84 | return f'{self.base_securities_url}/securities/financial/index/{stock}' 85 | 86 | def is_tradable(self, stock): 87 | return f'{self.base_trade_url}/ticker/broker/permissionV2?tickerId={stock}' 88 | 89 | def list_alerts(self): 90 | return f'{self.base_userbroker_url}/user/warning/v2/query/tickers' 91 | 92 | def login(self): 93 | return f'{self.base_userfintech_url}/user/v1/login/account/v2' 94 | 95 | def get_mfa(self): 96 | #return f'{self.base_userfintech_url}/user/v1/verificationCode/send/v2' 97 | return f'{self.base_user_url}/user/v1/verificationCode/send/v2' 98 | 99 | def check_mfa(self): 100 | return f'{self.base_userfintech_url}/user/v1/verificationCode/checkCode' 101 | 102 | def get_security(self, username, account_type, region_code, event, time, url=0) : 103 | if url == 1 : 104 | url = 'getPrivacyQuestion' 105 | else : 106 | url = 'getSecurityQuestion' 107 | 108 | return f'{self.base_user_url}/user/risk/{url}?account={username}&accountType={account_type}®ionId={region_code}&event={event}&v={time}' 109 | 110 | def next_security(self, username, account_type, region_code, event, time, url=0) : 111 | if url == 1 : 112 | url = 'nextPrivacyQuestion' 113 | else : 114 | url = 'nextSecurityQuestion' 115 | 116 | return f'{self.base_user_url}/user/risk/{url}?account={username}&accountType={account_type}®ionId={region_code}&event={event}&v={time}' 117 | 118 | def check_security(self) : 119 | return f'{self.base_user_url}/user/risk/checkAnswer' 120 | 121 | def logout(self): 122 | return f'{self.base_userfintech_url}/user/v1/logout' 123 | 124 | def news(self, stock, Id, items): 125 | return f'{self.base_fintech_gw_url}/information/news/tickerNews?tickerId={stock}¤tNewsId={Id}&pageSize={items}' 126 | 127 | def option_quotes(self): 128 | return f'{self.base_options_gw_url}/quote/option/query/list' 129 | 130 | def options(self, stock): 131 | return f'{self.base_options_url}/quote/option/{stock}/list' 132 | 133 | def options_exp_date(self, stock): 134 | return f'{self.base_options_url}/quote/option/{stock}/list' 135 | 136 | #new function 22/05/01 137 | def options_exp_dat_new(self): 138 | return f'{self.base_fintech_gw_url}/quote/option/strategy/list' 139 | 140 | def options_bars(self, derivativeId): 141 | return f'{self.base_options_gw_url}/quote/option/chart/query?derivativeId={derivativeId}' 142 | 143 | def orders(self, account_id, page_size): 144 | return f'{self.base_ustradebroker_url}/trade/v2/option/list?secAccountId={account_id}&startTime=1970-0-1&dateType=ORDER&pageSize={page_size}&status=' 145 | 146 | def history(self, account_id): 147 | return f'{self.base_ustrade_url}/trading/v1/webull/order/list?secAccountId={account_id}' 148 | 149 | def paper_orders(self, paper_account_id, page_size): 150 | return f'{self.base_paper_url}/paper/1/acc/{paper_account_id}/order?&startTime=1970-0-1&dateType=ORDER&pageSize={page_size}&status=' 151 | 152 | def paper_account(self, paper_account_id): 153 | return f'{self.base_paperfintech_url}/paper/1/acc/{paper_account_id}' 154 | 155 | def paper_account_id(self): 156 | return f'{self.base_paperfintech_url}/myaccounts/true' 157 | 158 | def paper_cancel_order(self, paper_account_id, order_id): 159 | return f'{self.base_paper_url}/paper/1/acc/{paper_account_id}/orderop/cancel/{order_id}' 160 | 161 | def paper_modify_order(self, paper_account_id, order_id): 162 | return f'{self.base_paper_url}/paper/1/acc/{paper_account_id}/orderop/modify/{order_id}' 163 | 164 | def paper_place_order(self, paper_account_id, stock): 165 | return f'{self.base_paper_url}/paper/1/acc/{paper_account_id}/orderop/place/{stock}' 166 | 167 | def place_option_orders(self, account_id): 168 | return f'{self.base_ustrade_url}/trade/v2/option/placeOrder/{account_id}' 169 | 170 | def place_orders(self, account_id): 171 | return f'{self.base_ustrade_url}/trade/order/{account_id}/placeStockOrder' 172 | 173 | def modify_order(self, account_id, order_id): 174 | return f'{self.base_ustrade_url}/trading/v1/webull/order/stockOrderModify?secAccountId={account_id}' 175 | 176 | def quotes(self, stock): 177 | return f'{self.base_options_gw_url}/quotes/ticker/getTickerRealTime?tickerId={stock}&includeSecu=1&includeQuote=1' 178 | 179 | def rankings(self): 180 | return f'{self.base_securities_url}/securities/market/v5/6/portal' 181 | 182 | def refresh_login(self, refresh_token): 183 | return f'{self.base_user_url}/passport/refreshToken?refreshToken={refresh_token}' 184 | 185 | def remove_alert(self): 186 | return f'{self.base_userbroker_url}/user/warning/v2/manage/overlap' 187 | 188 | def replace_option_orders(self, account_id): 189 | return f'{self.base_trade_url}/v2/option/replaceOrder/{account_id}' 190 | 191 | def stock_detail(self, stock) : 192 | return f'{self.base_fintech_gw_url}/stock/tickerRealTime/getQuote?tickerId={stock}&includeSecu=1&includeQuote=1&more=1' 193 | 194 | def stock_id(self, stock, region_code): 195 | return f'{self.base_options_gw_url}/search/pc/tickers?keyword={stock}&pageIndex=1&pageSize=20®ionId={region_code}' 196 | 197 | def trade_token(self): 198 | return f'{self.base_new_trade_url}/trading/v1/global/trade/login' 199 | 200 | def user(self): 201 | return f'{self.base_user_url}/user' 202 | 203 | def screener(self): 204 | return f'{self.base_userbroker_url}/wlas/screener/ng/query' 205 | 206 | def social_posts(self, topic, num=100): 207 | return f'{self.base_user_url}/social/feed/topic/{topic}/posts?size={num}' 208 | 209 | def social_home(self, topic, num=100): 210 | return f'{self.base_user_url}/social/feed/topic/{topic}/home?size={num}' 211 | 212 | def portfolio_lists(self): 213 | return f'{self.base_options_gw_url}/personal/portfolio/v2/check' 214 | 215 | def press_releases(self, stock, typeIds=None, num=50): 216 | typeIdsString = '' 217 | if typeIds is not None: 218 | typeIdsString = '&typeIds=' + typeIds 219 | return f'{self.base_securitiesfintech_url}/securities/announcement/{stock}/list?lastAnnouncementId=0&limit={num}{typeIdsString}&options=2' 220 | 221 | def calendar_events(self, event, region_code, start_date, page=1, num=50): 222 | return f'{self.base_fintech_gw_url}/bgw/explore/calendar/{event}?regionId={region_code}&pageIndex={page}&pageSize={num}&startDate={start_date}' 223 | 224 | def get_all_tickers(self, region_code, user_region_code) : 225 | return f'{self.base_securitiesfintech_url}/securities/market/v5/card/stockActivityPc.advanced/list?regionId={region_code}&userRegionId={user_region_code}&hasNum=0&pageSize=9999' 226 | -------------------------------------------------------------------------------- /webull/streamconn.py: -------------------------------------------------------------------------------- 1 | import paho.mqtt.client as mqtt 2 | import threading 3 | import json 4 | import time 5 | import os 6 | from . import webull 7 | 8 | class StreamConn : 9 | def __init__(self, debug_flg=False): 10 | self.onsub_lock = threading.RLock() 11 | self.oncon_lock = threading.RLock() 12 | self.onmsg_lock = threading.RLock() 13 | self.price_func = None 14 | self.order_func = None 15 | self.debug_flg = debug_flg 16 | self.total_volume={} 17 | self.client_order_upd = None 18 | self.client_streaming_quotes = None 19 | 20 | """ 21 | ====Order status from platpush==== 22 | topic _______: messageId, action, type, title, messageHeaders{popstatus, popvalidtime}, 23 | data {"tickerId, brokerId, secAccountId, orderId, filledQuantity, orderType, 24 | orderStatus, messageProtocolVersion, messageProtocolUri,messageTitle, messageContent} 25 | 26 | ====Price updates from wspush==== 27 | The topic of each message contains the message type and tickerId 28 | All price messages have a status field defined as F = pre-market, T = market hours, A = after-market 29 | Topic 102 is the most common streaming message used by the trading app, 30 | at times 102 for some crazy reason may not show a close price or volume, so it's not useful for a trading app 31 | High/low/open/close/volume are usually totals for the day 32 | Most message have these common fields: transId, pubId, tickerId, tradeStamp, trdSeq, status 33 | topic 101: T: close, change, marketValue, changeRatio 34 | topic 102: F/A: pPrice, pChange, pChRatio, 35 | T: high(optional), low(optional), open(optional), close(optional), volume(optional), 36 | vibrateRatio, turnoverRate(optional), change, changeRatio, marketValue 37 | topic 103: F/A: deal:{trdBs(always N), volume, tradeTime(H:M:S), price} 38 | T: deal:{trdBs, volume, tradeTime(H:M:S), price} 39 | topic 104: F/T/A: askList:[{price, volume}], bidList:[{price, volume}] 40 | topic 105: Seems to be 102 and 103 41 | topic 106: Seems to be 102 (and 103 sometimes depending on symbol/exchange) 42 | topic 107: Seems to be 103 and 104 43 | topic 108: Seems to be 103 and 104 and T: depth:{ntvAggAskList:[{price:, volume}], ntvAggBidList:[{price:,volume:}]}} 44 | """ 45 | 46 | def _setup_callbacks(self): 47 | """ 48 | Has to be done this way to have them live in a class and not require self as the first parameter 49 | Python is kind enough to hold onto a copy of self for the callbacks to use later on 50 | return: addresses of the call backs 51 | """ 52 | def on_connect(client, userdata, flags, rc): 53 | """ 54 | The callback for when the client receives a CONNACK response from the server. 55 | """ 56 | self.oncon_lock.acquire() 57 | if self.debug_flg: 58 | print("Connected with result code "+str(rc)) 59 | if rc != 0: 60 | raise ValueError("Connection Failed with rc:"+str(rc)) 61 | self.oncon_lock.release() 62 | 63 | def on_order_message(client, userdata, msg): 64 | #{tickerId, orderId, filledQuantity, orderType, orderStatus} 65 | self.onmsg_lock.acquire() 66 | 67 | topic = json.loads(msg.topic) 68 | data = json.loads(msg.payload) 69 | if self.debug_flg: 70 | print(f'topic: {topic} ----- payload: {data}') 71 | 72 | if not self.order_func is None: 73 | self.order_func(topic, data) 74 | 75 | self.onmsg_lock.release() 76 | 77 | def on_price_message(client, userdata, msg): 78 | self.onmsg_lock.acquire() 79 | try: 80 | topic = json.loads(msg.topic) 81 | data = json.loads(msg.payload) 82 | if self.debug_flg: 83 | print(f'topic: {topic} ----- payload: {data}') 84 | 85 | if not self.price_func is None: 86 | self.price_func(topic, data) 87 | 88 | except Exception as e: 89 | print(e) 90 | time.sleep(2) #so theres time for message to print 91 | os._exit(6) 92 | 93 | self.onmsg_lock.release() 94 | 95 | def on_subscribe(client, userdata, mid, granted_qos, properties=None): 96 | """ 97 | The callback for when the client receives a SUBACK response from the server. 98 | """ 99 | self.onsub_lock.acquire() 100 | if self.debug_flg: 101 | print(f"subscribe accepted with QOS: {granted_qos} with mid: {mid}") 102 | self.onsub_lock.release() 103 | 104 | def on_unsubscribe(client, userdata, mid): 105 | """ 106 | The callback for when the client receives a UNSUBACK response from the server. 107 | """ 108 | self.onsub_lock.acquire() 109 | if self.debug_flg: 110 | print(f"unsubscribe accepted with mid: {mid}") 111 | self.onsub_lock.release() 112 | #-------- end message callbacks 113 | return on_connect, on_subscribe, on_price_message, on_order_message, on_unsubscribe 114 | 115 | 116 | def connect(self, did, access_token=None) : 117 | if access_token is None: 118 | say_hello = {"header": 119 | {"did": did, 120 | "hl": "en", 121 | "app": "desktop", 122 | "os": "web", 123 | "osType": "windows"} 124 | } 125 | else: 126 | say_hello = {"header": 127 | {"access_token": access_token, 128 | "did": did, 129 | "hl": "en", 130 | "app": "desktop", 131 | "os": "web", 132 | "osType": "windows"} 133 | } 134 | 135 | 136 | #Has to be done this way to have them live in a class and not require self as the first parameter 137 | #in the callback functions 138 | on_connect, on_subscribe, on_price_message, on_order_message, on_unsubscribe = self._setup_callbacks() 139 | 140 | if not access_token is None: 141 | # no need to listen to order updates if you don't have a access token 142 | # paper trade order updates are not send down this socket, I believe they 143 | # are polled every 30=60 seconds from the app 144 | 145 | self.client_order_upd = mqtt.Client(did, transport='websockets') 146 | self.client_order_upd.on_connect = on_connect 147 | self.client_order_upd.on_subscribe = on_subscribe 148 | self.client_order_upd.on_message = on_order_message 149 | self.client_order_upd.tls_set_context() 150 | # this is a default password that they use in the app 151 | self.client_order_upd.username_pw_set('test', password='test') 152 | self.client_order_upd.connect('wspush.webullbroker.com', 443, 30) 153 | #time.sleep(5) 154 | self.client_order_upd.loop_start() # runs in a second thread 155 | print('say hello') 156 | self.client_order_upd.subscribe(json.dumps(say_hello)) 157 | #time.sleep(5) 158 | 159 | self.client_streaming_quotes = mqtt.Client(client_id=did, transport='websockets') 160 | self.client_streaming_quotes.on_connect = on_connect 161 | self.client_streaming_quotes.on_subscribe = on_subscribe 162 | self.client_streaming_quotes.on_unsubscribe = on_unsubscribe 163 | self.client_streaming_quotes.on_message = on_price_message 164 | self.client_streaming_quotes.tls_set_context() 165 | #this is a default password that they use in the app 166 | self.client_streaming_quotes.username_pw_set('test', password='test') 167 | self.client_streaming_quotes.connect('wspush.webullbroker.com', 443, 30) 168 | #time.sleep(5) 169 | self.client_streaming_quotes.loop() 170 | #print('say hello') 171 | self.client_streaming_quotes.subscribe(json.dumps(say_hello)) 172 | #time.sleep(5) 173 | self.client_streaming_quotes.loop() 174 | #print('sub ticker') 175 | 176 | 177 | def run_blocking_loop(self): 178 | """ 179 | this will never return, you need to put all your processing in the on message function 180 | """ 181 | self.client_streaming_quotes.loop_forever() 182 | 183 | def run_loop_once(self): 184 | try: 185 | self.client_streaming_quotes.loop() 186 | except Exception as e: 187 | print(e) 188 | time.sleep(2) # so theres time for message to print 189 | os._exit(6) 190 | 191 | def subscribe(self, tId=None, level=105): 192 | #you can only use curly brackets for variables in a f string 193 | self.client_streaming_quotes.subscribe('{'+f'"tickerIds":[{tId}],"type":"{level}"'+'}') 194 | self.client_streaming_quotes.loop() 195 | 196 | 197 | def unsubscribe(self, tId=None, level=105): 198 | self.client_streaming_quotes.unsubscribe(f'["type={level}&tid={tId}"]') 199 | #self.client_streaming_quotes.loop() #no need for this, you should already be in a loop 200 | 201 | 202 | if __name__ == '__main__': 203 | webull = webull(cmd=True) 204 | 205 | # for demo purpose 206 | webull.login('xxxxxx@xxxx.com', 'xxxxx') 207 | webull.get_trade_token('xxxxxx') 208 | # set self.account_id first 209 | webull.get_account_id() 210 | # webull.place_order('NKTR', 21.0, 1) 211 | orders = webull.get_current_orders() 212 | for order in orders: 213 | # print(order) 214 | webull.cancel_order(order['orderId']) 215 | # print(webull.get_serial_id()) 216 | # print(webull.get_ticker('BABA')) 217 | 218 | #test streaming 219 | nyc = timezone('America/New_York') 220 | def on_price_message(topic, data): 221 | print (data) 222 | print(f"Ticker: {topic['tickerId']}, Price: {data['deal']['price']}, Volume: {data['deal']['volume']}", end='', sep='') 223 | if 'tradeTime' in data: 224 | print(', tradeTime: ', data['tradeTime']) 225 | else: 226 | tradetime = data['deal']['tradeTime'] 227 | current_dt = datetime.today().astimezone(nyc) 228 | ts = current_dt.replace(hour=int(tradetime[:2]), minute=int(tradetime[3:5]), second=0, microsecond=0) 229 | print(', tradeTime: ', ts) 230 | 231 | def on_order_message(topic, data): 232 | print(data) 233 | 234 | 235 | conn = StreamConn(debug_flg=True) 236 | # set these to a processing callback where your algo logic is 237 | conn.price_func = on_price_message 238 | conn.order_func = on_order_message 239 | 240 | if not webull._access_token is None and len(webull._access_token) > 1: 241 | conn.connect(webull._did, access_token=webull._access_token) 242 | else: 243 | conn.connect(webull._did) 244 | 245 | conn.subscribe(tId='913256135') #AAPL 246 | conn.run_loop_once() 247 | conn.run_blocking_loop() #never returns till script crashes or exits 248 | -------------------------------------------------------------------------------- /webull/tests/test_webull.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests_mock 3 | 4 | from unittest.mock import MagicMock 5 | from webull.webull import webull, endpoints 6 | 7 | ''' 8 | 9 | [HOW TO RUN TESTS] 10 | 11 | pip install pytest requests_mocks 12 | python -m pytest -v 13 | 14 | ''' 15 | 16 | ##################################################### 17 | ################# FIXTURES ################## 18 | ##################################################### 19 | 20 | urls = endpoints.urls() 21 | 22 | @pytest.fixture 23 | def reqmock(): 24 | with requests_mock.Mocker() as m: 25 | yield m 26 | 27 | @pytest.fixture 28 | def wb(): 29 | return webull() 30 | 31 | 32 | ##################################################### 33 | ################# TESTS ##################### 34 | ##################################################### 35 | 36 | @pytest.mark.skip(reason="TODO") 37 | def test_alerts_add(): 38 | pass 39 | 40 | def test_alerts_list(wb: webull, reqmock): 41 | # [case 1] unsuccessful alerts_list 42 | reqmock.get(urls.list_alerts(), text=''' 43 | { 44 | "success": false 45 | } 46 | ''') 47 | 48 | result = wb.alerts_list() 49 | assert result is None 50 | 51 | # [case 2] successful alerts_list 52 | reqmock.get(urls.list_alerts(), text=''' 53 | { 54 | "success": true, 55 | "data": [ 56 | { 57 | "tickerId": 913257472, 58 | "tickerSymbol": "SBUX" 59 | } 60 | ] 61 | } 62 | ''') 63 | 64 | result = wb.alerts_list() 65 | assert result is not None 66 | 67 | @pytest.mark.skip(reason="TODO") 68 | def test_alerts_remove(): 69 | pass 70 | 71 | @pytest.mark.skip(reason="TODO") 72 | def test_build_req_headers(): 73 | pass 74 | 75 | @pytest.mark.skip(reason="TODO") 76 | def test_cancel_order(): 77 | pass 78 | 79 | @pytest.mark.skip(reason="TODO") 80 | def test_cancel_otoco_order(): 81 | pass 82 | 83 | @pytest.mark.skip(reason="TODO") 84 | def test_get_account(): 85 | pass 86 | 87 | @pytest.mark.skip(reason="TODO") 88 | def test_get_account_id(): 89 | pass 90 | 91 | @pytest.mark.skip(reason="TODO") 92 | def test_get_active_gainer_loser(): 93 | pass 94 | 95 | @pytest.mark.skip(reason="TODO") 96 | def test_get_analysis(): 97 | pass 98 | 99 | @pytest.mark.skip(reason="TODO") 100 | def test_get_bars(): 101 | pass 102 | 103 | @pytest.mark.skip(reason="TODO") 104 | def test_get_calendar(): 105 | pass 106 | 107 | @pytest.mark.skip(reason="TODO") 108 | def test_get_current_orders(): 109 | pass 110 | 111 | @pytest.mark.skip(reason="TODO") 112 | def test_get_detail(): 113 | pass 114 | 115 | @pytest.mark.skip(reason="TODO") 116 | def test_get_dividends(): 117 | pass 118 | 119 | @pytest.mark.skip(reason="TODO") 120 | def test_get_financials(): 121 | pass 122 | 123 | @pytest.mark.skip(reason="TODO") 124 | def test_get_history_orders(): 125 | pass 126 | 127 | def test_get_news(wb: webull, reqmock): 128 | stock = 'AAPL' 129 | Id = 0 130 | items = 20 131 | ticker = 913256135 132 | wb.get_ticker = MagicMock(return_value=ticker) 133 | reqmock.get(urls.stock_id(stock, wb._region_code), text=''' 134 | { 135 | "categoryId":0, 136 | "categoryName":"综合", 137 | "hasMore":true, 138 | "list":[{ 139 | "tickerId":913256135, 140 | "exchangeId":96, 141 | "type":2, 142 | "name":"Apple", 143 | "symbol":"AAPL", 144 | "disSymbol":"AAPL", 145 | "disExchangeCode":"NASDAQ", 146 | "exchangeCode":"NSQ", 147 | }] 148 | } 149 | ''') 150 | 151 | reqmock.get(urls.news(stock, Id, items), text=''' 152 | [ 153 | { 154 | "id": 45810067, 155 | "title": "US STOCKS-S&P 500, Dow gain on factory data, strong oil prices ", 156 | "sourceName": "reuters.com", 157 | "newsTime": "2021-09-15T16:35:33.000+0000", 158 | "summary": "US STOCKS-S&P 500, Dow gain on factory data, strong oil prices ", 159 | "newsUrl": "https://pub.webullfintech.com/us/news-html/83c0d98e2cca4165b94addfc3bfdac47.html", 160 | "siteType": 0, 161 | "collectSource": "reuters" 162 | } 163 | ] 164 | ''') 165 | 166 | result = wb.get_news(tId=stock, Id=Id, items=items) 167 | assert result is not None 168 | assert result[0]['id'] is not None 169 | 170 | @pytest.mark.skip(reason="TODO") 171 | def test_get_option_quote(): 172 | pass 173 | 174 | @pytest.mark.skip(reason="TODO") 175 | def test_get_options(): 176 | pass 177 | 178 | @pytest.mark.skip(reason="TODO") 179 | def test_get_options_by_strike_and_expire_date(): 180 | pass 181 | 182 | @pytest.mark.skip(reason="TODO") 183 | def test_get_options_expiration_dates(): 184 | pass 185 | 186 | @pytest.mark.skip(reason="TODO") 187 | def test_get_portfolio(): 188 | pass 189 | 190 | @pytest.mark.skip(reason="TODO") 191 | def test_get_positions(): 192 | pass 193 | 194 | def test_get_quote(wb: webull, reqmock): 195 | 196 | # successful get_quote 197 | stock = 'AAPL' 198 | ticker = 913256135 199 | wb.get_ticker = MagicMock(return_value=ticker) 200 | reqmock.get(urls.stock_id(stock, wb._region_code), text=''' 201 | { 202 | "categoryId":0, 203 | "categoryName":"综合", 204 | "hasMore":true, 205 | "list":[{ 206 | "tickerId":913256135, 207 | "exchangeId":96, 208 | "type":2, 209 | "name":"Apple", 210 | "symbol":"AAPL", 211 | "disSymbol":"AAPL", 212 | "disExchangeCode":"NASDAQ", 213 | "exchangeCode":"NSQ", 214 | }] 215 | } 216 | ''') 217 | 218 | reqmock.get(urls.quotes(ticker), text=''' 219 | { 220 | "open": "312.15", 221 | "close": "307.65", 222 | "high": "315.95", 223 | "low": "303.21", 224 | "tickerId": "913256135" 225 | } 226 | ''') 227 | 228 | result = wb.get_quote(stock=stock) 229 | assert result['open'] == '312.15' 230 | assert result['close'] == '307.65' 231 | assert result['high'] == '315.95' 232 | assert result['low'] == '303.21' 233 | assert result['tickerId'] == '913256135' 234 | 235 | # failed get_quote, no stock or tId provided 236 | with pytest.raises(ValueError): 237 | wb.get_quote() 238 | 239 | # failed get_quote, stock symbol doesn't exist 240 | bad_stock_symbol = '__YOLOSWAG__' 241 | wb.get_ticker = MagicMock(side_effect=ValueError('TickerId could not be found for stock __YOLOSWAG__')) 242 | with pytest.raises(ValueError) as e: 243 | wb.get_quote(bad_stock_symbol) 244 | 245 | def test_get_ticker(wb: webull, reqmock): 246 | 247 | # failed get_ticker, stock doesn't exist 248 | bad_stock_symbol = '__YOLOSWAG__' 249 | reqmock.get(urls.stock_id(bad_stock_symbol, wb._region_code), text=''' 250 | { "hasMore": false } 251 | ''') 252 | with pytest.raises(ValueError) as e: 253 | wb.get_ticker(bad_stock_symbol) 254 | assert 'TickerId could not be found for stock {}'.format(bad_stock_symbol) in str(e.value) 255 | 256 | # failed get_ticker, no stock provided 257 | with pytest.raises(ValueError) as e: 258 | wb.get_ticker() 259 | assert 'Stock symbol is required' in str(e.value) 260 | 261 | # successful get_ticker 262 | good_stock_symbol = 'SBUX' 263 | reqmock.get(urls.stock_id(good_stock_symbol, wb._region_code), text=''' 264 | { 265 | "data":[ 266 | { 267 | "tickerId":913257472, 268 | "exchangeId":96, 269 | "type":2, 270 | "secType":[61], 271 | "regionId":6, 272 | "regionCode":"US", 273 | "currencyId":247, 274 | "currencyCode":"USD", 275 | "name":"Starbucks", 276 | "symbol":"SBUX", 277 | "disSymbol":"SBUX", 278 | "disExchangeCode":"NASDAQ", 279 | "exchangeCode":"NSQ", 280 | "listStatus":1, 281 | "template":"stock", 282 | "derivativeSupport":1, 283 | "tinyName":"Starbucks" 284 | } 285 | ], 286 | "hasMore":false 287 | } 288 | ''') 289 | result = wb.get_ticker('SBUX') 290 | assert result == 913257472 291 | 292 | @pytest.mark.skip(reason="TODO") 293 | def test_get_ticker(): 294 | pass 295 | 296 | def test_get_tradable(wb: webull, reqmock): 297 | # [case 1] get_tradable returns any json object 298 | stock = 'SBUX' 299 | reqmock.get(urls.stock_id(stock, wb._region_code), text=''' 300 | { 301 | "data": [{ 302 | "tickerId": 913257472, 303 | "symbol": "SBUX" 304 | }] 305 | } 306 | ''') 307 | 308 | reqmock.get(urls.is_tradable("913257472"), text=''' 309 | { 310 | "json": "object" 311 | } 312 | ''') 313 | 314 | resp = wb.get_tradable(stock=stock) 315 | assert resp is not None 316 | 317 | def test_get_trade_token(wb: webull, reqmock): 318 | # [case 1] get_trade_token fails, access token is expired 319 | reqmock.post(urls.trade_token(), text=''' 320 | { 321 | "msg": "AccessToken is expire", 322 | "traceId": "xxxxxxxxxxxxxxxxxxxxxxxxxx", 323 | "code": "auth.token.expire", 324 | "success": false 325 | } 326 | ''') 327 | 328 | any_password = '123456' 329 | resp = wb.get_trade_token(any_password) 330 | assert resp == False 331 | assert wb._trade_token == '' 332 | 333 | # [case 2] get_trade_token fails, password is incorrect 334 | reqmock.post(urls.trade_token(), text=''' 335 | { 336 | "msg":"'Inner server error", 337 | "traceId": "xxxxxxxxxxxxxxxxxxxxxxxxxx", 338 | "code": "trade.pwd.invalid", 339 | "data": { "fail": 1.0, "retry": 4.0 }, 340 | "success": false 341 | } 342 | ''') 343 | 344 | bad_password = '123456' 345 | resp = wb.get_trade_token(bad_password) 346 | assert resp == False 347 | assert wb._trade_token == '' 348 | 349 | # [case 3] get_trade_token succeeds, password is correct 350 | reqmock.post(urls.trade_token(), text=''' 351 | { 352 | "success": true, 353 | "data": { 354 | "tradeToken": "xxxxxxxxxx", 355 | "tradeTokenExpireIn": 28800000 356 | } 357 | } 358 | ''') 359 | 360 | good_password = '123456' 361 | resp = wb.get_trade_token(good_password) 362 | assert resp == True 363 | assert wb._trade_token == 'xxxxxxxxxx' 364 | 365 | def test_login(reqmock, wb): 366 | # [case 1] login fails, bad mobile username credentials 367 | reqmock.post(urls.login(), text=''' 368 | { 369 | "code": "phone.illegal" 370 | } 371 | ''') 372 | 373 | bad_mobile_username = '1112224444' 374 | resp = wb.login(username=bad_mobile_username, password='xxxxxxxx') 375 | assert resp['code'] == 'phone.illegal' 376 | 377 | # [case 2] mobile login succeeds 378 | # actual API response includes more data, but for brevity, 379 | # this mock response only returns the fields which we are expecting 380 | reqmock.post(urls.login(), text=''' 381 | { 382 | "accessToken":"xxxxxxxxxx", 383 | "uuid":"yyyyyyyyyy", 384 | "refreshToken":"zzzzzzzzzz", 385 | "tokenExpireTime":"2020-07 13T00:25:34.235+0000" 386 | } 387 | ''') 388 | # mocking this to cover webull.login internal call to webull.get_account_id 389 | wb.get_account_id = MagicMock(return_value='11111111') 390 | 391 | resp = wb.login(username='1+1112223333', password='xxxxxxxx') 392 | assert wb._access_token == 'xxxxxxxxxx' 393 | assert wb._refresh_token == 'zzzzzzzzzz' 394 | assert wb._token_expire == '2020-07 13T00:25:34.235+0000' 395 | assert wb._uuid == 'yyyyyyyyyy' 396 | assert wb._account_id == '11111111' 397 | 398 | # if username or password is not passed, should raise ValueError 399 | with pytest.raises(ValueError): 400 | wb.login() 401 | 402 | @pytest.mark.skip(reason="TODO") 403 | def test_login_prompt(): 404 | pass 405 | 406 | def test_login_prompt(): 407 | pass 408 | 409 | def test_logout(wb, reqmock): 410 | # successful logout returns a 200 response 411 | reqmock.get(urls.logout(), status_code=200) 412 | resp = wb.logout() 413 | assert resp == 200 414 | 415 | def test_modify_order(): 416 | pass 417 | 418 | @pytest.mark.skip(reason="TODO") 419 | def test_place_option_order(): 420 | pass 421 | 422 | @pytest.mark.skip(reason="TODO") 423 | def test_place_order(): 424 | pass 425 | 426 | @pytest.mark.skip(reason="TODO") 427 | def test_place_otoco_order(): 428 | pass 429 | 430 | @pytest.mark.skip(reason="TODO") 431 | def test_refresh_login(): 432 | pass 433 | 434 | @pytest.mark.skip(reason="TODO") 435 | def test_replace_option_order(): 436 | pass 437 | 438 | @pytest.mark.skip(reason="TODO") 439 | def test_run_screener(): 440 | pass 441 | -------------------------------------------------------------------------------- /webull/webull.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import collections 3 | import getpass 4 | import hashlib 5 | import json 6 | import os 7 | import pickle 8 | import requests 9 | import time 10 | import uuid 11 | import urllib.parse 12 | 13 | from datetime import datetime, timedelta 14 | from email_validator import validate_email, EmailNotValidError 15 | from pandas import DataFrame, to_datetime 16 | from pytz import timezone 17 | 18 | from . import endpoints 19 | 20 | class webull : 21 | 22 | def __init__(self, region_code=None) : 23 | self._session = requests.session() 24 | self._headers = { 25 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0', 26 | 'Accept': '*/*', 27 | 'Accept-Encoding': 'gzip, deflate', 28 | 'Accept-Language': 'en-US,en;q=0.5', 29 | 'Content-Type': 'application/json', 30 | 'platform': 'web', 31 | 'hl': 'en', 32 | 'os': 'web', 33 | 'osv': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0', 34 | 'app': 'global', 35 | 'appid': 'webull-webapp', 36 | 'ver': '3.39.18', 37 | 'lzone': 'dc_core_r001', 38 | 'ph': 'MacOS Firefox', 39 | 'locale': 'eng', 40 | # 'reqid': req_id, 41 | 'device-type': 'Web', 42 | 'did': self._get_did() 43 | } 44 | 45 | #endpoints 46 | self._urls = endpoints.urls() 47 | 48 | #sessions 49 | self._account_id = '' 50 | self._trade_token = '' 51 | self._access_token = '' 52 | self._refresh_token = '' 53 | self._token_expire = '' 54 | self._uuid = '' 55 | 56 | #miscellaenous 57 | self._did = self._get_did() 58 | self._region_code = region_code or 6 59 | self.zone_var = 'dc_core_r001' 60 | self.timeout = 15 61 | 62 | def _get_did(self, path=''): 63 | ''' 64 | Makes a unique device id from a random uuid (uuid.uuid4). 65 | if the pickle file doesn't exist, this func will generate a random 32 character hex string 66 | uuid and save it in a pickle file for future use. if the file already exists it will 67 | load the pickle file to reuse the did. Having a unique did appears to be very important 68 | for the MQTT web socket protocol 69 | 70 | path: path to did.bin. For example _get_did('cache') will search for cache/did.bin instead. 71 | 72 | :return: hex string of a 32 digit uuid 73 | ''' 74 | filename = 'did.bin' 75 | if path: 76 | filename = os.path.join(path, filename) 77 | if os.path.exists(filename): 78 | did = pickle.load(open(filename,'rb')) 79 | else: 80 | did = uuid.uuid4().hex 81 | pickle.dump(did, open(filename, 'wb')) 82 | return did 83 | 84 | def _set_did(self, did, path=''): 85 | ''' 86 | If your starting to use this package after webull's new image verification for login, you'll 87 | need to login from a browser to get your did file in order to login through this api. You can 88 | find your did file by using this link: 89 | 90 | https://github.com/tedchou12/webull/wiki/Workaround-for-Login 91 | 92 | and then headers tab instead of response head, and finally look for the did value from the 93 | request headers. 94 | 95 | Then, you can run this program to save your did into did.bin so that it can be accessed in the 96 | future without the did explicitly being in your code. 97 | 98 | path: path to did.bin. For example _get_did('cache') will search for cache/did.bin instead. 99 | ''' 100 | filename = 'did.bin' 101 | if path: 102 | filename = os.path.join(path, filename) 103 | pickle.dump(did, open(filename, 'wb')) 104 | return True 105 | 106 | def build_req_headers(self, include_trade_token=False, include_time=False, include_zone_var=True): 107 | ''' 108 | Build default set of header params 109 | ''' 110 | headers = self._headers 111 | req_id = str(uuid.uuid4().hex) 112 | headers['reqid'] = req_id 113 | headers['did'] = self._did 114 | headers['access_token'] = self._access_token 115 | if include_trade_token : 116 | headers['t_token'] = self._trade_token 117 | if include_time : 118 | headers['t_time'] = str(round(time.time() * 1000)) 119 | if include_zone_var : 120 | headers['lzone'] = self.zone_var 121 | return headers 122 | 123 | 124 | def login(self, username='', password='', device_name='', mfa='', question_id='', question_answer='', save_token=False, token_path=None): 125 | ''' 126 | Login with email or phone number 127 | 128 | phone numbers must be a str in the following form 129 | US '+1-XXXXXXX' 130 | CH '+86-XXXXXXXXXXX' 131 | ''' 132 | 133 | if not username or not password: 134 | raise ValueError('username or password is empty') 135 | 136 | # with webull md5 hash salted 137 | password = ('wl_app-a&b@!423^' + password).encode('utf-8') 138 | md5_hash = hashlib.md5(password) 139 | 140 | account_type = self.get_account_type(username) 141 | 142 | if device_name == '' : 143 | device_name = 'default_string' 144 | 145 | data = { 146 | 'account': username, 147 | 'accountType': str(account_type), 148 | 'deviceId': self._did, 149 | 'deviceName': device_name, 150 | 'grade': 1, 151 | 'pwd': md5_hash.hexdigest(), 152 | 'regionId': self._region_code 153 | } 154 | 155 | if mfa != '' : 156 | data['extInfo'] = { 157 | 'codeAccountType': account_type, 158 | 'verificationCode': mfa 159 | } 160 | headers = self.build_req_headers() 161 | else : 162 | headers = self._headers 163 | 164 | if question_id != '' and question_answer != '' : 165 | data['accessQuestions'] = '[{"questionId":"' + str(question_id) + '", "answer":"' + str(question_answer) + '"}]' 166 | 167 | response = requests.post(self._urls.login(), json=data, headers=headers, timeout=self.timeout) 168 | result = response.json() 169 | if 'accessToken' in result : 170 | self._access_token = result['accessToken'] 171 | self._refresh_token = result['refreshToken'] 172 | self._token_expire = result['tokenExpireTime'] 173 | self._uuid = result['uuid'] 174 | self._account_id = self.get_account_id() 175 | if save_token: 176 | self._save_token(result, token_path) 177 | return result 178 | 179 | def get_mfa(self, username='') : 180 | account_type = self.get_account_type(username) 181 | 182 | data = {'account': str(username), 183 | 'accountType': str(account_type), 184 | 'codeType': int(5)} 185 | 186 | response = requests.post(self._urls.get_mfa(), json=data, headers=self._headers, timeout=self.timeout) 187 | # data = response.json() 188 | 189 | if response.status_code == 200 : 190 | return True 191 | else : 192 | return False 193 | 194 | def check_mfa(self, username='', mfa='') : 195 | account_type = self.get_account_type(username) 196 | 197 | data = {'account': str(username), 198 | 'accountType': str(account_type), 199 | 'code': str(mfa), 200 | 'codeType': int(5)} 201 | 202 | response = requests.post(self._urls.check_mfa(), json=data, headers=self._headers, timeout=self.timeout) 203 | data = response.json() 204 | 205 | return data 206 | 207 | def get_security(self, username='') : 208 | account_type = self.get_account_type(username) 209 | username = urllib.parse.quote(username) 210 | 211 | # seems like webull has a bug/stability issue here: 212 | time = datetime.now().timestamp() * 1000 213 | response = requests.get(self._urls.get_security(username, account_type, self._region_code, 'PRODUCT_LOGIN', time, 0), headers=self._headers, timeout=self.timeout) 214 | data = response.json() 215 | if len(data) == 0 : 216 | response = requests.get(self._urls.get_security(username, account_type, self._region_code, 'PRODUCT_LOGIN', time, 1), headers=self._headers, timeout=self.timeout) 217 | data = response.json() 218 | 219 | return data 220 | 221 | def next_security(self, username='') : 222 | account_type = self.get_account_type(username) 223 | username = urllib.parse.quote(username) 224 | 225 | # seems like webull has a bug/stability issue here: 226 | time = datetime.now().timestamp() * 1000 227 | response = requests.get(self._urls.next_security(username, account_type, self._region_code, 'PRODUCT_LOGIN', time, 0), headers=self._headers, timeout=self.timeout) 228 | data = response.json() 229 | if len(data) == 0 : 230 | response = requests.get(self._urls.next_security(username, account_type, self._region_code, 'PRODUCT_LOGIN', time, 1), headers=self._headers, timeout=self.timeout) 231 | data = response.json() 232 | 233 | return data 234 | 235 | def check_security(self, username='', question_id='', question_answer='') : 236 | account_type = self.get_account_type(username) 237 | 238 | data = {'account': str(username), 239 | 'accountType': str(account_type), 240 | 'answerList': [{'questionId': str(question_id), 'answer': str(question_answer)}], 241 | 'event': 'PRODUCT_LOGIN'} 242 | 243 | response = requests.post(self._urls.check_security(), json=data, headers=self._headers, timeout=self.timeout) 244 | data = response.json() 245 | 246 | return data 247 | 248 | def login_prompt(self): 249 | ''' 250 | End login session 251 | ''' 252 | uname = input('Enter Webull Username:') 253 | pwd = getpass.getpass('Enter Webull Password:') 254 | self.trade_pin = getpass.getpass('Enter 6 digit Webull Trade PIN:') 255 | self.login(uname, pwd) 256 | return self.get_trade_token(self.trade_pin) 257 | 258 | def logout(self): 259 | ''' 260 | End login session 261 | ''' 262 | headers = self.build_req_headers() 263 | response = requests.get(self._urls.logout(), headers=headers, timeout=self.timeout) 264 | return response.status_code 265 | 266 | def api_login(self, access_token='', refresh_token='', token_expire='', uuid='', mfa=''): 267 | self._access_token = access_token 268 | self._refresh_token = refresh_token 269 | self._token_expire = token_expire 270 | self._uuid = uuid 271 | self._account_id = self.get_account_id() 272 | 273 | def refresh_login(self, save_token=False, token_path=None): 274 | ''' 275 | Refresh login token 276 | ''' 277 | headers = self.build_req_headers() 278 | data = {'refreshToken': self._refresh_token} 279 | 280 | response = requests.post(self._urls.refresh_login(self._refresh_token), json=data, headers=headers, timeout=self.timeout) 281 | result = response.json() 282 | if 'accessToken' in result and result['accessToken'] != '' and result['refreshToken'] != '' and result['tokenExpireTime'] != '': 283 | self._access_token = result['accessToken'] 284 | self._refresh_token = result['refreshToken'] 285 | self._token_expire = result['tokenExpireTime'] 286 | self._account_id = self.get_account_id() 287 | if save_token: 288 | result['uuid'] = self._uuid 289 | self._save_token(result, token_path) 290 | return result 291 | 292 | def _save_token(self, token=None, path=None): 293 | ''' 294 | save login token to webull_credentials.json 295 | ''' 296 | filename = 'webull_credentials.json' 297 | if path: 298 | filename = os.path.join(path, filename) 299 | with open(filename, 'wb') as f: 300 | pickle.dump(token, f) 301 | return True 302 | return False 303 | 304 | def get_detail(self): 305 | ''' 306 | get some contact details of your account name, email/phone, region, avatar...etc 307 | ''' 308 | headers = self.build_req_headers() 309 | 310 | response = requests.get(self._urls.user(), headers=headers, timeout=self.timeout) 311 | result = response.json() 312 | 313 | return result 314 | 315 | def get_account_id(self, id=0): 316 | ''' 317 | get account id 318 | call account id before trade actions 319 | ''' 320 | headers = self.build_req_headers() 321 | 322 | response = requests.get(self._urls.account_id(), headers=headers, timeout=self.timeout) 323 | result = response.json() 324 | if result['success'] and len(result['data']) > 0 : 325 | self.zone_var = str(result['data'][int(id)]['rzone']) 326 | self._account_id = str(result['data'][int(id)]['secAccountId']) 327 | return self._account_id 328 | else: 329 | return None 330 | 331 | def get_account(self): 332 | ''' 333 | get important details of account, positions, portfolio stance...etc 334 | ''' 335 | headers = self.build_req_headers() 336 | response = requests.get(self._urls.account(self._account_id), headers=headers, timeout=self.timeout) 337 | result = response.json() 338 | return result 339 | 340 | def get_positions(self): 341 | ''' 342 | output standing positions of stocks 343 | ''' 344 | data = self.get_account() 345 | return data['positions'] 346 | 347 | def get_portfolio(self): 348 | ''' 349 | output numbers of portfolio 350 | ''' 351 | data = self.get_account() 352 | output = {} 353 | for item in data['accountMembers']: 354 | output[item['key']] = item['value'] 355 | return output 356 | 357 | def get_activities(self, index=1, size=500) : 358 | ''' 359 | Activities including transfers, trades and dividends 360 | ''' 361 | headers = self.build_req_headers(include_trade_token=True, include_time=True) 362 | data = {'pageIndex': index, 363 | 'pageSize': size} 364 | response = requests.post(self._urls.account_activities(self._account_id), json=data, headers=headers, timeout=self.timeout) 365 | return response.json() 366 | 367 | def get_current_orders(self) : 368 | ''' 369 | Get open/standing orders 370 | ''' 371 | data = self.get_account() 372 | return data['openOrders'] 373 | 374 | def get_history_orders(self, status='All', count=20): 375 | ''' 376 | Historical orders, can be cancelled or filled 377 | status = Cancelled / Filled / Working / Partially Filled / Pending / Failed / All 378 | ''' 379 | headers = self.build_req_headers(include_trade_token=True, include_time=True) 380 | response = requests.get(self._urls.orders(self._account_id, count) + str(status), headers=headers, timeout=self.timeout) 381 | return response.json() 382 | 383 | def get_trade_token(self, password=''): 384 | ''' 385 | Trading related 386 | authorize trade, must be done before trade action 387 | ''' 388 | headers = self.build_req_headers() 389 | 390 | # with webull md5 hash salted 391 | password = ('wl_app-a&b@!423^' + password).encode('utf-8') 392 | md5_hash = hashlib.md5(password) 393 | data = {'pwd': md5_hash.hexdigest()} 394 | 395 | response = requests.post(self._urls.trade_token(), json=data, headers=headers, timeout=self.timeout) 396 | result = response.json() 397 | if 'tradeToken' in result : 398 | self._trade_token = result['tradeToken'] 399 | return True 400 | else: 401 | return False 402 | 403 | ''' 404 | Lookup ticker_id 405 | Ticker issue, will attempt to find an exact match, if none is found, match the first one 406 | ''' 407 | def get_ticker(self, stock=''): 408 | headers = self.build_req_headers() 409 | ticker_id = 0 410 | if stock and isinstance(stock, str): 411 | response = requests.get(self._urls.stock_id(stock, self._region_code), headers=headers, timeout=self.timeout) 412 | result = response.json() 413 | if result.get('data') : 414 | for item in result['data'] : # implies multiple tickers, but only assigns last one? 415 | if 'symbol' in item and item['symbol'] == stock : 416 | ticker_id = item['tickerId'] 417 | break 418 | elif 'disSymbol' in item and item['disSymbol'] == stock : 419 | ticker_id = item['tickerId'] 420 | break 421 | if ticker_id == 0 : 422 | ticker_id = result['data'][0]['tickerId'] 423 | else: 424 | raise ValueError('TickerId could not be found for stock {}'.format(stock)) 425 | else: 426 | raise ValueError('Stock symbol is required') 427 | return ticker_id 428 | 429 | ''' 430 | Get stock public info 431 | get price quote 432 | tId: ticker ID str 433 | ''' 434 | def get_ticker_info(self, stock=None, tId=None) : 435 | headers = self.build_req_headers() 436 | if not stock and not tId: 437 | raise ValueError('Must provide a stock symbol or a stock id') 438 | 439 | if stock : 440 | try: 441 | tId = str(self.get_ticker(stock)) 442 | except ValueError as _e: 443 | raise ValueError("Could not find ticker for stock {}".format(stock)) 444 | response = requests.get(self._urls.stock_detail(tId), headers=headers, timeout=self.timeout) 445 | result = response.json() 446 | return result 447 | 448 | ''' 449 | Get all tickers from a region 450 | region id: https://github.com/tedchou12/webull/wiki/What-is-the-region_id%3F 451 | ''' 452 | def get_all_tickers(self, region_code=None) : 453 | headers = self.build_req_headers() 454 | 455 | if not region_code : 456 | region_code = self._region_code 457 | 458 | response = requests.get(self._urls.get_all_tickers(region_code, region_code), headers=headers, timeout=self.timeout) 459 | result = response.json() 460 | return result 461 | 462 | ''' 463 | Actions related to stock 464 | ''' 465 | def get_quote(self, stock=None, tId=None): 466 | ''' 467 | get price quote 468 | tId: ticker ID str 469 | ''' 470 | headers = self.build_req_headers() 471 | if not stock and not tId: 472 | raise ValueError('Must provide a stock symbol or a stock id') 473 | 474 | if stock: 475 | try: 476 | tId = str(self.get_ticker(stock)) 477 | except ValueError as _e: 478 | raise ValueError("Could not find ticker for stock {}".format(stock)) 479 | response = requests.get(self._urls.quotes(tId), headers=headers, timeout=self.timeout) 480 | result = response.json() 481 | return result 482 | 483 | def place_order(self, stock=None, tId=None, price=0, action='BUY', orderType='LMT', enforce='GTC', quant=0, outsideRegularTradingHour=True, stpPrice=None, trial_value=0, trial_type='DOLLAR'): 484 | ''' 485 | Place an order 486 | 487 | price: float (LMT / STP LMT Only) 488 | action: BUY / SELL / SHORT 489 | ordertype : LMT / MKT / STP / STP LMT / STP TRAIL 490 | timeinforce: GTC / DAY / IOC 491 | outsideRegularTradingHour: True / False 492 | stpPrice: float (STP / STP LMT Only) 493 | trial_value: float (STP TRIAL Only) 494 | trial_type: DOLLAR / PERCENTAGE (STP TRIAL Only) 495 | ''' 496 | if not tId is None: 497 | pass 498 | elif not stock is None: 499 | tId = self.get_ticker(stock) 500 | else: 501 | raise ValueError('Must provide a stock symbol or a stock id') 502 | 503 | headers = self.build_req_headers(include_trade_token=True, include_time=True) 504 | data = { 505 | 'action': action, 506 | 'comboType': 'NORMAL', 507 | 'orderType': orderType, 508 | 'outsideRegularTradingHour': outsideRegularTradingHour, 509 | 'quantity': int(quant), 510 | 'serialId': str(uuid.uuid4()), 511 | 'tickerId': tId, 512 | 'timeInForce': enforce 513 | } 514 | 515 | # Market orders do not support extended hours trading. 516 | if orderType == 'MKT' : 517 | data['outsideRegularTradingHour'] = False 518 | elif orderType == 'LMT': 519 | data['lmtPrice'] = float(price) 520 | elif orderType == 'STP' : 521 | data['auxPrice'] = float(stpPrice) 522 | elif orderType == 'STP LMT' : 523 | data['lmtPrice'] = float(price) 524 | data['auxPrice'] = float(stpPrice) 525 | elif orderType == 'STP TRAIL' : 526 | data['trailingStopStep'] = float(trial_value) 527 | data['trailingType'] = str(trial_type) 528 | 529 | response = requests.post(self._urls.place_orders(self._account_id), json=data, headers=headers, timeout=self.timeout) 530 | return response.json() 531 | 532 | def modify_order(self, order=None, order_id=0, stock=None, tId=None, price=0, action=None, orderType=None, enforce=None, quant=0, outsideRegularTradingHour=None): 533 | ''' 534 | Modify an order 535 | order_id: order_id 536 | action: BUY / SELL 537 | ordertype : LMT / MKT / STP / STP LMT / STP TRAIL 538 | timeinforce: GTC / DAY / IOC 539 | outsideRegularTradingHour: True / False 540 | ''' 541 | if not order and not order_id: 542 | raise ValueError('Must provide an order or order_id') 543 | 544 | headers = self.build_req_headers(include_trade_token=True, include_time=True) 545 | 546 | modifiedAction = action or order['action'] 547 | modifiedLmtPrice = float(price or order['lmtPrice']) 548 | modifiedOrderType = orderType or order['orderType'] 549 | modifiedOutsideRegularTradingHour = outsideRegularTradingHour if type(outsideRegularTradingHour) == bool else order['outsideRegularTradingHour'] 550 | modifiedEnforce = enforce or order['timeInForce'] 551 | modifiedQuant = int(quant or order['quantity']) 552 | if not tId is None: 553 | pass 554 | elif not stock is None: 555 | tId = self.get_ticker(stock) 556 | else : 557 | tId = order['ticker']['tickerId'] 558 | order_id = order_id or order['orderId'] 559 | 560 | data = { 561 | 'action': modifiedAction, 562 | 'lmtPrice': modifiedLmtPrice, 563 | 'orderType': modifiedOrderType, 564 | 'quantity': modifiedQuant, 565 | 'comboType': 'NORMAL', 566 | 'outsideRegularTradingHour': modifiedOutsideRegularTradingHour, 567 | 'serialId': str(uuid.uuid4()), 568 | 'orderId': order_id, 569 | 'tickerId': tId, 570 | 'timeInForce': modifiedEnforce 571 | } 572 | #Market orders do not support extended hours trading. 573 | if data['orderType'] == 'MKT': 574 | data['outsideRegularTradingHour'] = False 575 | 576 | response = requests.post(self._urls.modify_order(self._account_id, order_id), json=data, headers=headers, timeout=self.timeout) 577 | 578 | return response.json() 579 | 580 | def cancel_order(self, order_id=''): 581 | ''' 582 | Cancel an order 583 | ''' 584 | headers = self.build_req_headers(include_trade_token=True, include_time=True) 585 | data = {} 586 | response = requests.post(self._urls.cancel_order(self._account_id) + str(order_id) + '/' + str(uuid.uuid4()), json=data, headers=headers, timeout=self.timeout) 587 | result = response.json() 588 | return result['success'] 589 | 590 | def place_order_otoco(self, stock='', price='', stop_loss_price='', limit_profit_price='', time_in_force='DAY', quant=0) : 591 | ''' 592 | OTOCO: One-triggers-a-one-cancels-the-others, aka Bracket Ordering 593 | Submit a buy order, its fill will trigger sell order placement. If one sell fills, it will cancel the other 594 | sell 595 | ''' 596 | headers = self.build_req_headers(include_trade_token=False, include_time=True) 597 | data1 = { 598 | 'newOrders': [ 599 | {'orderType': 'LMT', 'timeInForce': time_in_force, 'quantity': int(quant), 600 | 'outsideRegularTradingHour': False, 'action': 'BUY', 'tickerId': self.get_ticker(stock), 601 | 'lmtPrice': float(price), 'comboType': 'MASTER'}, 602 | {'orderType': 'STP', 'timeInForce': time_in_force, 'quantity': int(quant), 603 | 'outsideRegularTradingHour': False, 'action': 'SELL', 'tickerId': self.get_ticker(stock), 604 | 'auxPrice': float(stop_loss_price), 'comboType': 'STOP_LOSS'}, 605 | {'orderType': 'LMT', 'timeInForce': time_in_force, 'quantity': int(quant), 606 | 'outsideRegularTradingHour': False, 'action': 'SELL', 'tickerId': self.get_ticker(stock), 607 | 'lmtPrice': float(limit_profit_price), 'comboType': 'STOP_PROFIT'} 608 | ] 609 | } 610 | 611 | response1 = requests.post(self._urls.check_otoco_orders(self._account_id), json=data1, headers=headers, timeout=self.timeout) 612 | result1 = response1.json() 613 | 614 | if result1['forward'] : 615 | data2 = {'newOrders': [ 616 | {'orderType': 'LMT', 'timeInForce': time_in_force, 'quantity': int(quant), 617 | 'outsideRegularTradingHour': False, 'action': 'BUY', 'tickerId': self.get_ticker(stock), 618 | 'lmtPrice': float(price), 'comboType': 'MASTER', 'serialId': str(uuid.uuid4())}, 619 | {'orderType': 'STP', 'timeInForce': time_in_force, 'quantity': int(quant), 620 | 'outsideRegularTradingHour': False, 'action': 'SELL', 'tickerId': self.get_ticker(stock), 621 | 'auxPrice': float(stop_loss_price), 'comboType': 'STOP_LOSS', 'serialId': str(uuid.uuid4())}, 622 | {'orderType': 'LMT', 'timeInForce': time_in_force, 'quantity': int(quant), 623 | 'outsideRegularTradingHour': False, 'action': 'SELL', 'tickerId': self.get_ticker(stock), 624 | 'lmtPrice': float(limit_profit_price), 'comboType': 'STOP_PROFIT', 'serialId': str(uuid.uuid4())}], 625 | 'serialId': str(uuid.uuid4()) 626 | } 627 | 628 | response2 = requests.post(self._urls.place_otoco_orders(self._account_id), json=data2, headers=headers, timeout=self.timeout) 629 | 630 | # print('Resp 2: {}'.format(response2)) 631 | return response2.json() 632 | else: 633 | print(result1['checkResultList'][0]['code']) 634 | print(result1['checkResultList'][0]['msg']) 635 | return False 636 | 637 | def modify_order_otoco(self, order_id1='', order_id2='', order_id3='', stock='', price='', stop_loss_price='', limit_profit_price='', time_in_force='DAY', quant=0) : 638 | ''' 639 | OTOCO: One-triggers-a-one-cancels-the-others, aka Bracket Ordering 640 | Submit a buy order, its fill will trigger sell order placement. If one sell fills, it will cancel the other 641 | sell 642 | ''' 643 | headers = self.build_req_headers(include_trade_token=False, include_time=True) 644 | 645 | data = {'modifyOrders': [ 646 | {'orderType': 'LMT', 'timeInForce': time_in_force, 'quantity': int(quant), 'orderId': str(order_id1), 647 | 'outsideRegularTradingHour': False, 'action': 'BUY', 'tickerId': self.get_ticker(stock), 648 | 'lmtPrice': float(price), 'comboType': 'MASTER', 'serialId': str(uuid.uuid4())}, 649 | {'orderType': 'STP', 'timeInForce': time_in_force, 'quantity': int(quant), 'orderId': str(order_id2), 650 | 'outsideRegularTradingHour': False, 'action': 'SELL', 'tickerId': self.get_ticker(stock), 651 | 'auxPrice': float(stop_loss_price), 'comboType': 'STOP_LOSS', 'serialId': str(uuid.uuid4())}, 652 | {'orderType': 'LMT', 'timeInForce': time_in_force, 'quantity': int(quant), 'orderId': str(order_id3), 653 | 'outsideRegularTradingHour': False, 'action': 'SELL', 'tickerId': self.get_ticker(stock), 654 | 'lmtPrice': float(limit_profit_price), 'comboType': 'STOP_PROFIT', 'serialId': str(uuid.uuid4())}], 655 | 'serialId': str(uuid.uuid4()) 656 | } 657 | 658 | response = requests.post(self._urls.modify_otoco_orders(self._account_id), json=data, headers=headers, timeout=self.timeout) 659 | 660 | # print('Resp: {}'.format(response)) 661 | return response.json() 662 | 663 | def cancel_order_otoco(self, combo_id=''): 664 | ''' 665 | Retract an otoco order. Cancelling the MASTER order_id cancels the sub orders. 666 | ''' 667 | headers = self.build_req_headers(include_trade_token=True, include_time=True) 668 | # data = { 'serialId': str(uuid.uuid4()), 'cancelOrders': [str(order_id)]} 669 | data = {} 670 | response = requests.post(self._urls.cancel_otoco_orders(self._account_id, combo_id), json=data, headers=headers, timeout=self.timeout) 671 | return response.json() 672 | 673 | ''' 674 | Actions related to cryptos 675 | ''' 676 | def place_order_crypto(self, stock=None, tId=None, price=0, action='BUY', orderType='LMT', enforce='DAY', entrust_type='QTY', quant=0, outsideRegularTradingHour=False) : 677 | ''' 678 | Place Crypto order 679 | price: Limit order entry price 680 | quant: dollar amount to buy/sell when entrust_type is CASH else the decimal or fractional amount of shares to buy 681 | action: BUY / SELL 682 | entrust_type: CASH / QTY 683 | ordertype : LMT / MKT 684 | timeinforce: DAY 685 | outsideRegularTradingHour: True / False 686 | ''' 687 | if not tId is None: 688 | pass 689 | elif not stock is None: 690 | tId = self.get_ticker(stock) 691 | else: 692 | raise ValueError('Must provide a stock symbol or a stock id') 693 | 694 | headers = self.build_req_headers(include_trade_token=True, include_time=True) 695 | data = { 696 | 'action': action, 697 | 'assetType': 'crypto', 698 | 'comboType': 'NORMAL', 699 | 'entrustType': entrust_type, 700 | 'lmtPrice': str(price), 701 | 'orderType': orderType, 702 | 'outsideRegularTradingHour': outsideRegularTradingHour, 703 | 'quantity': str(quant), 704 | 'serialId': str(uuid.uuid4()), 705 | 'tickerId': tId, 706 | 'timeInForce': enforce 707 | } 708 | 709 | response = requests.post(self._urls.place_orders(self._account_id), json=data, headers=headers, timeout=self.timeout) 710 | return response.json() 711 | 712 | ''' 713 | Actions related to options 714 | ''' 715 | def get_option_quote(self, stock=None, tId=None, optionId=None): 716 | ''' 717 | get option quote 718 | ''' 719 | if not stock and not tId: 720 | raise ValueError('Must provide a stock symbol or a stock id') 721 | 722 | if stock: 723 | try: 724 | tId = str(self.get_ticker(stock)) 725 | except ValueError as _e: 726 | raise ValueError("Could not find ticker for stock {}".format(stock)) 727 | headers = self.build_req_headers() 728 | params = {'tickerId': tId, 'derivativeIds': optionId} 729 | return requests.get(self._urls.option_quotes(), params=params, headers=headers, timeout=self.timeout).json() 730 | 731 | def get_options_expiration_dates(self, stock=None, count=-1): 732 | ''' 733 | returns a list of options expiration dates 734 | ''' 735 | headers = self.build_req_headers() 736 | data = {'count': count, 737 | 'direction': 'all', 738 | 'tickerId': self.get_ticker(stock)} 739 | 740 | res = requests.post(self._urls.options_exp_dat_new(), json=data, headers=headers, timeout=self.timeout).json() 741 | r_data = [] 742 | for entry in res['expireDateList'] : 743 | r_data.append(entry['from']) 744 | 745 | # return requests.get(self._urls.options_exp_date(self.get_ticker(stock)), params=data, headers=headers, timeout=self.timeout).json()['expireDateList'] 746 | return r_data 747 | 748 | def get_options(self, stock=None, count=-1, includeWeekly=1, direction='all', expireDate=None, queryAll=0): 749 | ''' 750 | get options and returns a dict of options contracts 751 | params: 752 | stock: symbol 753 | count: -1 754 | includeWeekly: 0 or 1 (deprecated) 755 | direction: all, call, put 756 | expireDate: contract expire date 757 | queryAll: 0 (deprecated) 758 | ''' 759 | headers = self.build_req_headers() 760 | # get next closet expiredate if none is provided 761 | if not expireDate: 762 | dates = self.get_options_expiration_dates(stock) 763 | # ensure we don't provide an option that has < 1 day to expire 764 | for d in dates: 765 | if d['days'] > 0: 766 | expireDate = d['date'] 767 | break 768 | 769 | data = {'count': count, 770 | 'direction': direction, 771 | 'tickerId': self.get_ticker(stock)} 772 | 773 | res = requests.post(self._urls.options_exp_dat_new(), json=data, headers=headers, timeout=self.timeout).json() 774 | t_data = [] 775 | for entry in res['expireDateList'] : 776 | if str(entry['from']['date']) == expireDate : 777 | t_data = entry['data'] 778 | 779 | r_data = {} 780 | for entry in t_data : 781 | if entry['strikePrice'] not in r_data : 782 | r_data[entry['strikePrice']] = {} 783 | r_data[entry['strikePrice']][entry['direction']] = entry 784 | 785 | r_data = dict(sorted(r_data.items())) 786 | 787 | rr_data = [] 788 | for s_price in r_data : 789 | rr_entry = {'strikePrice': s_price} 790 | if 'call' in r_data[s_price] : 791 | rr_entry['call'] = r_data[s_price]['call'] 792 | if 'put' in r_data[s_price] : 793 | rr_entry['put'] = r_data[s_price]['put'] 794 | rr_data.append(rr_entry) 795 | 796 | return rr_data 797 | 798 | #deprecated 22/05/01 799 | # params = {'count': count, 800 | # 'includeWeekly': includeWeekly, 801 | # 'direction': direction, 802 | # 'expireDate': expireDate, 803 | # 'unSymbol': stock, 804 | # 'queryAll': queryAll} 805 | # 806 | # data = requests.get(self._urls.options(self.get_ticker(stock)), params=params, headers=headers, timeout=self.timeout).json() 807 | # 808 | # return data['data'] 809 | 810 | def get_options_by_strike_and_expire_date(self, stock=None, expireDate=None, strike=None, direction='all'): 811 | ''' 812 | get a list of options contracts by expire date and strike price 813 | strike: string 814 | ''' 815 | 816 | opts = self.get_options(stock=stock, expireDate=expireDate, direction=direction) 817 | return [c for c in opts if c['strikePrice'] == strike] 818 | 819 | def place_order_option(self, optionId=None, lmtPrice=None, stpPrice=None, action=None, orderType='LMT', enforce='DAY', quant=0): 820 | ''' 821 | create buy / sell order 822 | stock: string 823 | lmtPrice: float 824 | stpPrice: float 825 | action: string BUY / SELL 826 | optionId: string 827 | orderType: MKT / LMT / STP / STP LMT 828 | enforce: GTC / DAY 829 | quant: int 830 | ''' 831 | headers = self.build_req_headers(include_trade_token=True, include_time=True) 832 | data = { 833 | 'orderType': orderType, 834 | 'serialId': str(uuid.uuid4()), 835 | 'timeInForce': enforce, 836 | 'orders': [{'quantity': int(quant), 'action': action, 'tickerId': int(optionId), 'tickerType': 'OPTION'}], 837 | } 838 | 839 | if orderType == 'LMT' and lmtPrice : 840 | data['lmtPrice'] = float(lmtPrice) 841 | elif orderType == 'STP' and stpPrice : 842 | data['auxPrice'] = float(stpPrice) 843 | elif orderType == 'STP LMT' and lmtPrice and stpPrice : 844 | data['lmtPrice'] = float(lmtPrice) 845 | data['auxPrice'] = float(stpPrice) 846 | 847 | response = requests.post(self._urls.place_option_orders(self._account_id), json=data, headers=headers, timeout=self.timeout) 848 | if response.status_code != 200: 849 | raise Exception('place_option_order failed', response.status_code, response.reason) 850 | return response.json() 851 | 852 | def modify_order_option(self, order=None, lmtPrice=None, stpPrice=None, enforce=None, quant=0): 853 | ''' 854 | order: dict from get_current_orders 855 | stpPrice: float 856 | lmtPrice: float 857 | enforce: GTC / DAY 858 | quant: int 859 | ''' 860 | headers = self.build_req_headers(include_trade_token=True, include_time=True) 861 | data = { 862 | 'comboId': order['comboId'], 863 | 'orderType': order['orderType'], 864 | 'timeInForce': enforce or order['timeInForce'], 865 | 'serialId': str(uuid.uuid4()), 866 | 'orders': [{'quantity': quant or order['totalQuantity'], 867 | 'action': order['action'], 868 | 'tickerId': order['ticker']['tickerId'], 869 | 'tickerType': 'OPTION', 870 | 'orderId': order['orderId']}] 871 | } 872 | 873 | if order['orderType'] == 'LMT' and (lmtPrice or order.get('lmtPrice')): 874 | data['lmtPrice'] = lmtPrice or order['lmtPrice'] 875 | elif order['orderType'] == 'STP' and (stpPrice or order.get('auxPrice')): 876 | data['auxPrice'] = stpPrice or order['auxPrice'] 877 | elif order['orderType'] == 'STP LMT' and (stpPrice or order.get('auxPrice')) and (lmtPrice or order.get('lmtPrice')): 878 | data['auxPrice'] = stpPrice or order['auxPrice'] 879 | data['lmtPrice'] = lmtPrice or order['lmtPrice'] 880 | 881 | response = requests.post(self._urls.replace_option_orders(self._account_id), json=data, headers=headers, timeout=self.timeout) 882 | if response.status_code != 200: 883 | raise Exception('replace_option_order failed', response.status_code, response.reason) 884 | return True 885 | 886 | def cancel_all_orders(self): 887 | ''' 888 | Cancels all open (aka 'working') orders 889 | ''' 890 | open_orders = self.get_current_orders() 891 | for order in open_orders: 892 | self.cancel_order(order['orderId']) 893 | 894 | def get_tradable(self, stock='') : 895 | ''' 896 | get if stock is tradable 897 | ''' 898 | headers = self.build_req_headers() 899 | response = requests.get(self._urls.is_tradable(self.get_ticker(stock)), headers=headers, timeout=self.timeout) 900 | 901 | return response.json() 902 | 903 | def alerts_list(self) : 904 | ''' 905 | Get alerts 906 | ''' 907 | headers = self.build_req_headers() 908 | 909 | response = requests.get(self._urls.list_alerts(), headers=headers, timeout=self.timeout) 910 | result = response.json() 911 | if 'data' in result: 912 | return result.get('data', []) 913 | else: 914 | return None 915 | 916 | def alerts_remove(self, alert=None, priceAlert=True, smartAlert=True): 917 | ''' 918 | remove alert 919 | alert is retrieved from alert_list 920 | ''' 921 | headers = self.build_req_headers() 922 | 923 | if alert.get('tickerWarning') and priceAlert: 924 | alert['tickerWarning']['remove'] = True 925 | alert['warningInput'] = alert['tickerWarning'] 926 | 927 | if alert.get('eventWarning') and smartAlert: 928 | alert['eventWarning']['remove'] = True 929 | for rule in alert['eventWarning']['rules']: 930 | rule['active'] = 'off' 931 | alert['eventWarningInput'] = alert['eventWarning'] 932 | 933 | response = requests.post(self._urls.remove_alert(), json=alert, headers=headers, timeout=self.timeout) 934 | if response.status_code != 200: 935 | raise Exception('alerts_remove failed', response.status_code, response.reason) 936 | return True 937 | 938 | def alerts_add(self, stock=None, frequency=1, interval=1, priceRules=[], smartRules=[]): 939 | ''' 940 | add price/percent/volume alert 941 | frequency: 1 is once a day, 2 is once a minute 942 | interval: 1 is once, 0 is repeating 943 | priceRules: list of dicts with below attributes per alert 944 | field: price , percent , volume 945 | type: price (above/below), percent (above/below), volume (vol in thousands) 946 | value: price, percent, volume amount 947 | remark: comment 948 | rules example: 949 | priceRules = [{'field': 'price', 'type': 'above', 'value': '900.00', 'remark': 'above'}, {'field': 'price', 'type': 'below', 950 | 'value': '900.00', 'remark': 'below'}] 951 | smartRules = [{'type':'earnPre','active':'on'},{'type':'fastUp','active':'on'},{'type':'fastDown','active':'on'}, 952 | {'type':'week52Up','active':'on'},{'type':'week52Down','active':'on'},{'type':'day5Down','active':'on'}] 953 | ''' 954 | headers = self.build_req_headers() 955 | 956 | rule_keys = ['value', 'field', 'remark', 'type', 'active'] 957 | for line, rule in enumerate(priceRules, start=1): 958 | for key in rule: 959 | if key not in rule_keys: 960 | raise Exception('malformed price alert priceRules found.') 961 | rule['alertRuleKey'] = line 962 | rule['active'] = 'on' 963 | 964 | alert_keys = ['earnPre', 'fastUp', 'fastDown', 'week52Up', 'week52Down', 'day5Up', 'day10Up', 'day20Up', 'day5Down', 'day10Down', 'day20Down'] 965 | for rule in smartRules: 966 | if rule['type'] not in alert_keys: 967 | raise Exception('malformed smart alert smartRules found.') 968 | 969 | try: 970 | stock_data = self.get_tradable(stock)['data'][0] 971 | data = { 972 | 'regionId': stock_data['regionId'], 973 | 'tickerType': stock_data['type'], 974 | 'tickerId': stock_data['tickerId'], 975 | 'tickerSymbol': stock, 976 | 'disSymbol': stock, 977 | 'tinyName': stock_data['name'], 978 | 'tickerName': stock_data['name'], 979 | 'exchangeCode': stock_data['exchangeCode'], 980 | 'showCode': stock_data['disExchangeCode'], 981 | 'disExchangeCode': stock_data['disExchangeCode'], 982 | 'eventWarningInput': { 983 | 'tickerId': stock_data['tickerId'], 984 | 'rules': smartRules, 985 | 'remove': False, 986 | 'del': False 987 | }, 988 | 'warningInput': { 989 | 'warningFrequency': frequency, 990 | 'warningInterval': interval, 991 | 'rules': priceRules, 992 | 'tickerId': stock_data['tickerId'] 993 | } 994 | } 995 | except Exception as e: 996 | print(f'failed to build alerts_add payload data. error: {e}') 997 | 998 | response = requests.post(self._urls.add_alert(), json=data, headers=headers, timeout=self.timeout) 999 | if response.status_code != 200: 1000 | raise Exception('alerts_add failed', response.status_code, response.reason) 1001 | return True 1002 | 1003 | def active_gainer_loser(self, direction='gainer', rank_type='afterMarket', count=50) : 1004 | ''' 1005 | gets gainer / loser / active stocks sorted by change 1006 | direction: gainer / loser / active 1007 | rank_type: preMarket / afterMarket / 5min / 1d / 5d / 1m / 3m / 52w (gainer/loser) 1008 | volume / turnoverRatio / range (active) 1009 | ''' 1010 | headers = self.build_req_headers() 1011 | 1012 | response = requests.get(self._urls.active_gainers_losers(direction, self._region_code, rank_type, count), headers=headers, timeout=self.timeout) 1013 | result = response.json() 1014 | 1015 | return result 1016 | 1017 | def run_screener(self, region=None, price_lte=None, price_gte=None, pct_chg_gte=None, pct_chg_lte=None, sort=None, 1018 | sort_dir=None, vol_lte=None, vol_gte=None): 1019 | ''' 1020 | Notice the fact that endpoints are reversed on lte and gte, but this function makes it work correctly 1021 | Also screeners are not sent by name, just the parameters are sent 1022 | example: run_screener( price_lte=.10, price_gte=5, pct_chg_lte=.035, pct_chg_gte=.51) 1023 | just a start, add more as you need it 1024 | ''' 1025 | 1026 | jdict = collections.defaultdict(dict) 1027 | jdict['fetch'] = 200 1028 | jdict['rules'] = collections.defaultdict(str) 1029 | jdict['sort'] = collections.defaultdict(str) 1030 | jdict['attach'] = {'hkexPrivilege': 'true'} #unknown meaning, was in network trace 1031 | 1032 | jdict['rules']['wlas.screener.rule.region'] = 'securities.region.name.6' 1033 | if not price_lte is None and not price_gte is None: 1034 | # lte and gte are backwards 1035 | jdict['rules']['wlas.screener.rule.price'] = 'gte=' + str(price_lte) + '<e=' + str(price_gte) 1036 | 1037 | if not vol_lte is None and not vol_gte is None: 1038 | # lte and gte are backwards 1039 | jdict['rules']['wlas.screener.rule.volume'] = 'gte=' + str(vol_lte) + '<e=' + str(vol_gte) 1040 | 1041 | if not pct_chg_lte is None and not pct_chg_gte is None: 1042 | # lte and gte are backwards 1043 | jdict['rules']['wlas.screener.rule.changeRatio'] = 'gte=' + str(pct_chg_lte) + '<e=' + str(pct_chg_gte) 1044 | 1045 | if sort is None: 1046 | jdict['sort']['rule'] = 'wlas.screener.rule.price' 1047 | if sort_dir is None: 1048 | jdict['sort']['desc'] = 'true' 1049 | 1050 | # jdict = self._ddict2dict(jdict) 1051 | response = requests.post(self._urls.screener(), json=jdict, timeout=self.timeout) 1052 | result = response.json() 1053 | return result 1054 | 1055 | def get_analysis(self, stock=None): 1056 | ''' 1057 | get analysis info and returns a dict of analysis ratings 1058 | ''' 1059 | headers = self.build_req_headers() 1060 | return requests.get(self._urls.analysis(self.get_ticker(stock)), headers=headers, timeout=self.timeout).json() 1061 | 1062 | def get_capital_flow(self, stock=None, tId=None, show_hist=True): 1063 | ''' 1064 | get capital flow 1065 | :param stock: 1066 | :param tId: 1067 | :param show_hist: 1068 | :return: list of capital flow 1069 | ''' 1070 | headers = self.build_req_headers() 1071 | if not tId is None: 1072 | pass 1073 | elif not stock is None: 1074 | tId = self.get_ticker(stock) 1075 | else: 1076 | raise ValueError('Must provide a stock symbol or a stock id') 1077 | return requests.get(self._urls.analysis_capital_flow(tId, show_hist), headers=headers, timeout=self.timeout).json() 1078 | 1079 | def get_etf_holding(self, stock=None, tId=None, has_num=0, count=50): 1080 | ''' 1081 | get ETF holdings by stock 1082 | :param stock: 1083 | :param tId: 1084 | :param has_num: 1085 | :param count: 1086 | :return: list of ETF holdings 1087 | ''' 1088 | headers = self.build_req_headers() 1089 | if not tId is None: 1090 | pass 1091 | elif not stock is None: 1092 | tId = self.get_ticker(stock) 1093 | else: 1094 | raise ValueError('Must provide a stock symbol or a stock id') 1095 | return requests.get(self._urls.analysis_etf_holding(tId, has_num, count), headers=headers, timeout=self.timeout).json() 1096 | 1097 | def get_institutional_holding(self, stock=None, tId=None): 1098 | ''' 1099 | get institutional holdings 1100 | :param stock: 1101 | :param tId: 1102 | :return: list of institutional holdings 1103 | ''' 1104 | headers = self.build_req_headers() 1105 | if not tId is None: 1106 | pass 1107 | elif not stock is None: 1108 | tId = self.get_ticker(stock) 1109 | else: 1110 | raise ValueError('Must provide a stock symbol or a stock id') 1111 | return requests.get(self._urls.analysis_institutional_holding(tId), headers=headers, timeout=self.timeout).json() 1112 | 1113 | def get_short_interest(self, stock=None, tId=None): 1114 | ''' 1115 | get short interest 1116 | :param stock: 1117 | :param tId: 1118 | :return: list of short interest 1119 | ''' 1120 | headers = self.build_req_headers() 1121 | if not tId is None: 1122 | pass 1123 | elif not stock is None: 1124 | tId = self.get_ticker(stock) 1125 | else: 1126 | raise ValueError('Must provide a stock symbol or a stock id') 1127 | return requests.get(self._urls.analysis_shortinterest(tId), headers=headers, timeout=self.timeout).json() 1128 | 1129 | def get_financials(self, stock=None): 1130 | ''' 1131 | get financials info and returns a dict of financial info 1132 | ''' 1133 | headers = self.build_req_headers() 1134 | return requests.get(self._urls.fundamentals(self.get_ticker(stock)), headers=headers, timeout=self.timeout).json() 1135 | 1136 | def get_news(self, stock=None, tId=None, Id=0, items=20): 1137 | ''' 1138 | get news and returns a list of articles 1139 | params: 1140 | Id: 0 is latest news article 1141 | items: number of articles to return 1142 | ''' 1143 | headers = self.build_req_headers() 1144 | if not tId is None: 1145 | pass 1146 | elif not stock is None: 1147 | tId = self.get_ticker(stock) 1148 | else: 1149 | raise ValueError('Must provide a stock symbol or a stock id') 1150 | return requests.get(self._urls.news(tId, Id, items), headers=headers, timeout=self.timeout).json() 1151 | 1152 | def get_bars(self, stock=None, tId=None, interval='m1', count=1, extendTrading=0, timeStamp=None): 1153 | ''' 1154 | get bars returns a pandas dataframe 1155 | params: 1156 | interval: m1, m5, m15, m30, h1, h2, h4, d1, w1 1157 | count: number of bars to return 1158 | extendTrading: change to 1 for pre-market and afterhours bars 1159 | timeStamp: If epoc timestamp is provided, return bar count up to timestamp. If not set default to current time. 1160 | ''' 1161 | headers = self.build_req_headers() 1162 | if not tId is None: 1163 | pass 1164 | elif not stock is None: 1165 | tId = self.get_ticker(stock) 1166 | else: 1167 | raise ValueError('Must provide a stock symbol or a stock id') 1168 | 1169 | if timeStamp is None: 1170 | # if not set, default to current time 1171 | timeStamp = int(time.time()) 1172 | 1173 | params = {'extendTrading': extendTrading} 1174 | df = DataFrame(columns=['open', 'high', 'low', 'close', 'volume', 'vwap']) 1175 | df.index.name = 'timestamp' 1176 | response = requests.get( 1177 | self._urls.bars(tId, interval, count, timeStamp), 1178 | params=params, 1179 | headers=headers, 1180 | timeout=self.timeout, 1181 | ) 1182 | result = response.json() 1183 | time_zone = timezone(result[0]['timeZone']) 1184 | for row in result[0]['data']: 1185 | row = row.split(',') 1186 | row = ['0' if value == 'null' else value for value in row] 1187 | data = { 1188 | 'open': float(row[1]), 1189 | 'high': float(row[3]), 1190 | 'low': float(row[4]), 1191 | 'close': float(row[2]), 1192 | 'volume': float(row[6]), 1193 | 'vwap': float(row[7]) 1194 | } 1195 | #convert to a panda datetime64 which has extra features like floor and resample 1196 | df.loc[to_datetime(datetime.fromtimestamp(int(row[0])).astimezone(time_zone))] = data 1197 | return df.iloc[::-1] 1198 | 1199 | def get_bars_crypto(self, stock=None, tId=None, interval='m1', count=1, extendTrading=0, timeStamp=None): 1200 | ''' 1201 | get bars returns a pandas dataframe 1202 | params: 1203 | interval: m1, m5, m15, m30, h1, h2, h4, d1, w1 1204 | count: number of bars to return 1205 | extendTrading: change to 1 for pre-market and afterhours bars 1206 | timeStamp: If epoc timestamp is provided, return bar count up to timestamp. If not set default to current time. 1207 | ''' 1208 | headers = self.build_req_headers() 1209 | if not tId is None: 1210 | pass 1211 | elif not stock is None: 1212 | tId = self.get_ticker(stock) 1213 | else: 1214 | raise ValueError('Must provide a stock symbol or a stock id') 1215 | 1216 | params = {'type': interval, 'count': count, 'extendTrading': extendTrading, 'timestamp': timeStamp} 1217 | df = DataFrame(columns=['open', 'high', 'low', 'close', 'volume', 'vwap']) 1218 | df.index.name = 'timestamp' 1219 | response = requests.get(self._urls.bars_crypto(tId), params=params, headers=headers, timeout=self.timeout) 1220 | result = response.json() 1221 | time_zone = timezone(result[0]['timeZone']) 1222 | for row in result[0]['data']: 1223 | row = row.split(',') 1224 | row = ['0' if value == 'null' else value for value in row] 1225 | data = { 1226 | 'open': float(row[1]), 1227 | 'high': float(row[3]), 1228 | 'low': float(row[4]), 1229 | 'close': float(row[2]), 1230 | 'volume': float(row[6]), 1231 | 'vwap': float(row[7]) 1232 | } 1233 | #convert to a panda datetime64 which has extra features like floor and resample 1234 | df.loc[to_datetime(datetime.fromtimestamp(int(row[0])).astimezone(time_zone))] = data 1235 | return df.iloc[::-1] 1236 | 1237 | def get_options_bars(self, derivativeId=None, interval='1m', count=1, direction=1, timeStamp=None): 1238 | ''' 1239 | get bars returns a pandas dataframe 1240 | params: 1241 | derivativeId: to be obtained from option chain, eg option_chain[0]['call']['tickerId'] 1242 | interval: 1m, 5m, 30m, 60m, 1d 1243 | count: number of bars to return 1244 | direction: 1 ignores {count} parameter & returns all bars on and after timestamp 1245 | setting any other value will ignore timestamp & return latest {count} bars 1246 | timeStamp: If epoc timestamp is provided, return bar count up to timestamp. If not set default to current time. 1247 | ''' 1248 | headers = self.build_req_headers() 1249 | if derivativeId is None: 1250 | raise ValueError('Must provide a derivative ID') 1251 | 1252 | params = {'type': interval, 'count': count, 'direction': direction, 'timestamp': timeStamp} 1253 | df = DataFrame(columns=['open', 'high', 'low', 'close', 'volume', 'vwap']) 1254 | df.index.name = 'timestamp' 1255 | response = requests.get(self._urls.options_bars(derivativeId), params=params, headers=headers, timeout=self.timeout) 1256 | result = response.json() 1257 | time_zone = timezone(result[0]['timeZone']) 1258 | for row in result[0]['data'] : 1259 | row = row.split(',') 1260 | row = ['0' if value == 'null' else value for value in row] 1261 | data = { 1262 | 'open': float(row[1]), 1263 | 'high': float(row[3]), 1264 | 'low': float(row[4]), 1265 | 'close': float(row[2]), 1266 | 'volume': float(row[6]), 1267 | 'vwap': float(row[7]) 1268 | } 1269 | #convert to a panda datetime64 which has extra features like floor and resample 1270 | df.loc[to_datetime(datetime.fromtimestamp(int(row[0])).astimezone(time_zone))] = data 1271 | return df.iloc[::-1] 1272 | 1273 | def get_chart_data(self, stock=None, tId=None, ma=5, timestamp=None): 1274 | bars = self.get_bars(stock=stock, tId=tId, interval='d1', count=1200, timestamp=timestamp) 1275 | ma_data = bars['close'].rolling(ma).mean() 1276 | return ma_data.dropna() 1277 | 1278 | def get_calendar(self, stock=None, tId=None): 1279 | ''' 1280 | There doesn't seem to be a way to get the times the market is open outside of the charts. 1281 | So, best way to tell if the market is open is to pass in a popular stock like AAPL then 1282 | and see the open and close hours as would be marked on the chart 1283 | and see if the last trade date is the same day as today's date 1284 | :param stock: 1285 | :param tId: 1286 | :return: dict of 'market open', 'market close', 'last trade date' 1287 | ''' 1288 | headers = self.build_req_headers() 1289 | if not tId is None: 1290 | pass 1291 | elif not stock is None: 1292 | tId = self.get_ticker(stock) 1293 | else: 1294 | raise ValueError('Must provide a stock symbol or a stock id') 1295 | 1296 | params = {'type': 'm1', 'count': 1, 'extendTrading': 0} 1297 | response = requests.get(self._urls.bars(tId), params=params, headers=headers, timeout=self.timeout) 1298 | result = response.json() 1299 | time_zone = timezone(result[0]['timeZone']) 1300 | last_trade_date = datetime.fromtimestamp(int(result[0]['data'][0].split(',')[0])).astimezone(time_zone) 1301 | today = datetime.today().astimezone() #use no time zone to have it pull in local time zone 1302 | 1303 | if last_trade_date.date() < today.date(): 1304 | # don't know what today's open and close times are, since no trade for today yet 1305 | return {'market open': None, 'market close': None, 'trading day': False} 1306 | 1307 | for d in result[0]['dates']: 1308 | if d['type'] == 'T': 1309 | market_open = today.replace( 1310 | hour=int(d['start'].split(':')[0]), 1311 | minute=int(d['start'].split(':')[1]), 1312 | second=0) 1313 | market_open -= timedelta(microseconds=market_open.microsecond) 1314 | market_open = market_open.astimezone(time_zone) #set to market timezone 1315 | 1316 | market_close = today.replace( 1317 | hour=int(d['end'].split(':')[0]), 1318 | minute=int(d['end'].split(':')[1]), 1319 | second=0) 1320 | market_close -= timedelta(microseconds=market_close.microsecond) 1321 | market_close = market_close.astimezone(time_zone) #set to market timezone 1322 | 1323 | #this implies that we have waited a few minutes from the open before trading 1324 | return {'market open': market_open , 'market close':market_close, 'trading day':True} 1325 | #otherwise 1326 | return None 1327 | 1328 | def get_dividends(self): 1329 | ''' Return account's incoming dividend info ''' 1330 | headers = self.build_req_headers() 1331 | data = {} 1332 | response = requests.post(self._urls.dividends(self._account_id), json=data, headers=headers, timeout=self.timeout) 1333 | return response.json() 1334 | 1335 | def get_five_min_ranking(self, extendTrading=0): 1336 | ''' 1337 | get 5 minute trend ranking 1338 | ''' 1339 | rank = [] 1340 | headers = self.build_req_headers() 1341 | params = {'regionId': self._region_code, 'userRegionId': self._region_code, 'platform': 'pc', 'limitCards': 'latestActivityPc'} 1342 | response = requests.get(self._urls.rankings(), params=params, headers=headers, timeout=self.timeout) 1343 | result = response.json()[0].get('data') 1344 | if extendTrading: 1345 | for data in result: 1346 | if data['id'] == 'latestActivityPc.faList': 1347 | rank = data['data'] 1348 | else: 1349 | for data in result: 1350 | if data['id'] == 'latestActivityPc.5minutes': 1351 | rank = data['data'] 1352 | return rank 1353 | 1354 | def get_watchlists(self, as_list_symbols=False) : 1355 | """ 1356 | get user watchlists 1357 | """ 1358 | headers = self.build_req_headers() 1359 | params = {'version': 0} 1360 | response = requests.get(self._urls.portfolio_lists(), params=params, headers=headers, timeout=self.timeout) 1361 | 1362 | if not as_list_symbols : 1363 | return response.json()['portfolioList'] 1364 | else: 1365 | list_ticker = response.json()['portfolioList'][0].get('tickerList') 1366 | return list(map(lambda x: x.get('symbol'), list_ticker)) 1367 | 1368 | def get_account_type(self, username='') : 1369 | try: 1370 | validate_email(username) 1371 | account_type = 2 # email 1372 | except EmailNotValidError as _e: 1373 | account_type = 1 # phone 1374 | 1375 | return account_type 1376 | 1377 | def is_logged_in(self): 1378 | ''' 1379 | Check if login session is active 1380 | ''' 1381 | try: 1382 | self.get_account_id() 1383 | except KeyError: 1384 | return False 1385 | else: 1386 | return True 1387 | 1388 | def get_press_releases(self, stock=None, tId=None, typeIds=None, num=50): 1389 | ''' 1390 | gets press releases, useful for getting past earning reports 1391 | typeIds: None (all) or comma-separated string of the following: '101' (financials) / '104' (insiders) 1392 | it's possible they add more announcment types in the future, so check the 'announcementTypes' 1393 | field on the response to verify you have the typeId you want 1394 | ''' 1395 | if not tId is None: 1396 | pass 1397 | elif not stock is None: 1398 | tId = self.get_ticker(stock) 1399 | else: 1400 | raise ValueError('Must provide a stock symbol or a stock id') 1401 | headers = self.build_req_headers() 1402 | response = requests.get(self._urls.press_releases(tId, typeIds, num), headers=headers, timeout=self.timeout) 1403 | result = response.json() 1404 | 1405 | return result 1406 | 1407 | def get_calendar_events(self, event, start_date=None, page=1, num=50): 1408 | ''' 1409 | gets calendar events 1410 | event: 'earnings' / 'dividend' / 'splits' 1411 | start_date: in `YYYY-MM-DD` format, today if None 1412 | ''' 1413 | if start_date is None: 1414 | start_date = datetime.today().strftime('%Y-%m-%d') 1415 | headers = self.build_req_headers() 1416 | response = requests.get(self._urls.calendar_events(event, self._region_code, start_date, page, num), headers=headers, timeout=self.timeout) 1417 | result = response.json() 1418 | 1419 | return result 1420 | 1421 | ''' Paper support ''' 1422 | class paper_webull(webull): 1423 | 1424 | def __init__(self): 1425 | super().__init__() 1426 | 1427 | def get_account(self): 1428 | ''' Get important details of paper account ''' 1429 | headers = self.build_req_headers() 1430 | response = requests.get(self._urls.paper_account(self._account_id), headers=headers, timeout=self.timeout) 1431 | return response.json() 1432 | 1433 | def get_account_id(self): 1434 | ''' Get paper account id: call this before paper account actions''' 1435 | headers = self.build_req_headers() 1436 | response = requests.get(self._urls.paper_account_id(), headers=headers, timeout=self.timeout) 1437 | result = response.json() 1438 | if result is not None and len(result) > 0 and 'id' in result[0]: 1439 | id = result[0]['id'] 1440 | self._account_id = id 1441 | return id 1442 | else: 1443 | return None 1444 | 1445 | def get_current_orders(self): 1446 | ''' Open paper trading orders ''' 1447 | return self.get_account()['openOrders'] 1448 | 1449 | def get_history_orders(self, status='Cancelled', count=20): 1450 | headers = self.build_req_headers(include_trade_token=True, include_time=True) 1451 | response = requests.get(self._urls.paper_orders(self._account_id, count) + str(status), headers=headers, timeout=self.timeout) 1452 | return response.json() 1453 | 1454 | def get_positions(self): 1455 | ''' Current positions in paper trading account. ''' 1456 | return self.get_account()['positions'] 1457 | 1458 | def place_order(self, stock=None, tId=None, price=0, action='BUY', orderType='LMT', enforce='GTC', quant=0, outsideRegularTradingHour=True): 1459 | ''' Place a paper account order. ''' 1460 | if not tId is None: 1461 | pass 1462 | elif not stock is None: 1463 | tId = self.get_ticker(stock) 1464 | else: 1465 | raise ValueError('Must provide a stock symbol or a stock id') 1466 | 1467 | headers = self.build_req_headers(include_trade_token=True, include_time=True) 1468 | 1469 | data = { 1470 | 'action': action, # BUY or SELL 1471 | 'lmtPrice': float(price), 1472 | 'orderType': orderType, # 'LMT','MKT' 1473 | 'outsideRegularTradingHour': outsideRegularTradingHour, 1474 | 'quantity': int(quant), 1475 | 'serialId': str(uuid.uuid4()), 1476 | 'tickerId': tId, 1477 | 'timeInForce': enforce # GTC or DAY 1478 | } 1479 | 1480 | #Market orders do not support extended hours trading. 1481 | if orderType == 'MKT': 1482 | data['outsideRegularTradingHour'] = False 1483 | 1484 | response = requests.post(self._urls.paper_place_order(self._account_id, tId), json=data, headers=headers, timeout=self.timeout) 1485 | return response.json() 1486 | 1487 | def modify_order(self, order, price=0, action='BUY', orderType='LMT', enforce='GTC', quant=0, outsideRegularTradingHour=True): 1488 | ''' Modify a paper account order. ''' 1489 | headers = self.build_req_headers() 1490 | 1491 | data = { 1492 | 'action': action, # BUY or SELL 1493 | 'lmtPrice': float(price), 1494 | 'orderType':orderType, 1495 | 'comboType': 'NORMAL', # 'LMT','MKT' 1496 | 'outsideRegularTradingHour': outsideRegularTradingHour, 1497 | 'serialId': str(uuid.uuid4()), 1498 | 'tickerId': order['ticker']['tickerId'], 1499 | 'timeInForce': enforce # GTC or DAY 1500 | } 1501 | 1502 | if quant == 0 or quant == order['totalQuantity']: 1503 | data['quantity'] = order['totalQuantity'] 1504 | else: 1505 | data['quantity'] = int(quant) 1506 | 1507 | response = requests.post(self._urls.paper_modify_order(self._account_id, order['orderId']), json=data, headers=headers, timeout=self.timeout) 1508 | if response: 1509 | return True 1510 | else: 1511 | print("Modify didn't succeed. {} {}".format(response, response.json())) 1512 | return False 1513 | 1514 | def cancel_order(self, order_id): 1515 | ''' Cancel a paper account order. ''' 1516 | headers = self.build_req_headers() 1517 | response = requests.post(self._urls.paper_cancel_order(self._account_id, order_id), headers=headers, timeout=self.timeout) 1518 | return bool(response) 1519 | 1520 | def get_social_posts(self, topic, num=100): 1521 | headers = self.build_req_headers() 1522 | 1523 | response = requests.get(self._urls.social_posts(topic, num), headers=headers, timeout=self.timeout) 1524 | result = response.json() 1525 | return result 1526 | 1527 | 1528 | def get_social_home(self, topic, num=100): 1529 | headers = self.build_req_headers() 1530 | 1531 | response = requests.get(self._urls.social_home(topic, num), headers=headers, timeout=self.timeout) 1532 | result = response.json() 1533 | return result 1534 | 1535 | if __name__ == '__main__': 1536 | parser = argparse.ArgumentParser(description='Interface with Webull. Paper trading is not the default.') 1537 | parser.add_argument('-p', '--use-paper', help='Use paper account instead.', action='store_true') 1538 | args = parser.parse_args() 1539 | 1540 | if args.use_paper: 1541 | wb = paper_webull() 1542 | else: 1543 | wb = webull() 1544 | --------------------------------------------------------------------------------