├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── looking-for-help--.md └── workflows │ └── python.yml ├── .gitignore ├── .readthedocs.yml ├── CONTRIBUTING.rst ├── LICENSE ├── Makefile ├── Makefile.sphinx ├── README.rst ├── bin ├── tda-generate-token.py └── tda-order-codegen.py ├── docs ├── _static │ ├── .empty │ ├── attempted-unauth-access.png │ ├── discord-logo.png │ ├── github-logo.png │ ├── patreon.png │ └── sp500.txt ├── auth.rst ├── client.rst ├── conf.py ├── contrib.rst ├── contributing.rst ├── example.rst ├── getting-started.rst ├── help.rst ├── index.rst ├── order-builder.rst ├── order-templates.rst ├── schwab.rst ├── streaming.rst └── util.rst ├── examples ├── async │ └── get_quote.py ├── birthday_dividends.py ├── client_from_access_functions.py ├── get_quote.py └── streaming │ └── timesales.py ├── readthedocs.yml ├── scripts ├── functional_tests.py └── price_history_combos.py ├── setup.cfg ├── setup.py ├── tda ├── __init__.py ├── auth.py ├── client │ ├── __init__.py │ ├── asynchronous.py │ ├── base.py │ └── synchronous.py ├── contrib │ ├── __init__.py │ ├── orders.py │ └── util.py ├── debug.py ├── orders │ ├── __init__.py │ ├── common.py │ ├── equities.py │ ├── generic.py │ └── options.py ├── scripts │ ├── __init__.py │ └── orders_codegen.py ├── streaming.py ├── utils.py └── version.py ├── tests ├── __init__.py ├── auth_test.py ├── client_test.py ├── contrib │ ├── __init__.py │ ├── orders_test.py │ └── util_test.py ├── debug_test.py ├── orders │ ├── __init__.py │ ├── common_test.py │ ├── generic_test.py │ └── options_test.py ├── orders_test.py ├── scripts │ ├── __init__.py │ └── orders_codegen_test.py ├── streaming_test.py ├── testdata │ └── principals.json ├── token_lifecycle_test.py ├── utils.py └── utils_test.py └── tox.ini /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: TDAAPI 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: 'Report a bug ' 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Please read the [bug submission guidelines](https://tda-api.readthedocs.io/en/latest/help.html) before submitting a bug. 11 | 12 | Not following guidelines may result in your bug being ignored and/or closed. 13 | 14 | **Description of Bug** 15 | 16 | 17 | **Code to Reproduce** 18 | Paste in the code that causes the bug to occur. 19 | 20 | IMPORTANT: Remember to anonymize your code. Be sure to replace API keys/Client IDs with placeholders. Also, never, ever share the contents of your token file. 21 | 22 | **Expected Behavior** 23 | 24 | 25 | **Actual Behavior** 26 | 27 | 28 | **Error/Exception Log, If Applicable** 29 | See here to learn how to turn on debug logging: https://tda-api.readthedocs.io/en/latest/help.html 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/looking-for-help--.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Looking for Help? ' 3 | about: 'Get help on our Discord server: https://discord.gg/M3vjtHj' 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Issues are not the place to ask for help. You can ask for help on our Discord server: 11 | 12 | https://discord.gg/M3vjtHj 13 | 14 | Issues filed asking for help will be ignored/closed. 15 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | strategy: 10 | fail-fast: false 11 | max-parallel: 3 12 | matrix: 13 | platform: [ubuntu-latest, macos-latest, windows-latest] 14 | python: 15 | - version: "3.7" 16 | toxenv: py37 17 | - version: "3.8" 18 | toxenv: py38 19 | - version: "3.9" 20 | toxenv: py39 21 | - version: "3.10" 22 | toxenv: py310 23 | runs-on: ${{ matrix.platform }} 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Set up Python ${{ matrix.python.version }} 28 | uses: actions/setup-python@v2 29 | with: 30 | python-version: ${{ matrix.python.version }} 31 | 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install tox 36 | pip install ".[dev]" 37 | 38 | - name: Test with tox ${{ matrix.python.toxenv }} 39 | env: 40 | TOXENV: ${{ matrix.python.toxenv }} 41 | run: tox 42 | 43 | - name: Report coverage 44 | if: matrix.python.version == '3.8' 45 | run: | 46 | coverage combine 47 | coverage report 48 | coverage xml 49 | 50 | - name: Upload coverage to Codecov 51 | if: matrix.python.version == '3.8' 52 | uses: codecov/codecov-action@v1.0.5 53 | with: 54 | token: ${{ secrets.CODECOV_TOKEN }} 55 | file: ./coverage.xml 56 | flags: unittests 57 | name: GitHub 58 | docs: 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v2 62 | - name: Set up Python ${{ matrix.python.version }} 63 | uses: actions/setup-python@v2 64 | with: 65 | python-version: 3.8 66 | - name: Install dependencies 67 | run: | 68 | python -m pip install --upgrade pip 69 | pip install ".[dev]" 70 | - name: Build Docs 71 | run: | 72 | make -f Makefile.sphinx html 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | .idea 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ 126 | 127 | # Swap 128 | [._]*.s[a-v][a-z] 129 | !*.svg # comment out if you don't need vector files 130 | [._]*.sw[a-p] 131 | [._]s[a-rt-v][a-z] 132 | [._]ss[a-gi-z] 133 | [._]sw[a-p] 134 | 135 | # Session 136 | Session.vim 137 | Sessionx.vim 138 | 139 | # Temporary 140 | .netrwhist 141 | *~ 142 | # Auto-generated tag files 143 | tags 144 | # Persistent undo 145 | [._]*.un~ 146 | 147 | .DS_Store 148 | virtualenv 149 | 150 | docs-build/ 151 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Build documentation with MkDocs 13 | #mkdocs: 14 | # configuration: mkdocs.yml 15 | 16 | # Optionally build your docs in additional formats such as PDF and ePub 17 | formats: all 18 | 19 | # Optionally set the version of Python and requirements required to build your docs 20 | python: 21 | version: 3.7 22 | install: 23 | - requirements: requirements.txt 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | Contributing to ``tda-api`` 3 | =========================== 4 | 5 | Fixing a bug? Adding a feature? Just cleaning up for the sake of cleaning up? 6 | Great! No improvement is too small for me, and I'm always happy to take pull 7 | requests. Read this guide to learn how to set up your environment so you can 8 | contribute. 9 | 10 | ------------------------------ 11 | Setting up the Dev Environment 12 | ------------------------------ 13 | 14 | Dependencies are listed in the `requirements.txt` file. These development 15 | requirements are distinct from the requirements listed in `setup.py` and include 16 | some additional packages around testing, documentation generation, etc. 17 | 18 | Before you install anything, I highly recommend setting up a `virtualenv` so you 19 | don't pollute your system installation directories: 20 | 21 | .. code-block:: shell 22 | 23 | pip install virtualenv 24 | virtualenv -v virtualenv 25 | source virtualenv/bin/activate 26 | 27 | Next, install project requirements: 28 | 29 | .. code-block:: shell 30 | 31 | pip install ".[dev]" 32 | 33 | Finally, verify everything works by running tests: 34 | 35 | .. code-block:: shell 36 | 37 | make test 38 | 39 | At this point you can make your changes. 40 | 41 | Note that if you are using a virtual environment and switch to a new terminal 42 | your virtual environment will not be active in the new terminal, 43 | and you need to run the activate command again. 44 | If you want to disable the loaded virtual environment in the same terminal window, 45 | use the command: 46 | 47 | .. code-block:: shell 48 | 49 | deactivate 50 | 51 | ---------------------- 52 | Development Guidelines 53 | ---------------------- 54 | 55 | +++++++++++++++++ 56 | Test your changes 57 | +++++++++++++++++ 58 | 59 | This project aims for high test coverage. All changes must be properly tested, 60 | and we will accept no PRs that lack appropriate unit testing. We also expect 61 | existing tests to pass. You can run your tests using: 62 | 63 | .. code-block:: shell 64 | 65 | make test 66 | 67 | ++++++++++++++++++ 68 | Document your code 69 | ++++++++++++++++++ 70 | 71 | Documentation is how users learn to use your code, and no feature is complete 72 | without a full description of how to use it. If your PR changes external-facing 73 | interfaces, or if it alters semantics, the changes must be thoroughly described 74 | in the docstrings of the affected components. If your change adds a substantial 75 | new module, a new section in the documentation may be justified. 76 | 77 | Documentation is built using `Sphinx `__. 78 | You can build the documentation using the `Makefile.sphinx` makefile. For 79 | example you can build the HTML documentation like so: 80 | 81 | .. code-block:: shell 82 | 83 | make -f Makefile.sphinx 84 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Alexander Golec 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 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | python3 -m nose $(NOSE_ARGS) 3 | 4 | fix: 5 | autopep8 --in-place -r -a tda 6 | autopep8 --in-place -r -a tests 7 | autopep8 --in-place -r -a examples 8 | 9 | coverage: 10 | python3 -m coverage run --source=tda -m nose 11 | python3 -m coverage html 12 | 13 | dist: clean 14 | python3 setup.py sdist bdist_wheel 15 | 16 | release: clean test dist 17 | python3 -m twine upload dist/* 18 | 19 | clean: 20 | rm -rf build dist docs-build tda_api.egg-info __pycache__ htmlcov 21 | -------------------------------------------------------------------------------- /Makefile.sphinx: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?=-W 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = docs 9 | BUILDDIR = docs-build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile.sphinx 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | TDAmeritrade is now defunct 2 | =========================== 3 | 4 | On May 10th, 2024, Charles Schwab officially turned down the TDAmeritrade 5 | service and migrated its last batch of customers to Charles Schwab. With this 6 | change, ``tda-api`` and the API that backed it stopped working. 7 | 8 | Fortunately, a new API is available, and it's quite similar to ``tda-api``. 9 | We've been hard at work on ``schwab-py``, a replacement for ``tda-api``. Here is 10 | the `documentation, complete with getting started and migration 11 | guides `__. If you have any 12 | questions, please ask them on our `Discord 13 | server `__ 14 | 15 | The old ``tda-api`` project is now defunct, and this repo is provided for 16 | archival purposes only. 17 | 18 | 19 | ``tda-api``: A TD Ameritrade API Wrapper 20 | ---------------------------------------- 21 | 22 | .. image:: https://img.shields.io/discord/720378361880248621.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2 23 | :target: https://discord.gg/BEr6y6Xqyv 24 | 25 | .. image:: https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fshieldsio-patreon.vercel.app%2Fapi%3Fusername%3Dalexgolec%26type%3Dpatrons&style=flat 26 | :target: https://patreon.com/TDAAPI 27 | 28 | .. image:: https://readthedocs.org/projects/tda-api/badge/?version=latest 29 | :target: https://tda-api.readthedocs.io/en/latest/?badge=latest 30 | 31 | .. image:: https://github.com/alexgolec/tda-api/workflows/tests/badge.svg 32 | :target: https://github.com/alexgolec/tda-api/actions?query=workflow%3Atests 33 | 34 | .. image:: https://badge.fury.io/py/tda-api.svg 35 | :target: https://badge.fury.io/py/tda-api 36 | 37 | .. image:: http://codecov.io/github/alexgolec/tda-api/coverage.svg?branch=master 38 | :target: http://codecov.io/github/alexgolec/tda-api?branch=master 39 | 40 | What is ``tda-api``? 41 | -------------------- 42 | 43 | ``tda-api`` is an unofficial wrapper around the `TD Ameritrade APIs 44 | `__. It strives to be as thin and 45 | unopinionated as possible, offering an elegant programmatic interface over each 46 | endpoint. Notable functionality includes: 47 | 48 | * Login and authentication 49 | * Quotes, fundamentals, and historical pricing data 50 | * Options chains 51 | * Streaming quotes and order book depth data 52 | * Trades and trade management 53 | * Account info and preferences 54 | 55 | How do I use ``tda-api``? 56 | ------------------------- 57 | 58 | For a full description of ``tda-api``'s functionality, check out the 59 | `documentation `__. Meawhile, here's 60 | a quick getting started guide: 61 | 62 | Before you do anything, create an account and an application on the 63 | `TD Ameritrade developer website `__. 64 | You'll receive an API key, also known as a Client Id, which you can pass to this 65 | wrapper. You'll also want to take note of your callback URI, as the login flow 66 | requires it. 67 | 68 | Next, install ``tda-api``: 69 | 70 | .. code-block:: python 71 | 72 | pip install tda-api 73 | 74 | You're good to go! To demonstrate, here's how you can authenticate and fetch 75 | daily historical price data for the past twenty years: 76 | 77 | .. code-block:: python 78 | 79 | from tda import auth, client 80 | import json 81 | 82 | token_path = '/path/to/token.json' 83 | api_key = 'YOUR_API_KEY@AMER.OAUTHAP' 84 | redirect_uri = 'https://your.redirecturi.com' 85 | try: 86 | c = auth.client_from_token_file(token_path, api_key) 87 | except FileNotFoundError: 88 | from selenium import webdriver 89 | with webdriver.Chrome() as driver: 90 | c = auth.client_from_login_flow( 91 | driver, api_key, redirect_uri, token_path) 92 | 93 | r = c.get_price_history('AAPL', 94 | period_type=client.Client.PriceHistory.PeriodType.YEAR, 95 | period=client.Client.PriceHistory.Period.TWENTY_YEARS, 96 | frequency_type=client.Client.PriceHistory.FrequencyType.DAILY, 97 | frequency=client.Client.PriceHistory.Frequency.DAILY) 98 | assert r.status_code == 200, r.raise_for_status() 99 | print(json.dumps(r.json(), indent=4)) 100 | 101 | Why should I use ``tda-api``? 102 | ----------------------------- 103 | 104 | ``tda-api`` was designed to provide a few important pieces of functionality: 105 | 106 | 1. **Safe Authentication**: TD Ameritrade's API supports OAuth authentication, 107 | but too many people online end up rolling their own implementation of the 108 | OAuth callback flow. This is both unnecessarily complex and dangerous. 109 | ``tda-api`` handles token fetch and refreshing for you. 110 | 111 | 2. **Minimal API Wrapping**: Unlike some other API wrappers, which build in lots 112 | of logic and validation, ``tda-api`` takes raw values and returns raw 113 | responses, allowing you to interpret the complex API responses as you see 114 | fit. Anything you can do with raw HTTP requests you can do with ``tda-api``, 115 | only more easily. 116 | 117 | Why should I *not* use ``tda-api``? 118 | ----------------------------------- 119 | 120 | As excellent as TD Ameritrade's API is, there are a few popular features it does 121 | not offer: 122 | 123 | * Unfortunately, the TD Ameritrade API does not seem to expose any endpoints 124 | around the `papermoney `__ simulated trading product. ``tda-api`` can 126 | only be used to perform real trades using a TD Ameritrade account. Note: 127 | trades made through the API appear in thinkorswim and vice versa. 128 | * The API only supports trading in equities, mutual funds, ETFs, and options 129 | (both simple contracts and complex composite positions). Futures and futures 130 | options trading is not supported. Some data is provided for futures, but not 131 | for futures options. 132 | * Historical options pricing data is not available. 133 | 134 | What else? 135 | ---------- 136 | 137 | We have a `Discord server `__! You can join to 138 | get help using ``tda-api`` or just to chat with interesting people. 139 | 140 | Bug reports, suggestions, and patches are always welcome! Submit issues 141 | `here `__ and pull requests 142 | `here `__. 143 | 144 | ``tda-api`` is released under the 145 | `MIT license `__. 146 | 147 | **Disclaimer:** *tda-api is an unofficial API wrapper. It is in no way 148 | endorsed by or affiliated with TD Ameritrade or any associated organization. 149 | Make sure to read and understand the terms of service of the underlying API 150 | before using this package. This authors accept no responsibility for any 151 | damage that might stem from use of this package. See the LICENSE file for 152 | more details.* 153 | -------------------------------------------------------------------------------- /bin/tda-generate-token.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import atexit 4 | import sys 5 | 6 | import tda 7 | 8 | from selenium import webdriver 9 | from selenium.common.exceptions import WebDriverException 10 | 11 | def main(api_key, redirect_uri, token_path): 12 | driver = None 13 | 14 | # Chrome 15 | try: 16 | driver = webdriver.Chrome() 17 | except WebDriverException as e: 18 | print('Failed to open Chrome, continuing:', e) 19 | 20 | # Firefox 21 | if driver is None: 22 | try: 23 | driver = webdriver.Firefox() 24 | except WebDriverException as e: 25 | print('Failed to open Firefox, continuing:', e) 26 | 27 | # Safari 28 | if driver is None: 29 | try: 30 | driver = webdriver.Safari() 31 | except WebDriverException as e: 32 | print('Failed to open Safari, continuing:', e) 33 | 34 | # Edge 35 | if driver is None: 36 | try: 37 | from msedge.selenium_tools import Edge 38 | driver = Edge() 39 | except ImportError: 40 | print('Failed to open Edge. Install msedge-selenium-tools if you want '+ 41 | 'to use edge. Continuing.') 42 | except WebDriverException as e: 43 | print('Failed to open Edge, continuing:', e) 44 | 45 | # Internet Explorer 46 | if driver is None: 47 | try: 48 | driver = webdriver.Ie() 49 | except WebDriverException as e: 50 | print('Failed to open Internet Explorer, continuing:', e) 51 | 52 | if driver is None: 53 | print('Failed to open any webdriver. See here for help: ' + 54 | 'https://tda-api.readthedocs.io/en/latest/help.html') 55 | return -1 56 | 57 | try: 58 | with driver: 59 | client = tda.auth.client_from_login_flow( 60 | driver, api_key, redirect_uri, token_path) 61 | return 0 62 | except: 63 | print('Failed to fetch a token using a web browser, falling back to ' 64 | 'the manual flow') 65 | 66 | tda.auth.client_from_manual_flow(api_key, redirect_uri, token_path) 67 | 68 | return 0 69 | 70 | 71 | if __name__ == '__main__': 72 | parser = argparse.ArgumentParser( 73 | description='Fetch a new token and write it to a file') 74 | 75 | required = parser.add_argument_group('required arguments') 76 | required.add_argument( 77 | '--token_file', required=True, 78 | help='Path to token file. Any existing file will be overwritten') 79 | required.add_argument('--api_key', required=True) 80 | required.add_argument('--redirect_uri', required=True, type=str) 81 | 82 | args = parser.parse_args() 83 | 84 | sys.exit(main(args.api_key, args.redirect_uri, args.token_file)) 85 | -------------------------------------------------------------------------------- /bin/tda-order-codegen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from tda.scripts.orders_codegen import latest_order_main 3 | 4 | if __name__ == '__main__': 5 | import sys 6 | sys.exit(latest_order_main(sys.argv[1:])) 7 | -------------------------------------------------------------------------------- /docs/_static/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgolec/tda-api/d22076c14add202b711af793ad48b36208e1ff6c/docs/_static/.empty -------------------------------------------------------------------------------- /docs/_static/attempted-unauth-access.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgolec/tda-api/d22076c14add202b711af793ad48b36208e1ff6c/docs/_static/attempted-unauth-access.png -------------------------------------------------------------------------------- /docs/_static/discord-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgolec/tda-api/d22076c14add202b711af793ad48b36208e1ff6c/docs/_static/discord-logo.png -------------------------------------------------------------------------------- /docs/_static/github-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgolec/tda-api/d22076c14add202b711af793ad48b36208e1ff6c/docs/_static/github-logo.png -------------------------------------------------------------------------------- /docs/_static/patreon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgolec/tda-api/d22076c14add202b711af793ad48b36208e1ff6c/docs/_static/patreon.png -------------------------------------------------------------------------------- /docs/_static/sp500.txt: -------------------------------------------------------------------------------- 1 | A 2 | AAL 3 | AAP 4 | AAPL 5 | ABBV 6 | ABC 7 | ABMD 8 | ABT 9 | ACN 10 | ADBE 11 | ADI 12 | ADM 13 | ADP 14 | ADS 15 | ADSK 16 | AEE 17 | AEP 18 | AES 19 | AFL 20 | AGN 21 | AIG 22 | AIV 23 | AIZ 24 | AJG 25 | AKAM 26 | ALB 27 | ALGN 28 | ALK 29 | ALL 30 | ALLE 31 | ALXN 32 | AMAT 33 | AMCR 34 | AMD 35 | AME 36 | AMGN 37 | AMP 38 | AMT 39 | AMZN 40 | ANET 41 | ANSS 42 | ANTM 43 | AON 44 | AOS 45 | APA 46 | APD 47 | APH 48 | APTV 49 | ARE 50 | ARNC 51 | ATO 52 | ATVI 53 | AVB 54 | AVGO 55 | AVY 56 | AWK 57 | AXP 58 | AZO 59 | BA 60 | BAC 61 | BAX 62 | BBY 63 | BDX 64 | BEN 65 | BF.B 66 | BIIB 67 | BK 68 | BKNG 69 | BKR 70 | BLK 71 | BLL 72 | BMY 73 | BR 74 | BRK.B 75 | BSX 76 | BWA 77 | BXP 78 | C 79 | CAG 80 | CAH 81 | CAT 82 | CB 83 | CBRE 84 | CCI 85 | CCL 86 | CDNS 87 | CDW 88 | CE 89 | CERN 90 | CF 91 | CFG 92 | CHD 93 | CHRW 94 | CHTR 95 | CI 96 | CINF 97 | CL 98 | CLX 99 | CMA 100 | CMCSA 101 | CME 102 | CMG 103 | CMI 104 | CMS 105 | CNC 106 | CNP 107 | COF 108 | COG 109 | COO 110 | COP 111 | COST 112 | COTY 113 | CPB 114 | CPRI 115 | CPRT 116 | CRM 117 | CSCO 118 | CSX 119 | CTAS 120 | CTL 121 | CTSH 122 | CTVA 123 | CTXS 124 | CVS 125 | CVX 126 | CXO 127 | D 128 | DAL 129 | DD 130 | DE 131 | DFS 132 | DG 133 | DGX 134 | DHI 135 | DHR 136 | DIS 137 | DISCA 138 | DISCK 139 | DISH 140 | DLR 141 | DLTR 142 | DOV 143 | DOW 144 | DRE 145 | DRI 146 | DTE 147 | DUK 148 | DVA 149 | DVN 150 | DXC 151 | EA 152 | EBAY 153 | ECL 154 | ED 155 | EFX 156 | EIX 157 | EL 158 | EMN 159 | EMR 160 | EOG 161 | EQIX 162 | EQR 163 | ES 164 | ESS 165 | ETFC 166 | ETN 167 | ETR 168 | EVRG 169 | EW 170 | EXC 171 | EXPD 172 | EXPE 173 | EXR 174 | F 175 | FANG 176 | FAST 177 | FB 178 | FBHS 179 | FCX 180 | FDX 181 | FE 182 | FFIV 183 | FIS 184 | FISV 185 | FITB 186 | FLIR 187 | FLS 188 | FLT 189 | FMC 190 | FOX 191 | FOXA 192 | FRC 193 | FRT 194 | FTI 195 | FTNT 196 | FTV 197 | GD 198 | GE 199 | GILD 200 | GIS 201 | GL 202 | GLW 203 | GM 204 | GOOG 205 | GOOGL 206 | GPC 207 | GPN 208 | GPS 209 | GRMN 210 | GS 211 | GWW 212 | HAL 213 | HAS 214 | HBAN 215 | HBI 216 | HCA 217 | HD 218 | HES 219 | HFC 220 | HIG 221 | HII 222 | HLT 223 | HOG 224 | HOLX 225 | HON 226 | HP 227 | HPE 228 | HPQ 229 | HRB 230 | HRL 231 | HSIC 232 | HST 233 | HSY 234 | HUM 235 | IBM 236 | ICE 237 | IDXX 238 | IEX 239 | IFF 240 | ILMN 241 | INCY 242 | INFO 243 | INTC 244 | INTU 245 | IP 246 | IPG 247 | IPGP 248 | IQV 249 | IR 250 | IRM 251 | ISRG 252 | IT 253 | ITW 254 | IVZ 255 | J 256 | JBHT 257 | JCI 258 | JKHY 259 | JNJ 260 | JNPR 261 | JPM 262 | JWN 263 | K 264 | KEY 265 | KEYS 266 | KHC 267 | KIM 268 | KLAC 269 | KMB 270 | KMI 271 | KMX 272 | KO 273 | KR 274 | KSS 275 | KSU 276 | L 277 | LB 278 | LDOS 279 | LEG 280 | LEN 281 | LH 282 | LHX 283 | LIN 284 | LKQ 285 | LLY 286 | LMT 287 | LNC 288 | LNT 289 | LOW 290 | LRCX 291 | LUV 292 | LVS 293 | LW 294 | LYB 295 | LYV 296 | M 297 | MA 298 | MAA 299 | MAR 300 | MAS 301 | MCD 302 | MCHP 303 | MCK 304 | MCO 305 | MDLZ 306 | MDT 307 | MET 308 | MGM 309 | MHK 310 | MKC 311 | MKTX 312 | MLM 313 | MMC 314 | MMM 315 | MNST 316 | MO 317 | MOS 318 | MPC 319 | MRK 320 | MRO 321 | MS 322 | MSCI 323 | MSFT 324 | MSI 325 | MTB 326 | MTD 327 | MU 328 | MXIM 329 | MYL 330 | NBL 331 | NCLH 332 | NDAQ 333 | NEE 334 | NEM 335 | NFLX 336 | NI 337 | NKE 338 | NLOK 339 | NLSN 340 | NOC 341 | NOV 342 | NOW 343 | NRG 344 | NSC 345 | NTAP 346 | NTRS 347 | NUE 348 | NVDA 349 | NVR 350 | NWL 351 | NWS 352 | NWSA 353 | O 354 | ODFL 355 | OKE 356 | OMC 357 | ORCL 358 | ORLY 359 | OXY 360 | PAYC 361 | PAYX 362 | PBCT 363 | PCAR 364 | PEAK 365 | PEG 366 | PEP 367 | PFE 368 | PFG 369 | PG 370 | PGR 371 | PH 372 | PHM 373 | PKG 374 | PKI 375 | PLD 376 | PM 377 | PNC 378 | PNR 379 | PNW 380 | PPG 381 | PPL 382 | PRGO 383 | PRU 384 | PSA 385 | PSX 386 | PVH 387 | PWR 388 | PXD 389 | PYPL 390 | QCOM 391 | QRVO 392 | RCL 393 | RE 394 | REG 395 | REGN 396 | RF 397 | RHI 398 | RJF 399 | RL 400 | RMD 401 | ROK 402 | ROL 403 | ROP 404 | ROST 405 | RSG 406 | RTN 407 | SBAC 408 | SBUX 409 | SCHW 410 | SEE 411 | SHW 412 | SIVB 413 | SJM 414 | SLB 415 | SLG 416 | SNA 417 | SNPS 418 | SO 419 | SPG 420 | SPGI 421 | SRE 422 | STE 423 | STT 424 | STX 425 | STZ 426 | SWK 427 | SWKS 428 | SYF 429 | SYK 430 | SYY 431 | T 432 | TAP 433 | TDG 434 | TEL 435 | TFC 436 | TFX 437 | TGT 438 | TIF 439 | TJX 440 | TMO 441 | TMUS 442 | TPR 443 | TROW 444 | TRV 445 | TSCO 446 | TSN 447 | TT 448 | TTWO 449 | TWTR 450 | TXN 451 | TXT 452 | UA 453 | UAA 454 | UAL 455 | UDR 456 | UHS 457 | ULTA 458 | UNH 459 | UNM 460 | UNP 461 | UPS 462 | URI 463 | USB 464 | UTX 465 | V 466 | VAR 467 | VFC 468 | VIAC 469 | VLO 470 | VMC 471 | VNO 472 | VRSK 473 | VRSN 474 | VRTX 475 | VTR 476 | VZ 477 | WAB 478 | WAT 479 | WBA 480 | WDC 481 | WEC 482 | WELL 483 | WFC 484 | WHR 485 | WLTW 486 | WM 487 | WMB 488 | WMT 489 | WRB 490 | WRK 491 | WU 492 | WY 493 | WYNN 494 | XEL 495 | XLNX 496 | XOM 497 | XRAY 498 | XRX 499 | XYL 500 | YUM 501 | ZBH 502 | ZBRA 503 | ZION 504 | ZTS 505 | -------------------------------------------------------------------------------- /docs/client.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: python 2 | .. py:module:: tda.client 3 | 4 | .. _client: 5 | 6 | =========== 7 | HTTP Client 8 | =========== 9 | 10 | A naive, unopinionated wrapper around the 11 | `TD Ameritrade HTTP API `_. This 12 | client provides access to all endpoints of the API in as easy and direct a way 13 | as possible. For example, here is how you can fetch the past 20 years of data 14 | for Apple stock: 15 | 16 | **Do not attempt to use more than one Client object per token file, as 17 | this will likely cause issues with the underlying OAuth2 session management** 18 | 19 | .. code-block:: python 20 | 21 | from tda.auth import easy_client 22 | from tda.client import Client 23 | 24 | c = easy_client( 25 | api_key='APIKEY', 26 | redirect_uri='https://localhost', 27 | token_path='/tmp/token.json') 28 | 29 | resp = c.get_price_history('AAPL', 30 | period_type=Client.PriceHistory.PeriodType.YEAR, 31 | period=Client.PriceHistory.Period.TWENTY_YEARS, 32 | frequency_type=Client.PriceHistory.FrequencyType.DAILY, 33 | frequency=Client.PriceHistory.Frequency.DAILY) 34 | assert resp.status_code == httpx.codes.OK 35 | history = resp.json() 36 | 37 | Note we we create a new client using the ``auth`` package as described in 38 | :ref:`auth`. Creating a client directly is possible, but not recommended. 39 | 40 | +++++++++++++++++++ 41 | Asyncio Support 42 | +++++++++++++++++++ 43 | 44 | An asynchronous variant is available through a keyword to the client 45 | constructor. This allows for higher-performance API usage, at the cost 46 | of slightly increased application complexity. 47 | 48 | .. code-block:: python 49 | 50 | from tda.auth import easy_client 51 | from tda.client import Client 52 | 53 | async def main(): 54 | c = easy_client( 55 | api_key='APIKEY', 56 | redirect_uri='https://localhost', 57 | token_path='/tmp/token.json', 58 | asyncio=True) 59 | 60 | resp = await c.get_price_history('AAPL', 61 | period_type=Client.PriceHistory.PeriodType.YEAR, 62 | period=Client.PriceHistory.Period.TWENTY_YEARS, 63 | frequency_type=Client.PriceHistory.FrequencyType.DAILY, 64 | frequency=Client.PriceHistory.Frequency.DAILY) 65 | assert resp.status_code == httpx.codes.OK 66 | history = resp.json() 67 | 68 | if __name__ == '__main__': 69 | import asyncio 70 | asyncio.run_until_complete(main()) 71 | 72 | For more examples, please see the ``examples/async`` directory in 73 | GitHub. 74 | 75 | +++++++++++++++++++ 76 | Calling Conventions 77 | +++++++++++++++++++ 78 | 79 | Function parameters are categorized as either required or optional. 80 | Required parameters, such as ``'AAPL'`` in the example above, are passed as 81 | positional arguments. Optional parameters, like ``period_type`` and the rest, 82 | are passed as keyword arguments. 83 | 84 | Parameters which have special values recognized by the API are 85 | represented by `Python enums `_. 86 | This is because the API rejects requests which pass unrecognized values, and 87 | this enum wrapping is provided as a convenient mechanism to avoid consternation 88 | caused by accidentally passing an unrecognized value. 89 | 90 | By default, passing values other than the required enums will raise a 91 | ``ValueError``. If you believe the API accepts a value that isn't supported 92 | here, you can use ``set_enforce_enums`` to disable this behavior at your own 93 | risk. If you *do* find a supported value that isn't listed here, please open an 94 | issue describing it or submit a PR adding the new functionality. 95 | 96 | +++++++++++++ 97 | Return Values 98 | +++++++++++++ 99 | 100 | All methods return a response object generated under the hood by the 101 | `HTTPX `__ module. 102 | For a full listing of what's possible, read that module's documentation. Most if 103 | not all users can simply use the following pattern: 104 | 105 | .. code-block:: python 106 | 107 | r = client.some_endpoint() 108 | assert r.status_code == httpx.codes.OK, r.raise_for_status() 109 | data = r.json() 110 | 111 | The API indicates errors using the response status code, and this pattern will 112 | raise the appropriate exception if the response is not a success. The data can 113 | be fetched by calling the ``.json()`` method. 114 | 115 | This data will be pure python data structures which can be directly accessed. 116 | You can also use your favorite data analysis library's dataframe format using 117 | the appropriate library. For instance you can create a `pandas 118 | `__ dataframe using `its conversion method 119 | `__. 121 | 122 | **Note:** Because the author has no relationship whatsoever with TD Ameritrade, 123 | this document makes no effort to describe the structure of the returned JSON 124 | objects. TDA might change them at any time, at which point this document will 125 | become silently out of date. Instead, each of the methods described below 126 | contains a link to the official documentation. For endpoints that return 127 | meaningful JSON objects, it includes a JSON schema which describes the return 128 | value. Please use that documentation or your own experimentation when figuring 129 | out how to use the data returned by this API. 130 | 131 | +++++++++++++++++++++ 132 | Creating a New Client 133 | +++++++++++++++++++++ 134 | 135 | 99.9% of users should not create their own clients, and should instead follow 136 | the instructions outlined in :ref:`auth`. 137 | 138 | For users who want to disable the strict enum type checking on http client, 139 | just pass ``enforce_enums=False`` in any of the client creation functions 140 | described in :ref:`auth`. Just note that for most users, it is advised they 141 | stick with the default behavior. 142 | 143 | For those brave enough to build their 144 | own, the constructor looks like this: 145 | 146 | .. automethod:: tda.client.Client.__init__ 147 | 148 | 149 | ++++++++++++++++++ 150 | Timeout Management 151 | ++++++++++++++++++ 152 | 153 | Timeouts for HTTP calls are managed under the hood by the ``httpx`` library. 154 | ``tda-api`` defaults to 30 seconds, which experience has shown should be more 155 | than enough to allow even the slowest API calls to complete. A different timeout 156 | specification can be set using this method: 157 | 158 | .. automethod:: tda.client.Client.set_timeout 159 | 160 | 161 | .. _orders-section: 162 | 163 | ++++++ 164 | Orders 165 | ++++++ 166 | 167 | 168 | .. _placing_new_orders: 169 | 170 | ------------------ 171 | Placing New Orders 172 | ------------------ 173 | 174 | Placing new orders can be a complicated task. The :meth:`Client.place_order` method is 175 | used to create all orders, from equities to options. The precise order type is 176 | defined by a complex order spec. TDA provides some `example order specs`_ to 177 | illustrate the process and provides a schema in the `place order documentation 178 | `__, but beyond that we're on our own. 180 | 181 | ``tda-api`` includes some helpers, described in :ref:`order_templates`, which 182 | provide an incomplete utility for creating various order types. While it only 183 | scratches the surface of what's possible, we encourage you to use that module 184 | instead of creating your own order specs. 185 | 186 | .. _`example order specs`: https://developer.tdameritrade.com/content/place-order-samples 187 | 188 | .. automethod:: tda.client.Client.place_order 189 | 190 | .. _accessing_existing_orders: 191 | 192 | ------------------------- 193 | Accessing Existing Orders 194 | ------------------------- 195 | 196 | .. automethod:: tda.client.Client.get_orders_by_path 197 | .. automethod:: tda.client.Client.get_orders_by_query 198 | .. automethod:: tda.client.Client.get_order 199 | .. autoclass:: tda.client.Client.Order 200 | :members: 201 | :undoc-members: 202 | 203 | ----------------------- 204 | Editing Existing Orders 205 | ----------------------- 206 | 207 | Endpoints for canceling and replacing existing orders. 208 | Annoyingly, while these endpoints require an order ID, it seems that when 209 | placing new orders the API does not return any metadata about the new order. As 210 | a result, if you want to cancel or replace an order after you've created it, you 211 | must search for it using the methods described in :ref:`accessing_existing_orders`. 212 | 213 | .. automethod:: tda.client.Client.cancel_order 214 | .. automethod:: tda.client.Client.replace_order 215 | 216 | ++++++++++++ 217 | Account Info 218 | ++++++++++++ 219 | 220 | These methods provide access to useful information about accounts. An incomplete 221 | list of the most interesting bits: 222 | 223 | * Account balances, including available trading balance 224 | * Positions 225 | * Order history 226 | 227 | See the official documentation for each method for a complete response schema. 228 | 229 | .. automethod:: tda.client.Client.get_account 230 | .. automethod:: tda.client.Client.get_accounts 231 | .. autoclass:: tda.client.Client.Account 232 | :members: 233 | :undoc-members: 234 | 235 | +++++++++++++++ 236 | Instrument Info 237 | +++++++++++++++ 238 | 239 | Note: symbol fundamentals (P/E ratios, number of shares outstanding, dividend 240 | yield, etc.) is available using the :attr:`Instrument.Projection.FUNDAMENTAL` 241 | projection. 242 | 243 | .. automethod:: tda.client.Client.search_instruments 244 | .. automethod:: tda.client.Client.get_instrument 245 | .. autoclass:: tda.client.Client.Instrument 246 | :members: 247 | :undoc-members: 248 | 249 | 250 | .. _option_chain: 251 | 252 | ++++++++++++ 253 | Option Chain 254 | ++++++++++++ 255 | 256 | Unfortunately, option chains are well beyond the ability of your humble author. 257 | You are encouraged to read the official API documentation to learn more. 258 | 259 | If you *are* knowledgeable enough to write something more substantive here, 260 | please follow the instructions in :ref:`contributing` to send in a patch. 261 | 262 | .. automethod:: tda.client.Client.get_option_chain 263 | .. autoclass:: tda.client.Client.Options 264 | :members: 265 | :undoc-members: 266 | 267 | +++++++++++++ 268 | Price History 269 | +++++++++++++ 270 | 271 | TDA provides price history for equities and ETFs. It does not provide price 272 | history for options, futures, or any other instruments. 273 | 274 | In the raw API, fetching price history is somewhat complicated: the API offers a 275 | single endpoint :meth:`Client.get_price_history` that accepts a complex variety 276 | of inputs, but fails to document them in any meaningful way. 277 | 278 | Thankfully, we've reverse engineered this endpoint and built some helpful 279 | utilities for fetching prices by minute, day, week, etc. Each method can be 280 | called with or without date bounds. When called without date bounds, it returns 281 | all data available. Each method offers a different lookback period, so make sure 282 | to read the documentation below to learn how much data is available. 283 | 284 | 285 | .. automethod:: tda.client.Client.get_price_history_every_minute 286 | .. automethod:: tda.client.Client.get_price_history_every_five_minutes 287 | .. automethod:: tda.client.Client.get_price_history_every_ten_minutes 288 | .. automethod:: tda.client.Client.get_price_history_every_fifteen_minutes 289 | .. automethod:: tda.client.Client.get_price_history_every_thirty_minutes 290 | .. automethod:: tda.client.Client.get_price_history_every_day 291 | .. automethod:: tda.client.Client.get_price_history_every_week 292 | 293 | For the sake of completeness, here is the documentation for the raw price 294 | history endpoint, in all its complexity. 295 | 296 | .. automethod:: tda.client.Client.get_price_history 297 | .. autoclass:: tda.client.Client.PriceHistory 298 | :members: 299 | :undoc-members: 300 | :member-order: bysource 301 | 302 | ++++++++++++++ 303 | Current Quotes 304 | ++++++++++++++ 305 | 306 | .. automethod:: tda.client.Client.get_quote 307 | .. automethod:: tda.client.Client.get_quotes 308 | 309 | +++++++++++++++ 310 | Other Endpoints 311 | +++++++++++++++ 312 | 313 | Note If your account limited to delayed quotes, these quotes will also be 314 | delayed. 315 | 316 | ------------------- 317 | Transaction History 318 | ------------------- 319 | 320 | .. automethod:: tda.client.Client.get_transaction 321 | .. automethod:: tda.client.Client.get_transactions 322 | .. autoclass:: tda.client.Client.Transactions 323 | :members: 324 | :undoc-members: 325 | 326 | ------------ 327 | Saved Orders 328 | ------------ 329 | 330 | .. automethod:: tda.client.Client.create_saved_order 331 | .. automethod:: tda.client.Client.delete_saved_order 332 | .. automethod:: tda.client.Client.get_saved_order 333 | .. automethod:: tda.client.Client.get_saved_orders_by_path 334 | .. automethod:: tda.client.Client.replace_saved_order 335 | 336 | ------------ 337 | Market Hours 338 | ------------ 339 | 340 | .. automethod:: tda.client.Client.get_hours_for_multiple_markets 341 | .. automethod:: tda.client.Client.get_hours_for_single_market 342 | .. autoclass:: tda.client.Client.Markets 343 | :members: 344 | :undoc-members: 345 | 346 | ------ 347 | Movers 348 | ------ 349 | 350 | .. automethod:: tda.client.Client.get_movers 351 | .. autoclass:: tda.client.Client.Movers 352 | :members: 353 | :undoc-members: 354 | 355 | ------------------------- 356 | User Info and Preferences 357 | ------------------------- 358 | 359 | .. automethod:: tda.client.Client.get_preferences 360 | .. automethod:: tda.client.Client.get_user_principals 361 | .. automethod:: tda.client.Client.update_preferences 362 | .. autoclass:: tda.client.Client.UserPrincipals 363 | :members: 364 | :undoc-members: 365 | 366 | ---------- 367 | Watchlists 368 | ---------- 369 | 370 | **Note**: These methods only support static watchlists, i.e. they cannot access 371 | dynamic watchlists. 372 | 373 | .. automethod:: tda.client.Client.create_watchlist 374 | .. automethod:: tda.client.Client.delete_watchlist 375 | .. automethod:: tda.client.Client.get_watchlist 376 | .. automethod:: tda.client.Client.get_watchlists_for_multiple_accounts 377 | .. automethod:: tda.client.Client.get_watchlists_for_single_account 378 | .. automethod:: tda.client.Client.replace_watchlist 379 | .. automethod:: tda.client.Client.update_watchlist 380 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('..')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'tda-api' 21 | copyright = '2020, Alex Golec' 22 | author = 'Alex Golec' 23 | 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | 'sphinx.ext.autodoc', 32 | 'sphinx_rtd_theme', 33 | ] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates'] 37 | 38 | # List of patterns, relative to source directory, that match files and 39 | # directories to ignore when looking for source files. 40 | # This pattern also affects html_static_path and html_extra_path. 41 | exclude_patterns = [] 42 | 43 | 44 | # -- Options for HTML output ------------------------------------------------- 45 | 46 | # The theme to use for HTML and HTML Help pages. See the documentation for 47 | # a list of builtin themes. 48 | # 49 | html_theme = 'sphinx_rtd_theme' 50 | 51 | # Add any paths that contain custom static files (such as style sheets) here, 52 | # relative to this directory. They are copied after the builtin static files, 53 | # so a file named "default.css" will overwrite the builtin "default.css". 54 | html_static_path = ['_static'] 55 | 56 | 57 | # Don't emit module names in autogenerated documentation. 58 | add_module_names = True 59 | 60 | # Explicitly specify the master file because ReadTheDocs is running on Sphinx 61 | # 1.8.5 62 | master_doc = 'index' 63 | 64 | autodoc_member_order = 'bysource' 65 | -------------------------------------------------------------------------------- /docs/contrib.rst: -------------------------------------------------------------------------------- 1 | .. _contrib: 2 | 3 | =================================== 4 | Community-Contributed Functionality 5 | =================================== 6 | 7 | 8 | When maintaining ``tda-api``, the authors have two goals: make common things 9 | easy, and make uncommon things possible. This meets the needs of vast majority 10 | of the community, while allowing advanced users or those with very niche 11 | requirements to progress using potentially custom approaches. 12 | 13 | However, this philosophy explicitly excludes functionality that is potentially 14 | useful to many users, but is either not directly related to the core 15 | functionality of the API wrapper. This is where the ``contrib`` module comes 16 | into play. 17 | 18 | This module is a collection of high-quality code that was produced by the 19 | community and for the community. It includes utility methods that provide 20 | additional functionality beyond the core library, fixes for quirks in API 21 | behavior, etc. This page lists the available functionality. If you'd like to 22 | discuss this or propose/request new additions, please join our `Discord server 23 | `__. 24 | 25 | 26 | .. _custom_json_decoding: 27 | 28 | -------------------- 29 | Custom JSON Decoding 30 | -------------------- 31 | 32 | TDA's API occasionally emits invalid JSON in the stream. This class implements 33 | all known workarounds and hacks to get around these quirks: 34 | 35 | .. autoclass:: tda.contrib.util::HeuristicJsonDecoder 36 | :members: 37 | :undoc-members: 38 | 39 | You can use it as follows: 40 | 41 | .. code-block:: python 42 | 43 | from tda.contrib.util import HeuristicJsonDecoder 44 | 45 | stream_client = # ... create your stream 46 | stream_client.set_json_decoder(HeuristicJsonDecoder()) 47 | # ... continue as normal 48 | 49 | If you encounter invalid stream items that are not fixed by using this decoder, 50 | please let us know in our `Discord server `__ or 51 | follow the guide in :ref:`contributing` to add new functionality. 52 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. _contributing: 2 | 3 | .. include:: ../CONTRIBUTING.rst 4 | -------------------------------------------------------------------------------- /docs/example.rst: -------------------------------------------------------------------------------- 1 | .. _example: 2 | 3 | =================== 4 | Example Application 5 | =================== 6 | 7 | To illustrate some of the functionality of ``tda-api``, here is an example 8 | application that finds stocks that pay a dividend during the month of your 9 | birthday and purchases one of each. 10 | 11 | .. literalinclude:: ../examples/birthday_dividends.py 12 | -------------------------------------------------------------------------------- /docs/getting-started.rst: -------------------------------------------------------------------------------- 1 | .. _getting_started: 2 | 3 | =============== 4 | Getting Started 5 | =============== 6 | 7 | Welcome to ``tda-api``! Read this page to learn how to install and configure 8 | your first TD Ameritrade Python application. 9 | 10 | 11 | +++++++++++++++++++++++++++++++++++++++++++++++++ 12 | Important New About the Charles Schwab Transition 13 | +++++++++++++++++++++++++++++++++++++++++++++++++ 14 | 15 | Following its 2020 acquisition of TDAmeritrade, Charles Schwab has begun its 16 | transition to TDAmeritrade. Notably for most readers of this page, this means 17 | that API keys are no longer being registered. If you do not already have an API 18 | key, *you cannot use ``tda-api``*. Our apologies. 19 | 20 | You can find more information on our :ref:`Charles Schwab transition page 21 | `. 22 | 23 | 24 | ++++++++++++++++++++++++ 25 | TD Ameritrade API Access 26 | ++++++++++++++++++++++++ 27 | 28 | **Note:** *Now that TDAmeritrade has closed registrations, this section is now 29 | likely impossible to follow. We've left it up just in case we're wrong about 30 | that.* 31 | 32 | All API calls to the TD Ameritrade API require an API key. Before we do 33 | anything with ``tda-api``, you'll need to create a developer account with TD 34 | Ameritrade and register an application. By the end of this section, you'll have 35 | accomplished the three prerequisites for using ``tda-api``: 36 | 37 | 1. Create an application. 38 | #. Choose and save the callback URL (important for authenticating). 39 | #. Receive an API key. 40 | 41 | You can create a developer account `here `__. The instructions from here on out assume you're logged in, 43 | so make sure you log into the developer site after you've created your account. 44 | 45 | Next, you'll want to `create an application 46 | `__. The app name and 47 | purpose aren't particularly important right now, but the callback URL is. In a 48 | nutshell, the `OAuth login flow `__ that TD Ameritrade uses 50 | works by opening a TD Ameritrade login page, securely collecting credentials on 51 | their domain, and then sending an HTTP request to the callback URL with the 52 | token in the URL query. 53 | 54 | How you use to choose your callback URL depends on whether and how you 55 | plan on distributing your app. If you're writing an app for your own personal 56 | use, and plan to run entirely on your own machine, use ``https://localhost``. If 57 | you plan on running on a server and having users send requests to you, use a URL 58 | you own, such as a dedicated endpoint on your domain. 59 | 60 | Once your app is created and approved, you will receive your API key, also known 61 | as the Client ID. This will be visible in TDA's `app listing page `__. Record this key, since it 63 | is necessary to access most API endpoints. 64 | 65 | ++++++++++++++++++++++ 66 | Installing ``tda-api`` 67 | ++++++++++++++++++++++ 68 | 69 | This section outlines the installation process for client users. For developers, 70 | check out :ref:`contributing`. 71 | 72 | The recommended method of installing ``tda-api`` is using ``pip`` from 73 | `PyPi `__ in a `virtualenv `__. First create a virtualenv in your project 75 | directory. Here we assume your virtualenv is called ``my-venv``: 76 | 77 | .. code-block:: shell 78 | 79 | pip install virtualenv 80 | virtualenv -v my-venv 81 | source my-venv/bin/activate 82 | 83 | You are now ready to install ``tda-api``: 84 | 85 | .. code-block:: shell 86 | 87 | pip install tda-api 88 | 89 | That's it! You're done! You can verify the install succeeded by importing the 90 | package: 91 | 92 | .. code-block:: python 93 | 94 | import tda 95 | 96 | If this succeeded, you're ready to move on to :ref:`auth`. 97 | 98 | Note that if you are using a virtual environment and switch to a new terminal 99 | your virtual environment will not be active in the new terminal, 100 | and you need to run the activate command again. 101 | If you want to disable the loaded virtual environment in the same terminal window, 102 | use the command: 103 | 104 | .. code-block:: shell 105 | 106 | deactivate 107 | 108 | ++++++++++++ 109 | Getting Help 110 | ++++++++++++ 111 | 112 | If you are ever stuck, feel free to `join our Discord server 113 | `__ to ask questions, get advice, and chat with 114 | like-minded people. If you feel you've found a bug, you can :ref:`fill out a bug 115 | report `. 116 | -------------------------------------------------------------------------------- /docs/help.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: python 2 | .. py:module:: tda.debug 3 | 4 | .. _help: 5 | 6 | ============ 7 | Getting Help 8 | ============ 9 | 10 | Even the most experienced developer needs help on occasion. This page describes 11 | how you can get help and make progress. 12 | 13 | 14 | -------------------------- 15 | Asking for Help on Discord 16 | -------------------------- 17 | 18 | ``tda-api`` has a vibrant community that hangs out in our `discord server 19 | `__. If you're having any sort of trouble, this 20 | server should be your first stop. Just make sure you follow a few rules to ask 21 | for help. 22 | 23 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 24 | Provide Adequate Information 25 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 26 | 27 | Nothing makes it easier to help you than information. The more information 28 | you provide, the easier it'll be to help you. If you are asking for advice on 29 | how to do something, share whatever code you've written or research you've 30 | performed. If you're asking for help about an error, make sure you provide **at 31 | least** the following information: 32 | 33 | 1. Your OS (Windows? Mac OS? Linux?) and execution environment (VSCode? A raw 34 | terminal? A docker container in the cloud?) 35 | 2. Your ``tda-api`` version. You can see this by executing 36 | ``print(tda.__version__)`` in a python shell. Make sure you're using the 37 | most recent version *before* asking for help. You can ensure this using 38 | ``pip install --upgrade tda-api``. 39 | 3. The full stack trace and error message. Descriptions of errors will be met 40 | with requests to provide more information. 41 | 4. Code that reproduces the error. If you're shy about your code, write a small 42 | script that reproduces the error when run. 43 | 44 | Optionally, you may want to share diagnostic logs generated by ``tda-api``. Not 45 | only does this provide even more information to the community, reading through 46 | the logs might also help you solve the problem yourself. You can read about 47 | enabling logging :ref:`here `. 48 | 49 | 50 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 51 | Format Your Request Properly 52 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 53 | 54 | Take advantage of Discord's wonderful `support for code blocks 55 | `__ 56 | and format your error, stack traces, and code using triple backticks. To do 57 | this, put ``````` before and after your message. Failing to do this will be met 58 | with a request to edit your message to be better formatted. 59 | 60 | 61 | --------------- 62 | Reporting a Bug 63 | --------------- 64 | 65 | ``tda-api`` is not perfect. Features are missing, documentation may be out of 66 | date, and it almost certainly contains bugs. If you think of a way in which 67 | ``tda-api`` can be improved, we're more than happy to hear it. 68 | 69 | This section outlines the process for getting help if you found a bug. If you need 70 | general help using ``tda-api``, or just want to chat with other people 71 | interested in developing trading strategies, you can 72 | `join our discord `__. 73 | 74 | If you still want to submit an issue, we ask that you follow a few guidelines to 75 | make everyone's lives easier: 76 | 77 | 78 | .. _enable_logging: 79 | 80 | ~~~~~~~~~~~~~~ 81 | Enable Logging 82 | ~~~~~~~~~~~~~~ 83 | 84 | Behind the scenes, ``tda-api`` performs diagnostic logging of its activity using 85 | Python's `logging `__ module. 86 | You can enable this debug information by telling the root logger to print these 87 | messages: 88 | 89 | .. code-block:: python 90 | 91 | import logging 92 | logging.getLogger('').addHandler(logging.StreamHandler()) 93 | 94 | Sometimes, this additional logging is enough to help you debug. Before you ask 95 | for help, carefully read through your logs to see if there's anything there that 96 | helps you. 97 | 98 | 99 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 100 | Gather Logs For Your Bug Report 101 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 102 | 103 | If you still can't figure out what's going wrong, ``tda-api`` has special 104 | functionality for gathering and preparing logs for filing issues. It works by 105 | capturing ``tda-api``'s logs, anonymizing them, and then dumping them to the 106 | console when the program exits. You can enable this by calling this method 107 | **before doing anything else in your application**: 108 | 109 | .. code-block:: python 110 | 111 | tda.debug.enable_bug_report_logging() 112 | 113 | This method will redact the logs to scrub them of common secrets, like account 114 | IDs, tokens, access keys, etc. However, this redaction is not guaranteed to be 115 | perfect, and it is your responsibility to make sure they are clean before you 116 | ask for help. 117 | 118 | When filing a issue, please upload the logs along with your description. **If 119 | you do not include logs with your issue, your issue may be closed**. 120 | 121 | For completeness, here is this method's documentation: 122 | 123 | .. automethod:: tda.debug.enable_bug_report_logging 124 | 125 | 126 | ~~~~~~~~~~~~~~~~~~ 127 | Submit Your Ticket 128 | ~~~~~~~~~~~~~~~~~~ 129 | 130 | You are now ready to write your bug. Before you do, be warned that your issue 131 | may be be closed if: 132 | 133 | * It does not include code. The first thing we do when we receive your issue is 134 | we try to reproduce your failure. We can't do that if you don't show us your 135 | code. 136 | * It does not include logs. It's very difficult to debug problems without logs. 137 | * Logs are not adequately redacted. This is for your own protection. 138 | * Logs are copy-pasted into the issue message field. Please write them to a 139 | file and attach them to your issue. 140 | * You do not follow the issue template. We're not *super* strict about this 141 | one, but you should at least include all the information it asks for. 142 | 143 | You can file an issue on our `GitHub page `__. 145 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ``tda-api``: An Unofficial TD Ameritrade Client 2 | =============================================== 3 | 4 | .. image:: _static/github-logo.png 5 | :width: 40 6 | :target: https://github.com/alexgolec/tda-api 7 | 8 | .. image:: _static/patreon.png 9 | :width: 110 10 | :target: https://www.patreon.com/TDAAPI 11 | 12 | .. image:: _static/discord-logo.png 13 | :width: 50 14 | :target: https://discord.gg/M3vjtHj 15 | 16 | .. toctree:: 17 | :maxdepth: 2 18 | :caption: Contents: 19 | 20 | schwab 21 | getting-started 22 | auth 23 | client 24 | streaming 25 | order-templates 26 | order-builder 27 | util 28 | example 29 | help 30 | contrib 31 | contributing 32 | 33 | Indices and tables 34 | ================== 35 | 36 | * :ref:`genindex` 37 | * :ref:`modindex` 38 | * :ref:`search` 39 | 40 | **Disclaimer:** *tda-api is an unofficial API wrapper. It is in no way 41 | endorsed by or affiliated with TD Ameritrade or any associated organization. 42 | Make sure to read and understand the terms of service of the underlying API 43 | before using this package. This authors accept no responsibility for any 44 | damage that might stem from use of this package. See the LICENSE file for 45 | more details.* 46 | -------------------------------------------------------------------------------- /docs/order-templates.rst: -------------------------------------------------------------------------------- 1 | .. py:module:: tda.orders 2 | 3 | .. _order_templates: 4 | 5 | =============== 6 | Order Templates 7 | =============== 8 | 9 | ``tda-api`` strives to be easy to use. This means making it easy to do simple 10 | things, while making it possible to do complicated things. Order construction is 11 | a major challenge to this mission: both simple and complicated orders use the 12 | same format, meaning simple orders require a surprising amount of sophistication 13 | to place. 14 | 15 | We get around this by providing templates that make it easy to place common 16 | orders, while allowing advanced users to modify the orders returned from the 17 | templates to create more complex ones. Very advanced users can even create their 18 | own orders from scratch. This page describes the simple templates, while the 19 | :ref:`order_builder` page documents the order builder in all its complexity. 20 | 21 | 22 | --------------------- 23 | Using These Templates 24 | --------------------- 25 | 26 | These templates serve two purposes. First, they are designed to choose defaults 27 | so you can immediately :ref:`place them `. These defaults 28 | are: 29 | 30 | * All orders execute during the current normal trading session. If placed 31 | outside of trading hours, the execute during the next normal trading session. 32 | * Time-in-force is set to ``DAY``. 33 | * All other fields (such as requested destination, etc.) are left unset, 34 | meaning they receive default treatment from TD Ameritrade. Note this 35 | treatment depends on TDA's implementation, and may change without warning. 36 | 37 | Secondly, they serve as starting points for building more complex order types. 38 | All templates return a pre-populated ``OrderBuilder`` object, meaning complex 39 | functionality can be specified by modifying the returned object. For example, 40 | here is how you would place an order to buy ``GOOG`` for no more than $1250 at 41 | any time in the next six months: 42 | 43 | .. code-block:: python 44 | 45 | from tda.orders.equities import equity_buy_limit 46 | from tda.orders.common import Duration, Session 47 | 48 | client = ... # See "Authentication and Client Creation" 49 | 50 | client.place_order( 51 | 1000, # account_id 52 | equity_buy_limit('GOOG', 1, 1250.0) 53 | .set_duration(Duration.GOOD_TILL_CANCEL) 54 | .set_session(Session.SEAMLESS) 55 | .build()) 56 | 57 | You can find a full reference for all supported fields in :ref:`order_builder`. 58 | 59 | 60 | ---------------- 61 | Equity Templates 62 | ---------------- 63 | 64 | ++++++++++ 65 | Buy orders 66 | ++++++++++ 67 | 68 | .. autofunction:: tda.orders.equities.equity_buy_market 69 | .. autofunction:: tda.orders.equities.equity_buy_limit 70 | 71 | +++++++++++ 72 | Sell orders 73 | +++++++++++ 74 | 75 | .. autofunction:: tda.orders.equities.equity_sell_market 76 | .. autofunction:: tda.orders.equities.equity_sell_limit 77 | 78 | +++++++++++++++++ 79 | Sell short orders 80 | +++++++++++++++++ 81 | 82 | .. autofunction:: tda.orders.equities.equity_sell_short_market 83 | .. autofunction:: tda.orders.equities.equity_sell_short_limit 84 | 85 | +++++++++++++++++++ 86 | Buy to cover orders 87 | +++++++++++++++++++ 88 | 89 | .. autofunction:: tda.orders.equities.equity_buy_to_cover_market 90 | .. autofunction:: tda.orders.equities.equity_buy_to_cover_limit 91 | 92 | 93 | ----------------- 94 | Options Templates 95 | ----------------- 96 | 97 | TD Ameritrade supports over a dozen options strategies, each of which involve a 98 | precise structure in the order builder. ``tda-api`` is slowly gaining support 99 | for these strategies, and they are documented here as they become ready for use. 100 | As time goes on, more templates will be added here. 101 | 102 | In the meantime, you can construct all supported options orders using the 103 | :ref:`OrderBuilder `, although you will have to construct them 104 | yourself. 105 | 106 | Note orders placed using these templates may be rejected, depending on the 107 | user's options trading authorization. 108 | 109 | 110 | ++++++++++++++++++++++++ 111 | Building Options Symbols 112 | ++++++++++++++++++++++++ 113 | 114 | All templates require option symbols, which are somewhat more involved than 115 | equity symbols. They encode the underlying, the expiration date, option type 116 | (put or call) and the strike price. They are especially tricky to extract 117 | because both the TD Ameritrade UI and the thinkorswim UI don't reveal the symbol 118 | in the option chain view. 119 | 120 | Real trading symbols can be found by requesting the :ref:`option_chain`. They 121 | can also be built using the ``OptionSymbol`` helper, which provides utilities 122 | for creating options symbols. Note it only emits syntactically correct symbols 123 | and does not validate whether the symbol actually represents a traded option: 124 | 125 | .. code-block:: python 126 | 127 | from tda.orders.options import OptionSymbol 128 | 129 | symbol = OptionSymbol( 130 | 'TSLA', datetime.date(year=2020, month=11, day=20), 'P', '1360').build() 131 | 132 | .. autoclass:: tda.orders.options.OptionSymbol 133 | :special-members: 134 | 135 | 136 | ++++++++++++++ 137 | Single Options 138 | ++++++++++++++ 139 | 140 | Buy and sell single options. 141 | 142 | .. autofunction:: tda.orders.options.option_buy_to_open_market 143 | .. autofunction:: tda.orders.options.option_buy_to_open_limit 144 | .. autofunction:: tda.orders.options.option_sell_to_open_market 145 | .. autofunction:: tda.orders.options.option_sell_to_open_limit 146 | .. autofunction:: tda.orders.options.option_buy_to_close_market 147 | .. autofunction:: tda.orders.options.option_buy_to_close_limit 148 | .. autofunction:: tda.orders.options.option_sell_to_close_market 149 | .. autofunction:: tda.orders.options.option_sell_to_close_limit 150 | 151 | 152 | .. _vertical_spreads: 153 | 154 | ++++++++++++++++ 155 | Vertical Spreads 156 | ++++++++++++++++ 157 | 158 | Vertical spreads are a complex option strategy that provides both limited upside 159 | and limited downside. They are constructed by buying an option at one 160 | strike while simultaneously selling another option with the same underlying and 161 | expiration date, except with a different strike, and they can be constructed 162 | using either puts or call. You can find more information about this strategy on 163 | `Investopedia `__ 165 | 166 | ``tda-api`` provides utilities for opening and closing vertical spreads in 167 | various ways. It follows the standard ``(bull/bear) (put/call)`` naming 168 | convention, where the name specifies the market attitude and the option type 169 | used in construction. 170 | 171 | For consistency's sake, the option with the smaller strike price is always 172 | passed first, followed by the higher strike option. You can find the option 173 | symbols by consulting the return value of the :ref:`option_chain` client call. 174 | 175 | 176 | ~~~~~~~~~~~~~~ 177 | Call Verticals 178 | ~~~~~~~~~~~~~~ 179 | 180 | .. autofunction:: tda.orders.options.bull_call_vertical_open 181 | .. autofunction:: tda.orders.options.bull_call_vertical_close 182 | .. autofunction:: tda.orders.options.bear_call_vertical_open 183 | .. autofunction:: tda.orders.options.bear_call_vertical_close 184 | 185 | 186 | ~~~~~~~~~~~~~ 187 | Put Verticals 188 | ~~~~~~~~~~~~~ 189 | 190 | .. autofunction:: tda.orders.options.bull_put_vertical_open 191 | .. autofunction:: tda.orders.options.bull_put_vertical_close 192 | .. autofunction:: tda.orders.options.bear_put_vertical_open 193 | .. autofunction:: tda.orders.options.bear_put_vertical_close 194 | 195 | 196 | --------------- 197 | Utility Methods 198 | --------------- 199 | 200 | These methods return orders that represent complex multi-order strategies, 201 | namely "one cancels other" and "first triggers second" strategies. Note they 202 | expect all their parameters to be of type ``OrderBuilder``. You can construct 203 | these orders using the templates above or by 204 | :ref:`creating them from scratch `. 205 | 206 | Note that you do **not** construct composite orders by placing the constituent 207 | orders and then passing the results to the utility methods: 208 | 209 | .. code-block:: python 210 | 211 | order_one = c.place_order(config.account_id, 212 | option_buy_to_open_limit(trade_symbol, contracts, safety_ask) 213 | .set_duration(Duration.GOOD_TILL_CANCEL) 214 | .set_session(Session.NORMAL) 215 | .build()) 216 | 217 | order_two = c.place_order(config.account_id, 218 | option_sell_to_close_limit(trade_symbol, half, double) 219 | .set_duration(Duration.GOOD_TILL_CANCEL) 220 | .set_session(Session.NORMAL) 221 | .build()) 222 | 223 | # THIS IS BAD, DO NOT DO THIS 224 | exec_trade = c.place_order(config.account_id, first_triggers_second(order_one, order_two)) 225 | 226 | What's happening here is both constituent orders are being executed, and then 227 | ``place_order`` will fail. Creating an ``OrderBuilder`` defers their execution, 228 | subject to your composite order rules. 229 | 230 | **Note:** In the past, using these features required disabling Advanced Features 231 | on your account. Since then, it appears this requirement has been silently removed, 232 | and many users have reported being able to use composite orders without disabling 233 | these features. If you encounter issues with OCO or trigger orders, you may find 234 | it helpful to call TDAmeritrade support and request that Advanced Features be 235 | turned off for your account. If you need more help, we recommend `joining our 236 | discord `__ to ask the community for help. 237 | 238 | .. autofunction:: tda.orders.common.one_cancels_other 239 | .. autofunction:: tda.orders.common.first_triggers_second 240 | 241 | 242 | ---------------------------------------- 243 | What happened to ``EquityOrderBuilder``? 244 | ---------------------------------------- 245 | 246 | Long-time users and new users following outdated tutorials may notice that 247 | this documentation no longer mentions the ``EquityOrderBuilder`` class. This 248 | class used to be used to create equities orders, and offered a subset of the 249 | functionality offered by the :ref:`OrderBuilder `. This class 250 | has been removed in favor of the order builder and the above templates. 251 | -------------------------------------------------------------------------------- /docs/schwab.rst: -------------------------------------------------------------------------------- 1 | .. _schwab: 2 | 3 | 4 | ===================================== 5 | ``tda-api`` and the Schwab Transition 6 | ===================================== 7 | 8 | In 2020, Charles Schwab acquired TDAmeritrade, and in late 2022 they announced 9 | their transition plan. This page outlines the implications for current and 10 | prospective ``tda-api`` users. 11 | 12 | 13 | **Disclaimer:** This page contains information about a transition in which the 14 | author is merely an observer. It attempts to collect and synthesize information 15 | provided by TDAmeritrade/Charles Schwab, and may be incorrect or out of date. 16 | Please refer to official communications for authoritative information. If you 17 | have a different interpretation of the available information, please `visit our 18 | discord server `__ and share it with us. Use the 19 | information this page at your own risk. 20 | 21 | ------------------ 22 | What is happening? 23 | ------------------ 24 | 25 | Charles Schwab now owns TDAmeritrade. TDAmeritrade appears to be continuing 26 | their operations as a broker, but is transitioning their customers onto new 27 | Charles Schwab accounts. This process was announced in late 2022 and is slated 28 | to happen in 2023. 29 | 30 | If you are reading this, you are likely interested in using the TDAmeritrade 31 | REST API. This transition has significant implications for both new and existing 32 | ``tda-api`` users. Please keep reading for more. 33 | 34 | 35 | -------------------------- 36 | Existing ``tda-api`` Users 37 | -------------------------- 38 | 39 | As far as we understand it, the implications of this transition for existing 40 | ``tda-api`` users are as follows: 41 | 42 | * All accounts will be transitioned to Schwab over the course of 2023. 43 | * Once an account is transitioned to Schwab, it will lose access to the TDAmeritrade REST API. This means all API wrappers will stop working on that account, including ``tda-api``. 44 | * Schwab has announced their intention to provide an API for retail traders, but no such API has materialized yet. They have also `stated that this API will be largely similar to the existing TDAmeritrade API `__, with some modifications. Again, details are forthcoming. 45 | 46 | 47 | +++++++++++++++++++++++++++++++++++++ 48 | When will my account be transitioned? 49 | +++++++++++++++++++++++++++++++++++++ 50 | 51 | We understand this will happen in 2023, although details have no yet been 52 | provided. Schwab advises to `"log in to your TD Ameritrade account and visit the 53 | Schwab Transition Center" `__, although as 54 | of this writing the author has not seen any such option on his brokerage page. 55 | 56 | 57 | +++++++++++++++++++++++++++++++++++++++++++++++++ 58 | Will I control when my account gets transitioned? 59 | +++++++++++++++++++++++++++++++++++++++++++++++++ 60 | 61 | It seems not. Our understanding is that each account will be assigned a 62 | "transition weekend" on which they will be migrated, and has `provided a 63 | timeline `__ relative to that weekend. How 64 | that weekend is chosen and whether it can be altered by the user is unclear. 65 | 66 | 67 | +++++++++++++++++++++++++++++++++++++++++++++++++++++ 68 | What happens to my app before my account transitions? 69 | +++++++++++++++++++++++++++++++++++++++++++++++++++++ 70 | 71 | There do not appear to be any changes to existing TDAmeritrade accounts, 72 | including access to the REST API. This suggests that ``tda-api`` should continue 73 | to work as normal until your account is transitioned. 74 | 75 | 76 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 77 | What happens to the ``tda-api`` app after I transition? 78 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 79 | 80 | It stops working. You will need to migrate your app to the upcoming Schwab API, 81 | once it becomes available. 82 | 83 | 84 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 85 | What if I transition before the new Schwab API becomes available? 86 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 87 | 88 | While not confirmed, it appears your account may be transitioned to Schwab 89 | before the new Schwab API is made available. If this happens, our understanding 90 | is that you will not have access to either the previous TDAmeritrade API (and 91 | ``tda-api`` as well) or to the as-yet-unreleased Schwab API. 92 | 93 | It's important to note that this scenario is still hypothetical. For all we 94 | know, the Schwab API will be made available before your account is transitioned, 95 | and your access to a retail trading API will not be interrupted. However, this 96 | scenario has not been ruled out, either. TDAmeritrade's/Schwab's `integration 97 | guide 98 | `__ 99 | says *"It is possible that a TDA brokerage account may not be migrated to Schwab 100 | brokerage before Schwab endpoints are live,"* although we're frankly at a loss 101 | for how to interpret that statement. 102 | 103 | 104 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 105 | How do I migrate my ``tda-api`` app to this new Schwab API? 106 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 107 | 108 | Until the new Schwab API becomes available, there is nothing you can do. Once it 109 | becomes available, the maintainers of ``tda-api`` will evaluate the situation 110 | and decide how to move forward. Our tentative plan is as follows, although note 111 | this is based on preliminary information and subject to change at any time: 112 | 113 | * Schwab has announced that their new API will resemble the old TDAmeritrade API, with some modifications. Notably, it appears all functionality will be carried forward except for saved orders and watchlists. It seems there will be some changes to the authentication flow as well. 114 | * The ``tda-api`` authors currently intend to implement a new API wrapper to support this new API. Wherever possible, the functionality and interface of ``tda-api`` will be kept intact. 115 | * This new library will be a separate package from ``tda-api``. We are in the process of constructing placeholders and registering PyPI packages. 116 | * Your app will almost certainly need to be modified to use this new library, although we aim to minimize the work required to do so. 117 | * TDAmeritrade/Schwab has also confirmed that you will need to register a new 118 | application, i.e. receive a new API key. Schwab has announced this will happen 119 | in `"early 2023." 120 | `__ 121 | 122 | 123 | --------------------- 124 | New ``tda-api`` Users 125 | --------------------- 126 | 127 | Unfortunately, as part of this transition, TDAmeritrade has closed registration 128 | of new applications. This means you cannot get a API key for your application, 129 | so if you're not currently a ``tda-api`` user, you cannot become one. This is an 130 | unfortunate state of affairs, but we are powerless to change it. 131 | 132 | 133 | ++++++++++++++++++++++++++++++++++++ 134 | Can I borrow someone else's API key? 135 | ++++++++++++++++++++++++++++++++++++ 136 | 137 | According to the ``tda-api`` authors' interpretation of `the TDAmeritrade API's 138 | terms of service `__, no. In fact, 139 | they explicitly say *"All API Keys assigned to you are unique to you and are 140 | solely for your own use in connection with your participation in the Program. 141 | You may not provide any third party with access to any API Key."* We're not 142 | lawyers, so take our advice with a grain of salt, but that seems pretty 143 | unambiguous to us. 144 | 145 | We are enforcing this interpretation on our discord server. Your first request 146 | for a third-party API key will be met with a warning, and subsequent requests 147 | will result in your being banned from the server. 148 | 149 | 150 | ++++++++++++++++++++++++++++++++++++++++++++++++ 151 | Wait, so I'm locked out of the TDAmeritrade API? 152 | ++++++++++++++++++++++++++++++++++++++++++++++++ 153 | 154 | Sadly, it would appear so. We still recommend `joining our discord server 155 | `__ to discuss trading with like-minded people 156 | and learn about temporary alternatives. Once a replacement is made available, 157 | members of that server will be the first to learn about it. 158 | 159 | 160 | ---------------- 161 | More information 162 | ---------------- 163 | 164 | You can get more information directly from TDAmeritrade and Charles Schwab at 165 | the following links: 166 | 167 | * `TDAmeritrade Transition Overview `__ at Charles Schwab 168 | * `TDAmeritrade & Charles Schwab: What to Know `__ at TDAmeritrade 169 | * `Trader API Schwab Integration Guide `__ at TDAmeritrade's developer portal -------------------------------------------------------------------------------- /docs/util.rst: -------------------------------------------------------------------------------- 1 | .. _utils: 2 | 3 | ========= 4 | Utilities 5 | ========= 6 | 7 | This section describes miscellaneous utility methods provided by ``tda-api``. 8 | All utilities are presented under the ``Utils`` class: 9 | 10 | .. autoclass:: tda.utils.Utils 11 | 12 | .. automethod:: __init__ 13 | .. automethod:: set_account_id 14 | 15 | ------------------------- 16 | Get the Most Recent Order 17 | ------------------------- 18 | 19 | For successfully placed orders, :meth:`tda.client.Client.place_order` returns 20 | the ID of the newly created order, encoded in the ``r.headers['Location']`` 21 | header. This method inspects the response and extracts the order ID from the 22 | contents, if it's there. This order ID can then be used to monitor or modify the 23 | order as described in the :ref:`Client documentation `. Example 24 | usage: 25 | 26 | .. code-block:: python 27 | 28 | # Assume client and order already exist and are valid 29 | account_id = 123456 30 | r = client.place_order(account_id, order) 31 | assert r.status_code == httpx.codes.OK, r.raise_for_status() 32 | order_id = Utils(client, account_id).extract_order_id(r) 33 | assert order_id is not None 34 | 35 | .. automethod:: tda.utils.Utils.extract_order_id 36 | -------------------------------------------------------------------------------- /examples/async/get_quote.py: -------------------------------------------------------------------------------- 1 | from urllib.request import urlopen 2 | 3 | import atexit 4 | import datetime 5 | import dateutil 6 | import sys 7 | import tda 8 | 9 | API_KEY = 'XXXXXX@AMER.OAUTHAP' 10 | REDIRECT_URI = 'http://localhost:8080/' 11 | TOKEN_PATH = 'ameritrade-credentials.json' 12 | 13 | 14 | def make_webdriver(): 15 | # Import selenium here because it's slow to import 16 | from selenium import webdriver 17 | 18 | # Choose your browser of choice by uncommenting the appropriate line. For 19 | # help, see https://selenium-python.readthedocs.io/installation.html#drivers 20 | 21 | #driver = webdriver.Chrome() 22 | #driver = webdriver.Firefox() 23 | #driver = webdriver.Safari() 24 | #driver = webdriver.Edge() 25 | 26 | atexit.register(lambda: driver.quit()) 27 | return driver 28 | 29 | 30 | # Create a new client 31 | client = tda.auth.easy_client( 32 | API_KEY, 33 | REDIRECT_URI, 34 | TOKEN_PATH, 35 | make_webdriver, asyncio=True) 36 | 37 | 38 | async def main(): 39 | r = await client.get_quote("AAPL") 40 | print(r.json()) 41 | 42 | # It is highly recommended to close your asynchronous client when you are 43 | # done with it. This step isn't strictly necessary, however not doing so 44 | # will result in warnings from the async HTTP library. 45 | await client.close_async_session() 46 | 47 | if __name__ == '__main__': 48 | import asyncio 49 | asyncio.run(main()) 50 | -------------------------------------------------------------------------------- /examples/birthday_dividends.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import datetime 3 | import dateutil 4 | import httpx 5 | import sys 6 | import tda 7 | 8 | API_KEY = 'XXXXXX' 9 | REDIRECT_URI = 'https://localhost:8080/' 10 | TOKEN_PATH = 'ameritrade-credentials.json' 11 | YOUR_BIRTHDAY = datetime.datetime(year=1969, month=4, day=20) 12 | SP500_URL = "https://tda-api.readthedocs.io/en/latest/_static/sp500.txt" 13 | 14 | 15 | def make_webdriver(): 16 | # Import selenium here because it's slow to import 17 | from selenium import webdriver 18 | 19 | driver = webdriver.Chrome() 20 | atexit.register(lambda: driver.quit()) 21 | return driver 22 | 23 | 24 | # Create a new client 25 | client = tda.auth.easy_client( 26 | API_KEY, 27 | REDIRECT_URI, 28 | TOKEN_PATH, 29 | make_webdriver) 30 | 31 | # Load S&P 500 composition from documentation 32 | sp500 = httpx.get( 33 | SP500_URL, headers={ 34 | "User-Agent": "Mozilla/5.0"}).read().decode().split() 35 | 36 | # Fetch fundamentals for all symbols and filter out the ones with ex-dividend 37 | # dates in the future and dividend payment dates on your birth month. Note we 38 | # perform the fetch in two calls because the API places an upper limit on the 39 | # number of symbols you can fetch at once. 40 | today = datetime.datetime.today() 41 | birth_month_dividends = [] 42 | for s in (sp500[:250], sp500[250:]): 43 | r = client.search_instruments( 44 | s, tda.client.Client.Instrument.Projection.FUNDAMENTAL) 45 | assert r.status_code == httpx.codes.OK, r.raise_for_status() 46 | 47 | for symbol, f in r.json().items(): 48 | 49 | # Parse ex-dividend date 50 | ex_div_string = f['fundamental']['dividendDate'] 51 | if not ex_div_string.strip(): 52 | continue 53 | ex_dividend_date = dateutil.parser.parse(ex_div_string) 54 | 55 | # Parse payment date 56 | pay_date_string = f['fundamental']['dividendPayDate'] 57 | if not pay_date_string.strip(): 58 | continue 59 | pay_date = dateutil.parser.parse(pay_date_string) 60 | 61 | # Check dates 62 | if (ex_dividend_date > today 63 | and pay_date.month == YOUR_BIRTHDAY.month): 64 | birth_month_dividends.append(symbol) 65 | 66 | if not birth_month_dividends: 67 | print('Sorry, no stocks are paying out in your birth month yet. This is ', 68 | 'most likely because the dividends haven\'t been announced yet. ', 69 | 'Try again closer to your birthday.') 70 | sys.exit(1) 71 | 72 | # Purchase one share of each the stocks that pay in your birthday month. 73 | account_id = int(input( 74 | 'Input your TDA account number to place orders ( to quit): ')) 75 | for symbol in birth_month_dividends: 76 | print('Buying one share of', symbol) 77 | 78 | # Build the order spec and place the order 79 | order = tda.orders.equities.equity_buy_market(symbol, 1) 80 | 81 | r = client.place_order(account_id, order) 82 | assert r.status_code == httpx.codes.OK, r.raise_for_status() 83 | -------------------------------------------------------------------------------- /examples/client_from_access_functions.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This example demonstrates how to create a client using the 3 | ``client_from_access_functions`` method. This is an advanced piece of 4 | functionality designed for users who want to customize the reading and writing 5 | of their tokens. Most notably, it is useful for users who want to safely store 6 | their tokens in a cloud environment where they do not have access to a 7 | persistent filesystems, such as on AWS Lambda. 8 | ''' 9 | 10 | 11 | import tda 12 | 13 | API_KEY = 'XXXXXX' 14 | 15 | 16 | # For the purposes of this demonstration, we will be storing the token object in 17 | # memory. For your use case, replace the contents of these functions with reads 18 | # and writes to your backing store. 19 | 20 | 21 | # Note we assume that the token was already created and exists in the backing 22 | # store. 23 | IN_MEMORY_TOKEN = { 24 | 'dummy': 'token', 25 | 'please': 'ignore', 26 | } 27 | 28 | def read_token(): 29 | return IN_MEMORY_TOKEN 30 | 31 | # Note the refresh_token kwarg is mandatory. It is passed by authlib on token 32 | # refreshes, and contains the refresh token that was used to generate the new 33 | # token (i.e. the first parameter). The current best practice is to ignore it, 34 | # but it's required. 35 | def write_token(token, refresh_token=None): 36 | global IN_MEMORY_TOKEN 37 | IN_MEMORY_TOKEN = token 38 | 39 | 40 | client = tda.auth.client_from_access_functions( 41 | API_KEY, 42 | token_read_func=read_token, 43 | token_write_func=write_token) 44 | -------------------------------------------------------------------------------- /examples/get_quote.py: -------------------------------------------------------------------------------- 1 | from urllib.request import urlopen 2 | 3 | import atexit 4 | import datetime 5 | import dateutil 6 | import sys 7 | import tda 8 | 9 | API_KEY = 'XXXXXX@AMER.OAUTHAP' 10 | REDIRECT_URI = 'http://localhost:8080/' 11 | TOKEN_PATH = 'ameritrade-credentials.json' 12 | 13 | 14 | def make_webdriver(): 15 | # Import selenium here because it's slow to import 16 | from selenium import webdriver 17 | 18 | driver = webdriver.Chrome() 19 | atexit.register(lambda: driver.quit()) 20 | return driver 21 | 22 | 23 | # Create a new client 24 | client = tda.auth.easy_client( 25 | API_KEY, 26 | REDIRECT_URI, 27 | TOKEN_PATH, 28 | make_webdriver) 29 | 30 | 31 | r = client.get_quote("AAPL") 32 | print(r.json()) 33 | -------------------------------------------------------------------------------- /examples/streaming/timesales.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pprint 3 | 4 | from selenium import webdriver 5 | 6 | from tda.auth import easy_client 7 | from tda.client import Client 8 | from tda.streaming import StreamClient 9 | 10 | API_KEY = "XXXXXX" 11 | ACCOUNT_ID = 1234567890 12 | 13 | 14 | class MyStreamConsumer: 15 | """ 16 | We use a class to enforce good code organization practices 17 | """ 18 | 19 | def __init__(self, api_key, account_id, queue_size=0, 20 | credentials_path='./ameritrade-credentials.json'): 21 | """ 22 | We're storing the configuration variables within the class for easy 23 | access later in the code! 24 | """ 25 | self.api_key = api_key 26 | self.account_id = account_id 27 | self.credentials_path = credentials_path 28 | self.tda_client = None 29 | self.stream_client = None 30 | self.symbols = [ 31 | 'GOOG', 'GOOGL', 'BP', 'CVS', 'ADBE', 'CRM', 'SNAP', 'AMZN', 32 | 'BABA', 'DIS', 'TWTR', 'M', 'USO', 'AAPL', 'NFLX', 'GE', 'TSLA', 33 | 'F', 'SPY', 'FDX', 'UBER', 'ROKU', 'X', 'FB', 'BIDU', 'FIT' 34 | ] 35 | 36 | # Create a queue so we can queue up work gathered from the client 37 | self.queue = asyncio.Queue(queue_size) 38 | 39 | def initialize(self): 40 | """ 41 | Create the clients and log in. Using easy_client, we can get new creds 42 | from the user via the web browser if necessary 43 | """ 44 | self.tda_client = easy_client( 45 | # You can customize your browser here 46 | webdriver_func=lambda: webdriver.Chrome(), 47 | #webdriver_func=lambda: webdriver.Firefox(), 48 | #webdriver_func=lambda: webdriver.Safari(), 49 | #webdriver_func=lambda: webdriver.Ie(), 50 | api_key=self.api_key, 51 | redirect_uri='https://localhost:8080', 52 | token_path=self.credentials_path) 53 | self.stream_client = StreamClient( 54 | self.tda_client, account_id=self.account_id) 55 | 56 | # The streaming client wants you to add a handler for every service type 57 | self.stream_client.add_timesale_equity_handler( 58 | self.handle_timesale_equity) 59 | 60 | async def stream(self): 61 | await self.stream_client.login() # Log into the streaming service 62 | await self.stream_client.quality_of_service(StreamClient.QOSLevel.EXPRESS) 63 | await self.stream_client.timesale_equity_subs(self.symbols) 64 | 65 | # Kick off our handle_queue function as an independent coroutine 66 | asyncio.ensure_future(self.handle_queue()) 67 | 68 | # Continuously handle inbound messages 69 | while True: 70 | await self.stream_client.handle_message() 71 | 72 | async def handle_timesale_equity(self, msg): 73 | """ 74 | This is where we take msgs from the streaming client and put them on a 75 | queue for later consumption. We use a queue to prevent us from wasting 76 | resources processing old data, and falling behind. 77 | """ 78 | # if the queue is full, make room 79 | if self.queue.full(): # This won't happen if the queue doesn't have a max size 80 | print('Handler queue is full. Awaiting to make room... Some messages might be dropped') 81 | await self.queue.get() 82 | await self.queue.put(msg) 83 | 84 | async def handle_queue(self): 85 | """ 86 | Here we pull messages off the queue and process them. 87 | """ 88 | while True: 89 | msg = await self.queue.get() 90 | pprint.pprint(msg) 91 | 92 | 93 | async def main(): 94 | """ 95 | Create and instantiate the consumer, and start the stream 96 | """ 97 | consumer = MyStreamConsumer(API_KEY, ACCOUNT_ID) 98 | consumer.initialize() 99 | await consumer.stream() 100 | 101 | if __name__ == '__main__': 102 | asyncio.run(main()) 103 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | python: 4 | version: 3.7 5 | install: 6 | - method: pip 7 | path: . 8 | extra_requirements: 9 | - dev 10 | -------------------------------------------------------------------------------- /scripts/functional_tests.py: -------------------------------------------------------------------------------- 1 | if __name__ == '__main__': 2 | import argparse 3 | import asyncio 4 | import json 5 | import sys 6 | import tda 7 | import unittest 8 | 9 | from tda.streaming import StreamClient 10 | 11 | tda.debug.enable_bug_report_logging() 12 | import logging 13 | logging.getLogger('').addHandler(logging.StreamHandler()) 14 | 15 | class FunctionalTests(unittest.TestCase): 16 | 17 | @classmethod 18 | def set_client(cls, client): 19 | cls.client = client 20 | 21 | 22 | @classmethod 23 | def setUpClass(cls): 24 | cls.account_id = args.account_id 25 | 26 | # Ensure we never run over an account with funds or assets under it 27 | account = cls.client.get_account(args.account_id).json() 28 | print(json.dumps(account, indent=4)) 29 | balances = account['securitiesAccount']['currentBalances'] 30 | def assert_balances(name): 31 | if balances[name] != 0: 32 | if args.allow_balances: 33 | print() 34 | print() 35 | print() 36 | print('###################################################') 37 | print() 38 | 39 | print(f'Account has nonzero \'{name}\'') 40 | print() 41 | print(f'Functional tests are being run because of ') 42 | print(f'--allow_balances.') 43 | cont = input('Please type \'continue\' to continue: ') 44 | assert cont.lower() == 'continue' 45 | 46 | print() 47 | print('###################################################') 48 | print() 49 | print() 50 | print() 51 | 52 | return True 53 | 54 | else: 55 | print() 56 | print() 57 | print() 58 | print('###################################################') 59 | print() 60 | 61 | print(f'Account has nonzero \'{name}\'') 62 | print() 63 | print(f'Functional tests aborted. You can rerun tests AT ') 64 | print(f'YOUR OWN RISK by specifying --allow_balances.') 65 | 66 | print() 67 | print('###################################################') 68 | print() 69 | print() 70 | print() 71 | 72 | return False 73 | 74 | for balance in ('liquidationValue', 75 | 'longOptionMarketValue', 76 | 'moneyMarketFund', 77 | 'cashAvailableForTrading', 78 | 'cashAvailableForWithdrawal'): 79 | if assert_balances(balance): 80 | break 81 | 82 | 83 | def test_streaming(self): 84 | print() 85 | print('##########################################################') 86 | print() 87 | print('Testing stream connection and handling...') 88 | 89 | stream_client = StreamClient(self.client, account_id=self.account_id) 90 | async def read_stream(): 91 | await stream_client.login() 92 | await stream_client.quality_of_service(StreamClient.QOSLevel.EXPRESS) 93 | 94 | stream_client.add_nasdaq_book_handler( 95 | lambda msg: print(json.dumps(msg, indent=4))) 96 | await stream_client.nasdaq_book_subs(['GOOG']) 97 | 98 | # Handle one message and then declare victory 99 | await stream_client.handle_message() 100 | 101 | asyncio.run(read_stream()) 102 | 103 | print() 104 | print('##########################################################') 105 | print() 106 | 107 | 108 | parser = argparse.ArgumentParser('Runs functional tests') 109 | parser.add_argument('--token', required=True, help='Path to token file') 110 | parser.add_argument('--account_id', type=int, required=True) 111 | parser.add_argument('--api_key', type=str, required=True) 112 | parser.add_argument('--allow_balances', action='store_true', default=False) 113 | parser.add_argument('--verbose', action='store_true', default=False) 114 | args = parser.parse_args() 115 | 116 | if args.verbose: 117 | logging.getLogger('').setLevel(logging.DEBUG) 118 | 119 | FunctionalTests.set_client( 120 | tda.auth.client_from_token_file(args.token, args.api_key)) 121 | 122 | unittest.main(argv=[sys.argv[0]]) 123 | -------------------------------------------------------------------------------- /scripts/price_history_combos.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Script to brute-force the outputs of parameters to the get_price_history method. 3 | Written as part of producing the price history helper methods and checked in for 4 | posterity and in case we need to rerun this analysis. 5 | ''' 6 | 7 | 8 | import argparse 9 | import datetime 10 | import json 11 | import sys 12 | import time 13 | 14 | from datetime import datetime, timedelta 15 | 16 | import tda 17 | from tda.client import Client 18 | 19 | 20 | parser = argparse.ArgumentParser( 21 | 'Determines which combinations of price history parameters yield '+ 22 | 'meaningful data.') 23 | 24 | parser.add_argument('--token', required=True, help='Path to token file') 25 | parser.add_argument('--account_id', type=int, required=True) 26 | parser.add_argument('--api_key', type=str, required=True) 27 | args = parser.parse_args() 28 | 29 | client = tda.auth.client_from_token_file(args.token, args.api_key) 30 | 31 | 32 | def report_candles(candles, call): 33 | 'Computes the length of the candles and the frequency of its updates.' 34 | if len(candles) <= 2: 35 | return None, None 36 | 37 | date_diffs = set() 38 | for i in range(len(candles) - 1): 39 | cur = candles[i] 40 | nxt = candles[i + 1] 41 | date_diff = (datetime.fromtimestamp(nxt['datetime'] / 1000) - 42 | datetime.fromtimestamp(cur['datetime'] / 1000)) 43 | date_diffs.add(date_diff) 44 | 45 | earliest = min( (i['datetime'] / 1000 for i in candles) ) 46 | latest = max( (i['datetime'] / 1000 for i in candles) ) 47 | 48 | return min(date_diffs), ( 49 | datetime.fromtimestamp(latest) - datetime.fromtimestamp(earliest)) 50 | 51 | 52 | def get_price_history(*args, **kwargs): 53 | 'Performs price history fetching with retry' 54 | while True: 55 | r = client.get_price_history(*args, **kwargs) 56 | if r.status_code == 429: 57 | time.sleep(60) 58 | else: 59 | return r 60 | 61 | 62 | def find_earliest_data(period_type, period, freq_type, freq): 63 | '''Performs a binary search to find the earliest data returned by the API for 64 | a given combination of input enums''' 65 | # First find the earliest day which return meaningful data 66 | def params_from_ts(dt): 67 | end = dt 68 | start = end - timedelta(days=1) 69 | return start, end 70 | 71 | max_date = datetime.now() - timedelta(days=1) 72 | min_date = max_date - timedelta(days=20*365) 73 | test_date = new_date = ( 74 | min_date + 75 | timedelta(seconds=(max_date - min_date).total_seconds()/2)) 76 | 77 | # Implements binary search over the range of possible dates 78 | def update_bounds(min_date, max_date, tried_date, success): 79 | if success: 80 | max_date = tried_date 81 | else: 82 | min_date = tried_date 83 | 84 | new_date = min_date + timedelta(seconds=(max_date - 85 | min_date).total_seconds()/2) 86 | 87 | if min(max_date - new_date, new_date - min_date) < timedelta(seconds=1): 88 | return None 89 | 90 | return min_date, max_date, new_date 91 | 92 | last_success = None 93 | 94 | while True: 95 | start, end = params_from_ts(test_date) 96 | 97 | r = get_price_history( 98 | 'AAPL', 99 | period_type=period_type, 100 | frequency_type=freq_type, 101 | frequency=freq, 102 | start_datetime=start, 103 | end_datetime=end) 104 | got_data = r.status_code==200 and not r.json()['empty'] 105 | if got_data: 106 | last_success = test_date 107 | 108 | ret = update_bounds(min_date, max_date, test_date, got_data) 109 | if ret is None: 110 | break 111 | else: 112 | min_date, max_date, test_date = ret 113 | 114 | r = get_price_history( 115 | 'AAPL', 116 | period_type=period_type, 117 | frequency_type=freq_type, 118 | frequency=freq, 119 | start_datetime=last_success, 120 | end_datetime=datetime.now()) 121 | period, duration = report_candles(r.json()['candles'], 122 | (period_type, period, freq_type, freq)) 123 | 124 | return (period, duration, 125 | datetime.fromtimestamp(r.json()['candles'][0]['datetime'] / 1000), 126 | datetime.fromtimestamp(r.json()['candles'][-1]['datetime'] / 1000)) 127 | 128 | 129 | report = {} 130 | 131 | # Brute force all combinations of enums 132 | for period_type in Client.PriceHistory.PeriodType: 133 | for period in Client.PriceHistory.Period: 134 | for freq_type in Client.PriceHistory.FrequencyType: 135 | for freq in Client.PriceHistory.Frequency: 136 | args = (period_type, period, freq_type, freq) 137 | r = get_price_history( 138 | 'AAPL', 139 | period_type=period_type, 140 | period=period, 141 | frequency_type=freq_type, 142 | frequency=freq) 143 | if r.status_code == 200: 144 | find_earliest_data(*args) 145 | report[args] = find_earliest_data(*args) 146 | else: 147 | report[args] = r.status_code 148 | print(args, r.status_code) 149 | 150 | 151 | # Emit a formatted report of the results 152 | for args in sorted(report.keys(), key=lambda k: str(k)): 153 | period_type, period, freq_type, freq = args 154 | 155 | try: 156 | period_observed, duration, min_date, max_date = report[args] 157 | 158 | print('{:<10} | {:<10} | {:<10} | {:<10} --> {}, {}'.format( 159 | str(period_type), str(period), str(freq_type), str(freq), 160 | str(period_observed), str(duration))) 161 | except TypeError: 162 | print('{:<10} | {:<10} | {:<10} | {:<10} --> {}'.format( 163 | str(period_type), str(period), str(freq_type), str(freq), 164 | report[args])) 165 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | license_file = LICENSE 6 | 7 | [check-manifest] 8 | ignore = 9 | tox.ini 10 | 11 | [flake8] 12 | exclude = 13 | tests/* 14 | max-line-length = 80 15 | max-complexity = 10 16 | 17 | [tool:brunette] 18 | line-length = 79 19 | verbose = true 20 | single-quotes = false 21 | 22 | [tool:pytest] 23 | python_files = *test*.py 24 | testpaths = tests 25 | norecursedirs=tda build dist docs htmlcov 26 | 27 | [coverage:run] 28 | branch = True 29 | 30 | [coverage:report] 31 | exclude_lines = 32 | pragma: no cover 33 | except ImportError 34 | def __repr__ 35 | raise NotImplementedError 36 | raise DeprecationWarning 37 | deprecate 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open('README.rst', 'r') as f: 4 | long_description = f.read() 5 | 6 | with open('tda/version.py', 'r') as f: 7 | '''Version looks like `version = '1.2.3'`''' 8 | version = [s.strip() for s in f.read().strip().split('=')][1] 9 | version = version[1:-1] 10 | 11 | setuptools.setup( 12 | name='tda-api', 13 | version=version, 14 | author='Alex Golec', 15 | author_email='bottomless.septic.tank@gmail.com', 16 | description='An unofficial wrapper around the TD Ameritrade HTTP API.', 17 | long_description=long_description, 18 | long_description_content_type='text/x-rst', 19 | url='https://github.com/alexgolec/tda-api', 20 | packages=setuptools.find_packages(), 21 | classifiers=[ 22 | 'Programming Language :: Python :: 3', 23 | 'License :: OSI Approved :: MIT License', 24 | 'Operating System :: OS Independent', 25 | 'Intended Audience :: Developers', 26 | 'Development Status :: 4 - Beta', 27 | 'Natural Language :: English', 28 | 'Operating System :: OS Independent', 29 | 'Topic :: Office/Business :: Financial :: Investment', 30 | ], 31 | python_requires='>=3.7', 32 | install_requires=[ 33 | 'authlib', 34 | 'autopep8', 35 | 'httpx', 36 | 'prompt_toolkit', 37 | 'python-dateutil', 38 | 'selenium', 39 | 'websockets>=9.0.0'], 40 | extras_require={ 41 | 'dev': [ 42 | 'asynctest', 43 | 'colorama', 44 | 'coverage', 45 | 'tox', 46 | 'nose', 47 | 'pytest', 48 | 'pytz', 49 | 'sphinx_rtd_theme', 50 | 'twine', 51 | ] 52 | }, 53 | keywords='finance trading equities bonds options research', 54 | project_urls={ 55 | 'Documentation': 'https://tda-api.readthedocs.io/en/latest/', 56 | 'Source': 'https://github.com/alexgolec/tda-api', 57 | 'Tracker': 'https://github.com/alexgolec/tda-api/issues', 58 | }, 59 | license='MIT', 60 | scripts=[ 61 | 'bin/tda-generate-token.py', 62 | 'bin/tda-order-codegen.py', 63 | ], 64 | ) 65 | 66 | -------------------------------------------------------------------------------- /tda/__init__.py: -------------------------------------------------------------------------------- 1 | from . import auth 2 | from . import client 3 | from . import contrib 4 | from . import debug 5 | from . import orders 6 | from . import streaming 7 | 8 | from .version import version as __version__ 9 | 10 | LOG_REDACTOR = debug.LogRedactor() 11 | -------------------------------------------------------------------------------- /tda/client/__init__.py: -------------------------------------------------------------------------------- 1 | from .synchronous import Client 2 | from .asynchronous import AsyncClient 3 | -------------------------------------------------------------------------------- /tda/client/asynchronous.py: -------------------------------------------------------------------------------- 1 | from .base import BaseClient 2 | from ..debug import register_redactions_from_response 3 | from ..utils import LazyLog 4 | 5 | import json 6 | 7 | 8 | class AsyncClient(BaseClient): 9 | 10 | async def close_async_session(self): 11 | await self.session.aclose() 12 | 13 | async def _get_request(self, path, params): 14 | self.ensure_updated_refresh_token() 15 | 16 | dest = 'https://api.tdameritrade.com' + path 17 | 18 | req_num = self._req_num() 19 | self.logger.debug('Req %s: GET to %s, params=%s', 20 | req_num, dest, LazyLog(lambda: json.dumps(params, indent=4))) 21 | 22 | resp = await self.session.get(dest, params=params) 23 | self._log_response(resp, req_num) 24 | register_redactions_from_response(resp) 25 | return resp 26 | 27 | async def _post_request(self, path, data): 28 | self.ensure_updated_refresh_token() 29 | 30 | dest = 'https://api.tdameritrade.com' + path 31 | 32 | req_num = self._req_num() 33 | self.logger.debug('Req %s: POST to %s, json=%s', 34 | req_num, dest, LazyLog(lambda: json.dumps(data, indent=4))) 35 | 36 | resp = await self.session.post(dest, json=data) 37 | self._log_response(resp, req_num) 38 | register_redactions_from_response(resp) 39 | return resp 40 | 41 | async def _put_request(self, path, data): 42 | self.ensure_updated_refresh_token() 43 | 44 | dest = 'https://api.tdameritrade.com' + path 45 | 46 | req_num = self._req_num() 47 | self.logger.debug('Req %s: PUT to %s, json=%s', 48 | req_num, dest, LazyLog(lambda: json.dumps(data, indent=4))) 49 | 50 | resp = await self.session.put(dest, json=data) 51 | self._log_response(resp, req_num) 52 | register_redactions_from_response(resp) 53 | return resp 54 | 55 | async def _patch_request(self, path, data): 56 | self.ensure_updated_refresh_token() 57 | 58 | dest = 'https://api.tdameritrade.com' + path 59 | 60 | req_num = self._req_num() 61 | self.logger.debug('Req %s: PATCH to %s, json=%s', 62 | req_num, dest, LazyLog(lambda: json.dumps(data, indent=4))) 63 | 64 | resp = await self.session.patch(dest, json=data) 65 | self._log_response(resp, req_num) 66 | register_redactions_from_response(resp) 67 | return resp 68 | 69 | async def _delete_request(self, path): 70 | self.ensure_updated_refresh_token() 71 | 72 | dest = 'https://api.tdameritrade.com' + path 73 | 74 | req_num = self._req_num() 75 | self.logger.debug('Req %s: DELETE to %s', req_num, dest) 76 | 77 | resp = await self.session.delete(dest) 78 | self._log_response(resp, req_num) 79 | register_redactions_from_response(resp) 80 | return resp 81 | -------------------------------------------------------------------------------- /tda/client/synchronous.py: -------------------------------------------------------------------------------- 1 | from .base import BaseClient 2 | from ..utils import LazyLog 3 | from ..debug import register_redactions_from_response 4 | 5 | import json 6 | 7 | 8 | class Client(BaseClient): 9 | def _get_request(self, path, params): 10 | self.ensure_updated_refresh_token() 11 | 12 | dest = 'https://api.tdameritrade.com' + path 13 | 14 | req_num = self._req_num() 15 | self.logger.debug('Req %s: GET to %s, params=%s', 16 | req_num, dest, LazyLog(lambda: json.dumps(params, indent=4))) 17 | 18 | resp = self.session.get(dest, params=params) 19 | self._log_response(resp, req_num) 20 | register_redactions_from_response(resp) 21 | return resp 22 | 23 | def _post_request(self, path, data): 24 | self.ensure_updated_refresh_token() 25 | 26 | dest = 'https://api.tdameritrade.com' + path 27 | 28 | req_num = self._req_num() 29 | self.logger.debug('Req %s: POST to %s, json=%s', 30 | req_num, dest, LazyLog(lambda: json.dumps(data, indent=4))) 31 | 32 | resp = self.session.post(dest, json=data) 33 | self._log_response(resp, req_num) 34 | register_redactions_from_response(resp) 35 | return resp 36 | 37 | def _put_request(self, path, data): 38 | self.ensure_updated_refresh_token() 39 | 40 | dest = 'https://api.tdameritrade.com' + path 41 | 42 | req_num = self._req_num() 43 | self.logger.debug('Req %s: PUT to %s, json=%s', 44 | req_num, dest, LazyLog(lambda: json.dumps(data, indent=4))) 45 | 46 | resp = self.session.put(dest, json=data) 47 | self._log_response(resp, req_num) 48 | register_redactions_from_response(resp) 49 | return resp 50 | 51 | def _patch_request(self, path, data): 52 | self.ensure_updated_refresh_token() 53 | 54 | dest = 'https://api.tdameritrade.com' + path 55 | 56 | req_num = self._req_num() 57 | self.logger.debug('Req %s: PATCH to %s, json=%s', 58 | req_num, dest, LazyLog(lambda: json.dumps(data, indent=4))) 59 | 60 | resp = self.session.patch(dest, json=data) 61 | self._log_response(resp, req_num) 62 | register_redactions_from_response(resp) 63 | return resp 64 | 65 | def _delete_request(self, path): 66 | self.ensure_updated_refresh_token() 67 | 68 | dest = 'https://api.tdameritrade.com' + path 69 | 70 | req_num = self._req_num() 71 | self.logger.debug('Req %s: DELETE to %s'.format(req_num, dest)) 72 | 73 | resp = self.session.delete(dest) 74 | self._log_response(resp, req_num) 75 | register_redactions_from_response(resp) 76 | return resp 77 | -------------------------------------------------------------------------------- /tda/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | from . import orders, util 2 | -------------------------------------------------------------------------------- /tda/contrib/orders.py: -------------------------------------------------------------------------------- 1 | import autopep8 2 | import tda 3 | 4 | from tda.orders.generic import OrderBuilder 5 | from tda.orders.common import ( 6 | EquityInstrument, 7 | OptionInstrument, 8 | OrderStrategyType, 9 | ) 10 | 11 | from collections import defaultdict 12 | 13 | 14 | def _call_setters_with_values(order, builder): 15 | ''' 16 | For each field in fields_and_setters, if it exists as a key in the order 17 | object, pass its value to the appropriate setter on the order builder. 18 | ''' 19 | for field_name, setter_name, enum_class in _FIELDS_AND_SETTERS: 20 | try: 21 | value = order[field_name] 22 | except KeyError: 23 | continue 24 | 25 | if enum_class: 26 | value = enum_class[value] 27 | 28 | setter = getattr(builder, setter_name) 29 | setter(value) 30 | 31 | 32 | # Top-level fields 33 | _FIELDS_AND_SETTERS = ( 34 | ('session', 'set_session', tda.orders.common.Session), 35 | ('duration', 'set_duration', tda.orders.common.Duration), 36 | ('orderType', 'set_order_type', tda.orders.common.OrderType), 37 | ('complexOrderStrategyType', 'set_complex_order_strategy_type', 38 | tda.orders.common.ComplexOrderStrategyType), 39 | ('quantity', 'set_quantity', None), 40 | ('requestedDestination', 'set_requested_destination', 41 | tda.orders.common.Destination), 42 | ('stopPrice', 'copy_stop_price', None), 43 | ('stopPriceLinkBasis', 'set_stop_price_link_basis', 44 | tda.orders.common.StopPriceLinkBasis), 45 | ('stopPriceLinkType', 'set_stop_price_link_type', 46 | tda.orders.common.StopPriceLinkType), 47 | ('stopPriceOffset', 'set_stop_price_offset', None), 48 | ('stopType', 'set_stop_type', tda.orders.common.StopType), 49 | ('priceLinkBasis', 'set_price_link_basis', 50 | tda.orders.common.PriceLinkBasis), 51 | ('priceLinkType', 'set_price_link_type', 52 | tda.orders.common.PriceLinkType), 53 | ('price', 'copy_price', None), 54 | ('activationPrice', 'set_activation_price', None), 55 | ('specialInstruction', 'set_special_instruction', 56 | tda.orders.common.SpecialInstruction), 57 | ('orderStrategyType', 'set_order_strategy_type', 58 | tda.orders.common.OrderStrategyType), 59 | ) 60 | 61 | def construct_repeat_order(historical_order): 62 | builder = tda.orders.generic.OrderBuilder() 63 | 64 | # Top-level fields 65 | _call_setters_with_values(historical_order, builder) 66 | 67 | # Composite orders 68 | if 'orderStrategyType' in historical_order: 69 | if historical_order['orderStrategyType'] == 'TRIGGER': 70 | builder = tda.orders.common.first_triggers_second( 71 | builder, construct_repeat_order( 72 | historical_order['childOrderStrategies'][0])) 73 | elif historical_order['orderStrategyType'] == 'OCO': 74 | builder = tda.orders.common.one_cancels_other( 75 | construct_repeat_order( 76 | historical_order['childOrderStrategies'][0]), 77 | construct_repeat_order( 78 | historical_order['childOrderStrategies'][1])) 79 | else: 80 | raise ValueError('historical order is missing orderStrategyType') 81 | 82 | # Order legs 83 | if 'orderLegCollection' in historical_order: 84 | for leg in historical_order['orderLegCollection']: 85 | if leg['orderLegType'] == 'EQUITY': 86 | builder.add_equity_leg( 87 | tda.orders.common.EquityInstruction[leg['instruction']], 88 | leg['instrument']['symbol'], 89 | leg['quantity']) 90 | elif leg['orderLegType'] == 'OPTION': 91 | builder.add_option_leg( 92 | tda.orders.common.OptionInstruction[leg['instruction']], 93 | leg['instrument']['symbol'], 94 | leg['quantity']) 95 | else: 96 | raise ValueError( 97 | 'unknown orderLegType {}'.format(leg['orderLegType'])) 98 | 99 | return builder 100 | 101 | 102 | ################################################################################ 103 | # AST generation 104 | 105 | 106 | def code_for_builder(builder, var_name=None): 107 | ''' 108 | Returns code that can be executed to construct the given builder, including 109 | import statements. 110 | 111 | :param builder: :class:`~tda.orders.generic.OrderBuilder` to generate. 112 | :param var_name: If set, emit code that assigns the builder to a variable 113 | with this name. 114 | ''' 115 | ast = construct_order_ast(builder) 116 | 117 | imports = defaultdict(set) 118 | lines = [] 119 | ast.render(imports, lines) 120 | 121 | import_lines = [] 122 | for module, names in imports.items(): 123 | line = 'from {} import {}'.format( 124 | module, ', '.join(names)) 125 | if len(line) > 80: 126 | line = 'from {} import (\n{}\n)'.format( 127 | module, ',\n'.join(names)) 128 | import_lines.append(line) 129 | 130 | if var_name: 131 | var_prefix = f'{var_name} = ' 132 | else: 133 | var_prefix = '' 134 | 135 | return autopep8.fix_code( 136 | '\n'.join(import_lines) + 137 | '\n\n' + 138 | var_prefix + 139 | '\n'.join(lines)) 140 | 141 | 142 | class FirstTriggersSecondAST: 143 | def __init__(self, first, second): 144 | self.first = first 145 | self.second = second 146 | 147 | def render(self, imports, lines, paren_depth=0): 148 | imports['tda.orders.common'].add('first_triggers_second') 149 | 150 | lines.append('first_triggers_second(') 151 | self.first.render(imports, lines, paren_depth + 1) 152 | lines[-1] += ',' 153 | self.second.render(imports, lines, paren_depth + 2) 154 | lines.append(')') 155 | 156 | 157 | class OneCancelsOtherAST: 158 | def __init__(self, one, other): 159 | self.one = one 160 | self.other = other 161 | 162 | def render(self, imports, lines, paren_depth=0): 163 | imports['tda.orders.common'].add('one_cancels_other') 164 | 165 | lines.append('one_cancels_other(') 166 | self.one.render(imports, lines, paren_depth + 1) 167 | lines[-1] += ',' 168 | self.other.render(imports, lines, paren_depth + 2) 169 | lines.append(')') 170 | 171 | 172 | class FieldAST: 173 | def __init__(self, setter_name, enum_type, value): 174 | self.setter_name = setter_name 175 | self.enum_type = enum_type 176 | self.value = value 177 | 178 | def render(self, imports, lines, paren_depth=0): 179 | value = self.value 180 | if self.enum_type: 181 | imports[self.enum_type.__module__].add(self.enum_type.__qualname__) 182 | value = self.enum_type.__qualname__ + '.' + value 183 | 184 | lines.append(f'.{self.setter_name}({value})') 185 | 186 | 187 | class EquityOrderLegAST: 188 | def __init__(self, instruction, symbol, quantity): 189 | self.instruction = instruction 190 | self.symbol = symbol 191 | self.quantity = quantity 192 | 193 | def render(self, imports, lines, paren_depth=0): 194 | imports['tda.orders.common'].add('EquityInstruction') 195 | lines.append('.add_equity_leg(EquityInstruction.{}, "{}", {})'.format( 196 | self.instruction, self.symbol, self.quantity)) 197 | 198 | 199 | class OptionOrderLegAST: 200 | def __init__(self, instruction, symbol, quantity): 201 | self.instruction = instruction 202 | self.symbol = symbol 203 | self.quantity = quantity 204 | 205 | def render(self, imports, lines, paren_depth=0): 206 | imports['tda.orders.common'].add('OptionInstruction') 207 | lines.append('.add_option_leg(OptionInstruction.{}, "{}", {})'.format( 208 | self.instruction, self.symbol, self.quantity)) 209 | 210 | 211 | class GenericBuilderAST: 212 | def __init__(self, builder): 213 | self.top_level_fields = [] 214 | for name, setter, enum_type in sorted(_FIELDS_AND_SETTERS): 215 | value = getattr(builder, '_'+name) 216 | if value is not None: 217 | self.top_level_fields.append(FieldAST(setter, enum_type, value)) 218 | 219 | for leg in builder._orderLegCollection: 220 | if leg['instrument']._assetType == 'EQUITY': 221 | self.top_level_fields.append(EquityOrderLegAST( 222 | leg['instruction'], leg['instrument']._symbol, 223 | leg['quantity'])) 224 | elif leg['instrument']._assetType == 'OPTION': 225 | self.top_level_fields.append(OptionOrderLegAST( 226 | leg['instruction'], leg['instrument']._symbol, 227 | leg['quantity'])) 228 | else: 229 | raise ValueError('unknown leg asset type {}'.format( 230 | leg['instrument']._assetType)) 231 | 232 | def render(self, imports, lines, paren_depth=0): 233 | imports['tda.orders.generic'].add('OrderBuilder') 234 | 235 | lines.append('OrderBuilder() \\') 236 | for idx, field in enumerate(self.top_level_fields): 237 | field.render(imports, lines, paren_depth) 238 | 239 | if paren_depth == 0 and idx != len(self.top_level_fields) - 1: 240 | lines[-1] += ' \\' 241 | 242 | 243 | def construct_order_ast(builder): 244 | if builder._orderStrategyType == 'OCO': 245 | return OneCancelsOtherAST( 246 | construct_order_ast(builder._childOrderStrategies[0]), 247 | construct_order_ast(builder._childOrderStrategies[1])) 248 | elif builder._orderStrategyType == 'TRIGGER': 249 | return FirstTriggersSecondAST( 250 | GenericBuilderAST(builder), 251 | construct_order_ast(builder._childOrderStrategies[0])) 252 | else: 253 | return GenericBuilderAST(builder) 254 | -------------------------------------------------------------------------------- /tda/contrib/util.py: -------------------------------------------------------------------------------- 1 | import json 2 | from tda.streaming import StreamJsonDecoder 3 | 4 | 5 | class HeuristicJsonDecoder(StreamJsonDecoder): 6 | def decode_json_string(self, raw): 7 | ''' 8 | Attempts the following, in order: 9 | 10 | 1. Return the JSON decoding of the raw string. 11 | 2. Replace all instances of ``\\\\\\\\`` with ``\\\\`` and return the 12 | decoding. 13 | 14 | Note alternative (and potentially expensive) transformations are only 15 | performed when ``JSONDecodeError`` exceptions are raised by earlier 16 | stages. 17 | ''' 18 | 19 | # Note "no cover" pragmas are added pending addition of real-world test 20 | # cases which trigger this issue. 21 | 22 | try: 23 | return json.loads(raw) 24 | except json.decoder.JSONDecodeError: # pragma: no cover 25 | raw = raw.replace('\\\\', '\\') 26 | 27 | return json.loads(raw) # pragma: no cover 28 | -------------------------------------------------------------------------------- /tda/debug.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import httpx 3 | import logging 4 | import sys 5 | import tda 6 | 7 | # This creates a tuple of JSONDecodeError types as a mechanism to catch multiple 8 | # errors. This is needed because currently the `requests` library either uses 9 | # the `simplejson` library or the builtin `json` library. This tuple-based 10 | # approach makes it somewhat future-proof. 11 | # Note: the `requests` is migrating to always use the builtin `json` library. 12 | import json.decoder 13 | try: 14 | import simplejson.errors 15 | __json_errors = (json.decoder.JSONDecodeError, 16 | simplejson.errors.JSONDecodeError) 17 | except ImportError: 18 | __json_errors = (json.decoder.JSONDecodeError,) 19 | 20 | 21 | def get_logger(): 22 | return logging.getLogger(__name__) 23 | 24 | 25 | class LogRedactor: 26 | ''' 27 | Collects strings that should not be emitted and replaces them with safe 28 | placeholders. 29 | ''' 30 | 31 | def __init__(self): 32 | from collections import defaultdict 33 | 34 | self.redacted_strings = {} 35 | self.label_counts = defaultdict(int) 36 | 37 | def register(self, string, label): 38 | ''' 39 | Registers a string that should not be emitted and the label with with 40 | which it should be replaced. 41 | ''' 42 | string = str(string) 43 | if string not in self.redacted_strings: 44 | self.label_counts[label] += 1 45 | self.redacted_strings[string] = (label, self.label_counts[label]) 46 | 47 | def redact(self, msg): 48 | ''' 49 | Scans the string for secret strings and returns a sanitized version with 50 | the secrets replaced with placeholders. 51 | ''' 52 | for string, label in self.redacted_strings.items(): 53 | label, count = label 54 | msg = msg.replace(string, ''.format( 55 | label, '-{}'.format(count) if 56 | self.label_counts[label] > 1 else '')) 57 | return msg 58 | 59 | 60 | def register_redactions_from_response(resp): 61 | ''' 62 | Convenience method that calls ``register_redactions`` if resp represents a 63 | successful response. Note this method assumes that resp has a JSON contents. 64 | ''' 65 | if resp.status_code == httpx.codes.OK: 66 | try: 67 | register_redactions(resp.json()) 68 | except __json_errors: 69 | pass 70 | 71 | 72 | def register_redactions(obj, key_path=None, 73 | bad_patterns=[ 74 | 'auth', 'acl', 'displayname', 'id', 'key', 'token'], 75 | whitelisted=set([ 76 | 'requestid', 77 | 'token_type', 78 | 'legid', 79 | 'bidid', 80 | 'askid', 81 | 'lastid', 82 | 'bidsizeinlong', 83 | 'bidsizeindouble', 84 | 'bidpriceindouble'])): 85 | ''' 86 | Recursively iterates through the leaf elements of ``obj`` and registers 87 | elements with keys matching a blacklist with the global ``Redactor``. 88 | ''' 89 | if key_path is None: 90 | key_path = [] 91 | 92 | if isinstance(obj, list): 93 | for idx, value in enumerate(obj): 94 | key_path.append(str(idx)) 95 | register_redactions(value, key_path, bad_patterns, whitelisted) 96 | key_path.pop() 97 | elif isinstance(obj, dict): 98 | for key, value in obj.items(): 99 | key_path.append(key) 100 | register_redactions(value, key_path, bad_patterns, whitelisted) 101 | key_path.pop() 102 | else: 103 | if key_path: 104 | last_key = key_path[-1].lower() 105 | if last_key in whitelisted: 106 | return 107 | elif any(bad in last_key for bad in bad_patterns): 108 | tda.LOG_REDACTOR.register(obj, '-'.join(key_path)) 109 | 110 | 111 | def enable_bug_report_logging(): 112 | ''' 113 | Turns on bug report logging. Will collect all logged output, redact out 114 | anything that should be kept secret, and emit the result at program exit. 115 | 116 | Notes: 117 | * This method does a best effort redaction. Never share its output 118 | without verifying that all secret information is properly redacted. 119 | * Because this function records all logged output, it has a performance 120 | penalty. It should not be called in production code. 121 | ''' 122 | _enable_bug_report_logging() 123 | 124 | 125 | def _enable_bug_report_logging(output=sys.stderr, loggers=None): 126 | ''' 127 | Module-internal version of :func:`enable_bug_report_logging`, intended for 128 | use in tests. 129 | ''' 130 | if loggers is None: 131 | loggers = ( 132 | tda.auth.get_logger(), 133 | tda.client.base.get_logger(), 134 | tda.streaming.get_logger(), 135 | get_logger()) 136 | 137 | class RecordingHandler(logging.Handler): 138 | def __init__(self, *args, **kwargs): 139 | super().__init__(*args, **kwargs) 140 | self.messages = [] 141 | 142 | def emit(self, record): 143 | self.messages.append(self.format(record)) 144 | 145 | handler = RecordingHandler() 146 | handler.setFormatter(logging.Formatter( 147 | '[%(filename)s:%(lineno)s:%(funcName)s] %(message)s')) 148 | 149 | for logger in loggers: 150 | logger.setLevel(logging.DEBUG) 151 | logger.addHandler(handler) 152 | 153 | def write_logs(): 154 | print(file=output) 155 | print(' ### BEGIN REDACTED LOGS ###', file=output) 156 | print(file=output) 157 | 158 | for msg in handler.messages: 159 | msg = tda.LOG_REDACTOR.redact(msg) 160 | print(msg, file=output) 161 | atexit.register(write_logs) 162 | 163 | get_logger().debug('tda-api version %s', tda.__version__) 164 | 165 | return write_logs 166 | -------------------------------------------------------------------------------- /tda/orders/__init__.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from . import common 4 | from . import equities 5 | from . import generic 6 | from . import options 7 | 8 | import sys 9 | assert sys.version_info[0] >= 3 10 | 11 | __error_message = ( 12 | 'EquityOrderBuilder has been deleted from the library. Please use ' + 13 | 'OrderBuilder and its associated templates instead. See here for ' + 14 | 'details: https://tda-api.readthedocs.io/en/latest/' + 15 | 'order-templates.html#what-happened-to-equityorderbuilder') 16 | 17 | if sys.version_info[1] >= 7: 18 | def __getattr__(name): 19 | if name == 'EquityOrderBuilder': 20 | raise ImportError(__error_message) 21 | raise AttributeError(name) 22 | else: # pragma: no cover 23 | class EquityOrderBuilder: 24 | def __init__(self, *args, **kwargs): 25 | raise NotImplementedError(globals()['__error_message']) 26 | -------------------------------------------------------------------------------- /tda/orders/common.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class __BaseInstrument: 5 | def __init__(self, asset_type, symbol): 6 | self._assetType = asset_type 7 | self._symbol = symbol 8 | 9 | 10 | class EquityInstrument(__BaseInstrument): 11 | '''Represents an equity when creating order legs.''' 12 | 13 | def __init__(self, symbol): 14 | super().__init__('EQUITY', symbol) 15 | 16 | 17 | class OptionInstrument(__BaseInstrument): 18 | '''Represents an option when creating order legs.''' 19 | 20 | def __init__(self, symbol): 21 | super().__init__('OPTION', symbol) 22 | 23 | 24 | class InvalidOrderException(Exception): 25 | '''Raised when attempting to build an incomplete order''' 26 | pass 27 | 28 | 29 | class Duration(Enum): 30 | ''' 31 | Length of time over which the trade will be active. 32 | ''' 33 | #: Cancel the trade at the end of the trading day. Note if the order cannot 34 | #: be filled all at once, you may see partial executions throughout the day. 35 | DAY = 'DAY' 36 | 37 | # TODO: Implement order cancellation date 38 | 39 | #: Keep the trade open for six months, or until the end of the cancel date, 40 | #: whichever is shorter. Note if the order cannot be filled all at once, you 41 | #: may see partial executions over the lifetime of the order. 42 | GOOD_TILL_CANCEL = 'GOOD_TILL_CANCEL' 43 | 44 | #: Either execute the order immediately at the specified price, or cancel it 45 | #: immediately. 46 | FILL_OR_KILL = 'FILL_OR_KILL' 47 | 48 | 49 | class Session(Enum): 50 | ''' 51 | The market session during which the order trade should be executed. 52 | ''' 53 | 54 | #: Normal market hours, from 9:30am to 4:00pm Eastern. 55 | NORMAL = 'NORMAL' 56 | 57 | #: Premarket session, from 8:00am to 9:30am Eastern. 58 | AM = 'AM' 59 | 60 | #: After-market session, from 4:00pm to 8:00pm Eastern. 61 | PM = 'PM' 62 | 63 | #: Orders are active during all trading sessions except the overnight 64 | #: session. This is the union of ``NORMAL``, ``AM``, and ``PM``. 65 | SEAMLESS = 'SEAMLESS' 66 | 67 | 68 | class OrderType(Enum): 69 | ''' 70 | Type of equity or option order to place. 71 | ''' 72 | 73 | #: Execute the order immediately at the best-available price. 74 | #: `More Info `__. 75 | MARKET = 'MARKET' 76 | 77 | #: Execute the order at your price or better. 78 | #: `More info `__. 79 | LIMIT = 'LIMIT' 80 | 81 | #: Wait until the price reaches the stop price, and then immediately place a 82 | #: market order. 83 | #: `More Info `__. 84 | STOP = 'STOP' 85 | 86 | #: Wait until the price reaches the stop price, and then immediately place a 87 | #: limit order at the specified price. `More Info 88 | #: `__. 89 | STOP_LIMIT = 'STOP_LIMIT' 90 | 91 | #: Similar to ``STOP``, except if the price moves in your favor, the stop 92 | #: price is adjusted in that direction. Places a market order if the stop 93 | #: condition is met. 94 | #: `More info `__. 95 | TRAILING_STOP = 'TRAILING_STOP' 96 | 97 | #: Similar to ``STOP_LIMIT``, except if the price moves in your favor, the 98 | #: stop price is adjusted in that direction. Places a limit order at the 99 | #: specified price if the stop condition is met. 100 | #: `More info `__. 101 | TRAILING_STOP_LIMIT = 'TRAILING_STOP_LIMIT' 102 | 103 | #: Place the order at the closing price immediately upon market close. 104 | #: `More info `__ 105 | MARKET_ON_CLOSE = 'MARKET_ON_CLOSE' 106 | 107 | #: Exercise an option. 108 | EXERCISE = 'EXERCISE' 109 | 110 | #: Place an order for an options spread resulting in a net debit. 111 | #: `More info `__ 113 | NET_DEBIT = 'NET_DEBIT' 114 | 115 | #: Place an order for an options spread resulting in a net credit. 116 | #: `More info `__ 118 | NET_CREDIT = 'NET_CREDIT' 119 | 120 | #: Place an order for an options spread resulting in neither a credit nor a 121 | #: debit. 122 | #: `More info `__ 124 | NET_ZERO = 'NET_ZERO' 125 | 126 | 127 | class ComplexOrderStrategyType(Enum): 128 | ''' 129 | Explicit order strategies for executing multi-leg options orders. 130 | ''' 131 | 132 | #: No complex order strategy. This is the default. 133 | NONE = 'NONE' 134 | 135 | #: `Covered call `__ 137 | COVERED = 'COVERED' 138 | 139 | #: `Vertical spread `__ 141 | VERTICAL = 'VERTICAL' 142 | 143 | #: `Ratio backspread `__ 145 | BACK_RATIO = 'BACK_RATIO' 146 | 147 | #: `Calendar spread `__ 149 | CALENDAR = 'CALENDAR' 150 | 151 | #: `Diagonal spread `__ 153 | DIAGONAL = 'DIAGONAL' 154 | 155 | #: `Straddle spread `__ 157 | STRADDLE = 'STRADDLE' 158 | 159 | #: `Strandle spread `__ 161 | STRANGLE = 'STRANGLE' 162 | 163 | COLLAR_SYNTHETIC = 'COLLAR_SYNTHETIC' 164 | 165 | #: `Butterfly spread `__ 167 | BUTTERFLY = 'BUTTERFLY' 168 | 169 | #: `Condor spread `__ 171 | CONDOR = 'CONDOR' 172 | 173 | #: `Iron condor spread `__ 175 | IRON_CONDOR = 'IRON_CONDOR' 176 | 177 | #: `Roll a vertical spread `__ 179 | VERTICAL_ROLL = 'VERTICAL_ROLL' 180 | 181 | #: `Collar strategy `__ 183 | COLLAR_WITH_STOCK = 'COLLAR_WITH_STOCK' 184 | 185 | #: `Double diagonal spread `__ 187 | DOUBLE_DIAGONAL = 'DOUBLE_DIAGONAL' 188 | 189 | #: `Unbalanced butterfy spread `__ 191 | UNBALANCED_BUTTERFLY = 'UNBALANCED_BUTTERFLY' 192 | UNBALANCED_CONDOR = 'UNBALANCED_CONDOR' 193 | UNBALANCED_IRON_CONDOR = 'UNBALANCED_IRON_CONDOR' 194 | UNBALANCED_VERTICAL_ROLL = 'UNBALANCED_VERTICAL_ROLL' 195 | 196 | #: A custom multi-leg order strategy. 197 | CUSTOM = 'CUSTOM' 198 | 199 | 200 | class Destination(Enum): 201 | ''' 202 | Destinations for when you want to request a specific destination for your 203 | order. 204 | ''' 205 | 206 | INET = 'INET' 207 | ECN_ARCA = 'ECN_ARCA' 208 | CBOE = 'CBOE' 209 | AMEX = 'AMEX' 210 | PHLX = 'PHLX' 211 | ISE = 'ISE' 212 | BOX = 'BOX' 213 | NYSE = 'NYSE' 214 | NASDAQ = 'NASDAQ' 215 | BATS = 'BATS' 216 | C2 = 'C2' 217 | AUTO = 'AUTO' 218 | 219 | 220 | class StopPriceLinkBasis(Enum): 221 | MANUAL = 'MANUAL' 222 | BASE = 'BASE' 223 | TRIGGER = 'TRIGGER' 224 | LAST = 'LAST' 225 | BID = 'BID' 226 | ASK = 'ASK' 227 | ASK_BID = 'ASK_BID' 228 | MARK = 'MARK' 229 | AVERAGE = 'AVERAGE' 230 | 231 | 232 | class StopPriceLinkType(Enum): 233 | VALUE = 'VALUE' 234 | PERCENT = 'PERCENT' 235 | TICK = 'TICK' 236 | 237 | 238 | class StopType(Enum): 239 | STANDARD = 'STANDARD' 240 | BID = 'BID' 241 | ASK = 'ASK' 242 | LAST = 'LAST' 243 | MARK = 'MARK' 244 | 245 | 246 | class PriceLinkBasis(Enum): 247 | MANUAL = 'MANUAL' 248 | BASE = 'BASE' 249 | TRIGGER = 'TRIGGER' 250 | LAST = 'LAST' 251 | BID = 'BID' 252 | ASK = 'ASK' 253 | ASK_BID = 'ASK_BID' 254 | MARK = 'MARK' 255 | AVERAGE = 'AVERAGE' 256 | 257 | 258 | class PriceLinkType(Enum): 259 | VALUE = 'VALUE' 260 | PERCENT = 'PERCENT' 261 | TICK = 'TICK' 262 | 263 | 264 | class EquityInstruction(Enum): 265 | ''' 266 | Instructions for opening and closing equity positions. 267 | ''' 268 | 269 | #: Open a long equity position 270 | BUY = 'BUY' 271 | 272 | #: Close a long equity position 273 | SELL = 'SELL' 274 | 275 | #: Open a short equity position 276 | SELL_SHORT = 'SELL_SHORT' 277 | 278 | #: Close a short equity position 279 | BUY_TO_COVER = 'BUY_TO_COVER' 280 | 281 | 282 | class OptionInstruction(Enum): 283 | ''' 284 | Instructions for opening and closing options positions. 285 | ''' 286 | #: Enter a new long option position 287 | BUY_TO_OPEN = 'BUY_TO_OPEN' 288 | 289 | #: Exit an existing long option position 290 | SELL_TO_CLOSE = 'SELL_TO_CLOSE' 291 | 292 | #: Enter a short position in an option 293 | SELL_TO_OPEN = 'SELL_TO_OPEN' 294 | 295 | #: Exit an existing short position in an option 296 | BUY_TO_CLOSE = 'BUY_TO_CLOSE' 297 | 298 | 299 | class SpecialInstruction(Enum): 300 | ''' 301 | Special instruction for trades. 302 | ''' 303 | 304 | #: Disallow partial order execution. 305 | #: `More info `__. 306 | ALL_OR_NONE = 'ALL_OR_NONE' 307 | 308 | #: Do not reduce order size in response to cash dividends. 309 | #: `More info `__. 310 | DO_NOT_REDUCE = 'DO_NOT_REDUCE' 311 | 312 | #: Combination of ``ALL_OR_NONE`` and ``DO_NOT_REDUCE``. 313 | ALL_OR_NONE_DO_NOT_REDUCE = 'ALL_OR_NONE_DO_NOT_REDUCE' 314 | 315 | 316 | class OrderStrategyType(Enum): 317 | ''' 318 | Rules for composite orders. 319 | ''' 320 | 321 | #: No chaining, only a single order is submitted 322 | SINGLE = 'SINGLE' 323 | 324 | #: Execution of one order cancels the other 325 | OCO = 'OCO' 326 | 327 | #: Execution of one order triggers placement of the other 328 | TRIGGER = 'TRIGGER' 329 | 330 | 331 | def one_cancels_other(order1, order2): 332 | ''' 333 | If one of the orders is executed, immediately cancel the other. 334 | ''' 335 | from tda.orders.generic import OrderBuilder 336 | 337 | return (OrderBuilder() 338 | .set_order_strategy_type(OrderStrategyType.OCO) 339 | .add_child_order_strategy(order1) 340 | .add_child_order_strategy(order2)) 341 | 342 | 343 | def first_triggers_second(first_order, second_order): 344 | ''' 345 | If ``first_order`` is executed, immediately place ``second_order``. 346 | ''' 347 | from tda.orders.generic import OrderBuilder 348 | 349 | return (first_order 350 | .set_order_strategy_type(OrderStrategyType.TRIGGER) 351 | .add_child_order_strategy(second_order)) 352 | -------------------------------------------------------------------------------- /tda/orders/equities.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from tda.orders.common import Duration, Session 4 | 5 | 6 | ########################################################################## 7 | # Buy orders 8 | 9 | 10 | def equity_buy_market(symbol, quantity): 11 | ''' 12 | Returns a pre-filled :class:`~tda.orders.generic.OrderBuilder` for an equity 13 | buy market order. 14 | ''' 15 | from tda.orders.common import Duration, EquityInstruction 16 | from tda.orders.common import OrderStrategyType, OrderType, Session 17 | from tda.orders.generic import OrderBuilder 18 | 19 | return (OrderBuilder() 20 | .set_order_type(OrderType.MARKET) 21 | .set_session(Session.NORMAL) 22 | .set_duration(Duration.DAY) 23 | .set_order_strategy_type(OrderStrategyType.SINGLE) 24 | .add_equity_leg(EquityInstruction.BUY, symbol, quantity)) 25 | 26 | 27 | def equity_buy_limit(symbol, quantity, price): 28 | ''' 29 | Returns a pre-filled :class:`~tda.orders.generic.OrderBuilder` for an equity 30 | buy limit order. 31 | ''' 32 | from tda.orders.common import Duration, EquityInstruction 33 | from tda.orders.common import OrderStrategyType, OrderType, Session 34 | from tda.orders.generic import OrderBuilder 35 | 36 | return (OrderBuilder() 37 | .set_order_type(OrderType.LIMIT) 38 | .set_price(price) 39 | .set_session(Session.NORMAL) 40 | .set_duration(Duration.DAY) 41 | .set_order_strategy_type(OrderStrategyType.SINGLE) 42 | .add_equity_leg(EquityInstruction.BUY, symbol, quantity)) 43 | 44 | ########################################################################## 45 | # Sell orders 46 | 47 | 48 | def equity_sell_market(symbol, quantity): 49 | ''' 50 | Returns a pre-filled :class:`~tda.orders.generic.OrderBuilder` for an equity 51 | sell market order. 52 | ''' 53 | from tda.orders.common import Duration, EquityInstruction 54 | from tda.orders.common import OrderStrategyType, OrderType, Session 55 | from tda.orders.generic import OrderBuilder 56 | 57 | return (OrderBuilder() 58 | .set_order_type(OrderType.MARKET) 59 | .set_session(Session.NORMAL) 60 | .set_duration(Duration.DAY) 61 | .set_order_strategy_type(OrderStrategyType.SINGLE) 62 | .add_equity_leg(EquityInstruction.SELL, symbol, quantity)) 63 | 64 | 65 | def equity_sell_limit(symbol, quantity, price): 66 | ''' 67 | Returns a pre-filled :class:`~tda.orders.generic.OrderBuilder` for an equity 68 | sell limit order. 69 | ''' 70 | from tda.orders.common import Duration, EquityInstruction 71 | from tda.orders.common import OrderStrategyType, OrderType, Session 72 | from tda.orders.generic import OrderBuilder 73 | 74 | return (OrderBuilder() 75 | .set_order_type(OrderType.LIMIT) 76 | .set_price(price) 77 | .set_session(Session.NORMAL) 78 | .set_duration(Duration.DAY) 79 | .set_order_strategy_type(OrderStrategyType.SINGLE) 80 | .add_equity_leg(EquityInstruction.SELL, symbol, quantity)) 81 | 82 | ########################################################################## 83 | # Short sell orders 84 | 85 | 86 | def equity_sell_short_market(symbol, quantity): 87 | ''' 88 | Returns a pre-filled :class:`~tda.orders.generic.OrderBuilder` for an equity 89 | short sell market order. 90 | ''' 91 | from tda.orders.common import Duration, EquityInstruction 92 | from tda.orders.common import OrderStrategyType, OrderType, Session 93 | from tda.orders.generic import OrderBuilder 94 | 95 | return (OrderBuilder() 96 | .set_order_type(OrderType.MARKET) 97 | .set_session(Session.NORMAL) 98 | .set_duration(Duration.DAY) 99 | .set_order_strategy_type(OrderStrategyType.SINGLE) 100 | .add_equity_leg(EquityInstruction.SELL_SHORT, symbol, quantity)) 101 | 102 | 103 | def equity_sell_short_limit(symbol, quantity, price): 104 | ''' 105 | Returns a pre-filled :class:`~tda.orders.generic.OrderBuilder` for an equity 106 | short sell limit order. 107 | ''' 108 | from tda.orders.common import Duration, EquityInstruction 109 | from tda.orders.common import OrderStrategyType, OrderType, Session 110 | from tda.orders.generic import OrderBuilder 111 | 112 | return (OrderBuilder() 113 | .set_order_type(OrderType.LIMIT) 114 | .set_price(price) 115 | .set_session(Session.NORMAL) 116 | .set_duration(Duration.DAY) 117 | .set_order_strategy_type(OrderStrategyType.SINGLE) 118 | .add_equity_leg(EquityInstruction.SELL_SHORT, symbol, quantity)) 119 | 120 | ########################################################################## 121 | # Buy to cover orders 122 | 123 | 124 | def equity_buy_to_cover_market(symbol, quantity): 125 | ''' 126 | Returns a pre-filled :class:`~tda.orders.generic.OrderBuilder` for an equity 127 | buy-to-cover market order. 128 | ''' 129 | from tda.orders.common import Duration, EquityInstruction 130 | from tda.orders.common import OrderStrategyType, OrderType, Session 131 | from tda.orders.generic import OrderBuilder 132 | 133 | return (OrderBuilder() 134 | .set_order_type(OrderType.MARKET) 135 | .set_session(Session.NORMAL) 136 | .set_duration(Duration.DAY) 137 | .set_order_strategy_type(OrderStrategyType.SINGLE) 138 | .add_equity_leg(EquityInstruction.BUY_TO_COVER, symbol, quantity)) 139 | 140 | 141 | def equity_buy_to_cover_limit(symbol, quantity, price): 142 | ''' 143 | Returns a pre-filled :class:`~tda.orders.generic.OrderBuilder` for an equity 144 | buy-to-cover limit order. 145 | ''' 146 | from tda.orders.common import Duration, EquityInstruction 147 | from tda.orders.common import OrderStrategyType, OrderType, Session 148 | from tda.orders.generic import OrderBuilder 149 | 150 | return (OrderBuilder() 151 | .set_order_type(OrderType.LIMIT) 152 | .set_price(price) 153 | .set_session(Session.NORMAL) 154 | .set_duration(Duration.DAY) 155 | .set_order_strategy_type(OrderStrategyType.SINGLE) 156 | .add_equity_leg(EquityInstruction.BUY_TO_COVER, symbol, quantity)) 157 | -------------------------------------------------------------------------------- /tda/orders/generic.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from tda.orders import common 4 | from tda.utils import EnumEnforcer 5 | 6 | import httpx 7 | 8 | 9 | def _build_object(obj): 10 | # Literals are passed straight through 11 | if isinstance(obj, str) or isinstance(obj, int) or isinstance(obj, float): 12 | return obj 13 | 14 | # Note enums are not handled because call callers convert their enums to 15 | # values. 16 | 17 | # Dicts and lists are iterated over, with keys intact 18 | elif isinstance(obj, dict): 19 | return dict((key, _build_object(value)) for key, value in obj.items()) 20 | elif isinstance(obj, list): 21 | return [_build_object(i) for i in obj] 22 | 23 | # Objects have their variables translated into keys 24 | else: 25 | ret = {} 26 | for name, value in vars(obj).items(): 27 | if value is None or name[0] != '_': 28 | continue 29 | 30 | name = name[1:] 31 | ret[name] = _build_object(value) 32 | return ret 33 | 34 | 35 | def truncate_float(flt): 36 | if abs(flt) < 1 and flt != 0.0: 37 | return '{:.4f}'.format(float(int(flt * 10000)) / 10000.0) 38 | else: 39 | return '{:.2f}'.format(float(int(flt * 100)) / 100.0) 40 | 41 | 42 | class OrderBuilder(EnumEnforcer): 43 | ''' 44 | Helper class to create arbitrarily complex orders. Note this class simply 45 | implements the order schema defined in the `documentation 46 | `__, with no attempts to validate the result. 48 | Orders created using this class may be rejected or may never fill. Use at 49 | your own risk. 50 | ''' 51 | 52 | def __init__(self, *, enforce_enums=True): 53 | super().__init__(enforce_enums) 54 | 55 | self._session = None 56 | self._duration = None 57 | self._orderType = None 58 | self._complexOrderStrategyType = None 59 | self._quantity = None 60 | self._requestedDestination = None 61 | self._stopPrice = None 62 | self._stopPriceLinkBasis = None 63 | self._stopPriceLinkType = None 64 | self._stopPriceOffset = None 65 | self._stopType = None 66 | self._priceLinkBasis = None 67 | self._priceLinkType = None 68 | self._price = None 69 | self._orderLegCollection = None 70 | self._activationPrice = None 71 | self._specialInstruction = None 72 | self._orderStrategyType = None 73 | self._childOrderStrategies = None 74 | 75 | # Session 76 | def set_session(self, session): 77 | ''' 78 | Set the order session. See :class:`~tda.orders.common.Session` for 79 | details. 80 | ''' 81 | session = self.convert_enum(session, common.Session) 82 | self._session = session 83 | return self 84 | 85 | def clear_session(self): 86 | ''' 87 | Clear the order session. 88 | ''' 89 | self._session = None 90 | return self 91 | 92 | # Duration 93 | def set_duration(self, duration): 94 | ''' 95 | Set the order duration. See :class:`~tda.orders.common.Duration` for 96 | details. 97 | ''' 98 | duration = self.convert_enum(duration, common.Duration) 99 | self._duration = duration 100 | return self 101 | 102 | def clear_duration(self): 103 | ''' 104 | Clear the order duration. 105 | ''' 106 | self._duration = None 107 | return self 108 | 109 | # OrderType 110 | def set_order_type(self, order_type): 111 | ''' 112 | Set the order type. See :class:`~tda.orders.common.OrderType` for 113 | details. 114 | ''' 115 | order_type = self.convert_enum(order_type, common.OrderType) 116 | self._orderType = order_type 117 | return self 118 | 119 | def clear_order_type(self): 120 | ''' 121 | Clear the order type. 122 | ''' 123 | self._orderType = None 124 | return self 125 | 126 | # ComplexOrderStrategyType 127 | def set_complex_order_strategy_type(self, complex_order_strategy_type): 128 | ''' 129 | Set the complex order strategy type. See 130 | :class:`~tda.orders.common.ComplexOrderStrategyType` for details. 131 | ''' 132 | complex_order_strategy_type = self.convert_enum( 133 | complex_order_strategy_type, common.ComplexOrderStrategyType) 134 | self._complexOrderStrategyType = complex_order_strategy_type 135 | return self 136 | 137 | def clear_complex_order_strategy_type(self): 138 | ''' 139 | Clear the complex order strategy type. 140 | ''' 141 | self._complexOrderStrategyType = None 142 | return self 143 | 144 | # Quantity 145 | def set_quantity(self, quantity): 146 | ''' 147 | Exact semantics unknown. See :ref:`undocumented_quantity` for a 148 | discussion. 149 | ''' 150 | if quantity <= 0: 151 | raise ValueError('quantity must be positive') 152 | self._quantity = quantity 153 | return self 154 | 155 | def clear_quantity(self): 156 | ''' 157 | Clear the order-level quantity. Note this does not affect order legs. 158 | ''' 159 | self._quantity = None 160 | return self 161 | 162 | # RequestedDestination 163 | def set_requested_destination(self, requested_destination): 164 | ''' 165 | Set the requested destination. See 166 | :class:`~tda.orders.common.Destination` for details. 167 | ''' 168 | requested_destination = self.convert_enum( 169 | requested_destination, common.Destination) 170 | self._requestedDestination = requested_destination 171 | return self 172 | 173 | def clear_requested_destination(self): 174 | ''' 175 | Clear the requested destination. 176 | ''' 177 | self._requestedDestination = None 178 | return self 179 | 180 | # StopPrice 181 | def set_stop_price(self, stop_price): 182 | ''' 183 | Set the stop price. Note price can be passed as either a `float` or an 184 | `str`. See :ref:`number_truncation`. 185 | ''' 186 | if isinstance(stop_price, str): 187 | self._stopPrice = stop_price 188 | else: 189 | self._stopPrice = truncate_float(stop_price) 190 | return self 191 | 192 | def copy_stop_price(self, stop_price): 193 | ''' 194 | Directly set the stop price, avoiding all the validation and truncation 195 | logic from :func:`set_stop_price`. 196 | ''' 197 | self._stopPrice = stop_price 198 | return self 199 | 200 | def clear_stop_price(self): 201 | ''' 202 | Clear the stop price. 203 | ''' 204 | self._stopPrice = None 205 | return self 206 | 207 | # StopPriceLinkBasis 208 | def set_stop_price_link_basis(self, stop_price_link_basis): 209 | ''' 210 | Set the stop price link basis. See 211 | :class:`~tda.orders.common.StopPriceLinkBasis` for details. 212 | ''' 213 | stop_price_link_basis = self.convert_enum( 214 | stop_price_link_basis, common.StopPriceLinkBasis) 215 | self._stopPriceLinkBasis = stop_price_link_basis 216 | return self 217 | 218 | def clear_stop_price_link_basis(self): 219 | ''' 220 | Clear the stop price link basis. 221 | ''' 222 | self._stopPriceLinkBasis = None 223 | return self 224 | 225 | # StopPriceLinkType 226 | def set_stop_price_link_type(self, stop_price_link_type): 227 | ''' 228 | Set the stop price link type. See 229 | :class:`~tda.orders.common.StopPriceLinkType` for details. 230 | ''' 231 | stop_price_link_type = self.convert_enum( 232 | stop_price_link_type, common.StopPriceLinkType) 233 | self._stopPriceLinkType = stop_price_link_type 234 | return self 235 | 236 | def clear_stop_price_link_type(self): 237 | ''' 238 | Clear the stop price link type. 239 | ''' 240 | self._stopPriceLinkType = None 241 | return self 242 | 243 | # StopPriceOffset 244 | def set_stop_price_offset(self, stop_price_offset): 245 | ''' 246 | Set the stop price offset. 247 | ''' 248 | self._stopPriceOffset = stop_price_offset 249 | return self 250 | 251 | def clear_stop_price_offset(self): 252 | ''' 253 | Clear the stop price offset. 254 | ''' 255 | self._stopPriceOffset = None 256 | return self 257 | 258 | # StopType 259 | def set_stop_type(self, stop_type): 260 | ''' 261 | Set the stop type. See 262 | :class:`~tda.orders.common.StopType` for more details. 263 | ''' 264 | stop_type = self.convert_enum(stop_type, common.StopType) 265 | self._stopType = stop_type 266 | return self 267 | 268 | def clear_stop_type(self): 269 | ''' 270 | Clear the stop type. 271 | ''' 272 | self._stopType = None 273 | return self 274 | 275 | # PriceLinkBasis 276 | def set_price_link_basis(self, price_link_basis): 277 | ''' 278 | Set the price link basis. See 279 | :class:`~tda.orders.common.PriceLinkBasis` for details. 280 | ''' 281 | price_link_basis = self.convert_enum( 282 | price_link_basis, common.PriceLinkBasis) 283 | self._priceLinkBasis = price_link_basis 284 | return self 285 | 286 | def clear_price_link_basis(self): 287 | ''' 288 | Clear the price link basis. 289 | ''' 290 | self._priceLinkBasis = None 291 | return self 292 | 293 | # PriceLinkType 294 | def set_price_link_type(self, price_link_type): 295 | ''' 296 | Set the price link type. See 297 | :class:`~tda.orders.common.PriceLinkType` for more details. 298 | ''' 299 | price_link_type = self.convert_enum( 300 | price_link_type, common.PriceLinkType) 301 | self._priceLinkType = price_link_type 302 | return self 303 | 304 | def clear_price_link_type(self): 305 | ''' 306 | Clear the price link basis. 307 | ''' 308 | self._priceLinkType = None 309 | return self 310 | 311 | # Price 312 | def set_price(self, price): 313 | ''' 314 | Set the order price. Note price can be passed as either a `float` or an 315 | `str`. See :ref:`number_truncation`. 316 | ''' 317 | if isinstance(price, str): 318 | self._price = price 319 | else: 320 | self._price = truncate_float(price) 321 | return self 322 | 323 | def copy_price(self, price): 324 | ''' 325 | Directly set the stop price, avoiding all the validation and truncation 326 | logic from :func:`set_price`. 327 | ''' 328 | self._price = price 329 | return self 330 | 331 | def clear_price(self): 332 | ''' 333 | Clear the order price 334 | ''' 335 | self._price = None 336 | return self 337 | 338 | # ActivationPrice 339 | def set_activation_price(self, activation_price): 340 | ''' 341 | Set the activation price. 342 | ''' 343 | if activation_price <= 0.0: 344 | raise ValueError('activation price must be positive') 345 | self._activationPrice = activation_price 346 | return self 347 | 348 | def clear_activation_price(self): 349 | ''' 350 | Clear the activation price. 351 | ''' 352 | self._activationPrice = None 353 | return self 354 | 355 | # SpecialInstruction 356 | def set_special_instruction(self, special_instruction): 357 | ''' 358 | Set the special instruction. See 359 | :class:`~tda.orders.common.SpecialInstruction` for details. 360 | ''' 361 | special_instruction = self.convert_enum( 362 | special_instruction, common.SpecialInstruction) 363 | self._specialInstruction = special_instruction 364 | return self 365 | 366 | def clear_special_instruction(self): 367 | ''' 368 | Clear the special instruction. 369 | ''' 370 | self._specialInstruction = None 371 | return self 372 | 373 | # OrderStrategyType 374 | def set_order_strategy_type(self, order_strategy_type): 375 | ''' 376 | Set the order strategy type. See 377 | :class:`~tda.orders.common.OrderStrategyType` for more details. 378 | ''' 379 | order_strategy_type = self.convert_enum( 380 | order_strategy_type, common.OrderStrategyType) 381 | self._orderStrategyType = order_strategy_type 382 | return self 383 | 384 | def clear_order_strategy_type(self): 385 | ''' 386 | Clear the order strategy type. 387 | ''' 388 | self._orderStrategyType = None 389 | return self 390 | 391 | # ChildOrderStrategies 392 | def add_child_order_strategy(self, child_order_strategy): 393 | if isinstance(child_order_strategy, httpx.Response): 394 | raise ValueError( 395 | 'Child order cannot be a response. See here for ' + 396 | 'details: https://tda-api.readthedocs.io/en/latest/' + 397 | 'order-templates.html#utility-methods') 398 | 399 | if (not isinstance(child_order_strategy, OrderBuilder) 400 | and not isinstance(child_order_strategy, dict)): 401 | raise ValueError('child order must be OrderBuilder or dict') 402 | 403 | if self._childOrderStrategies is None: 404 | self._childOrderStrategies = [] 405 | 406 | self._childOrderStrategies.append(child_order_strategy) 407 | return self 408 | 409 | def clear_child_order_strategies(self): 410 | self._childOrderStrategies = None 411 | return self 412 | 413 | # OrderLegCollection 414 | def __add_order_leg(self, instruction, instrument, quantity): 415 | # instruction is assumed to have been verified 416 | 417 | if quantity <= 0: 418 | raise ValueError('quantity must be positive') 419 | 420 | if self._orderLegCollection is None: 421 | self._orderLegCollection = [] 422 | 423 | self._orderLegCollection.append({ 424 | 'instruction': instruction, 425 | 'instrument': instrument, 426 | 'quantity': quantity, 427 | }) 428 | 429 | return self 430 | 431 | def add_equity_leg(self, instruction, symbol, quantity): 432 | ''' 433 | Add an equity order leg. 434 | 435 | :param instruction: Instruction for the leg. See 436 | :class:`~tda.orders.common.EquityInstruction` for 437 | valid options. 438 | :param symbol: Equity symbol 439 | :param quantity: Number of shares for the order 440 | ''' 441 | instruction = self.convert_enum(instruction, common.EquityInstruction) 442 | return self.__add_order_leg( 443 | instruction, common.EquityInstrument(symbol), quantity) 444 | 445 | def add_option_leg(self, instruction, symbol, quantity): 446 | ''' 447 | Add an option order leg. 448 | 449 | :param instruction: Instruction for the leg. See 450 | :class:`~tda.orders.common.OptionInstruction` for 451 | valid options. 452 | :param symbol: Option symbol 453 | :param quantity: Number of contracts for the order 454 | ''' 455 | instruction = self.convert_enum(instruction, common.OptionInstruction) 456 | return self.__add_order_leg( 457 | instruction, common.OptionInstrument(symbol), quantity) 458 | 459 | def clear_order_legs(self): 460 | ''' 461 | Clear all order legs. 462 | ''' 463 | self._orderLegCollection = None 464 | return self 465 | 466 | # Build 467 | 468 | def build(self): 469 | return _build_object(self) 470 | -------------------------------------------------------------------------------- /tda/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgolec/tda-api/d22076c14add202b711af793ad48b36208e1ff6c/tda/scripts/__init__.py -------------------------------------------------------------------------------- /tda/scripts/orders_codegen.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | 4 | from tda.auth import client_from_token_file 5 | from tda.contrib.orders import construct_repeat_order, code_for_builder 6 | 7 | 8 | def latest_order_main(sys_args): 9 | parser = argparse.ArgumentParser( 10 | description='Utilities for generating code from historical orders') 11 | 12 | required = parser.add_argument_group('required arguments') 13 | required.add_argument( 14 | '--token_file', required=True, help='Path to token file') 15 | required.add_argument('--api_key', required=True) 16 | 17 | parser.add_argument('--account_id', type=int, 18 | help='Restrict lookups to a specific account ID') 19 | 20 | args = parser.parse_args(args=sys_args) 21 | client = client_from_token_file(args.token_file, args.api_key) 22 | 23 | if args.account_id: 24 | orders = client.get_orders_by_path(args.account_id).json() 25 | if 'error' in orders: 26 | print(('TDA returned error: "{}", This is most often caused by ' + 27 | 'an invalid account ID').format(orders['error'])) 28 | return -1 29 | else: 30 | orders = client.get_orders_by_query().json() 31 | if 'error' in orders: 32 | print('TDA returned error: "{}"'.format(orders['error'])) 33 | return -1 34 | 35 | if orders: 36 | order = sorted(orders, key=lambda o: -o['orderId'])[0] 37 | print('# Order ID', order['orderId']) 38 | print(code_for_builder(construct_repeat_order(order))) 39 | else: 40 | print('No recent orders found') 41 | 42 | return 0 43 | -------------------------------------------------------------------------------- /tda/utils.py: -------------------------------------------------------------------------------- 1 | '''Implements additional functionality beyond what's implemented in the client 2 | module.''' 3 | 4 | import datetime 5 | import dateutil.parser 6 | import enum 7 | import httpx 8 | import inspect 9 | import re 10 | 11 | 12 | def class_fullname(o): 13 | return o.__module__ + '.' + o.__name__ 14 | 15 | 16 | class EnumEnforcer: 17 | def __init__(self, enforce_enums): 18 | self.enforce_enums = enforce_enums 19 | 20 | def type_error(self, value, required_enum_type): 21 | possible_members_message = '' 22 | 23 | if isinstance(value, str): 24 | possible_members = [] 25 | for member in required_enum_type.__members__: 26 | fullname = class_fullname(required_enum_type) + '.' + member 27 | if value in fullname: 28 | possible_members.append(fullname) 29 | 30 | # Oxford comma insertion 31 | if possible_members: 32 | possible_members_message = 'Did you mean ' + ', '.join( 33 | possible_members[:-2] + [' or '.join( 34 | possible_members[-2:])]) + '? ' 35 | 36 | raise ValueError( 37 | ('expected type "{}", got type "{}". {}(initialize with ' + 38 | 'enforce_enums=False to disable this checking)').format( 39 | required_enum_type.__name__, 40 | type(value).__name__, 41 | possible_members_message)) 42 | 43 | def convert_enum(self, value, required_enum_type): 44 | if value is None: 45 | return None 46 | 47 | if isinstance(value, required_enum_type): 48 | return value.value 49 | elif self.enforce_enums: 50 | self.type_error(value, required_enum_type) 51 | else: 52 | return value 53 | 54 | def convert_enum_iterable(self, iterable, required_enum_type): 55 | if iterable is None: 56 | return None 57 | 58 | if isinstance(iterable, required_enum_type): 59 | return [iterable.value] 60 | 61 | values = [] 62 | for value in iterable: 63 | if isinstance(value, required_enum_type): 64 | values.append(value.value) 65 | elif self.enforce_enums: 66 | self.type_error(value, required_enum_type) 67 | else: 68 | values.append(value) 69 | return values 70 | 71 | def set_enforce_enums(self, enforce_enums): 72 | self.enforce_enums = enforce_enums 73 | 74 | 75 | class UnsuccessfulOrderException(ValueError): 76 | ''' 77 | Raised by :meth:`Utils.extract_order_id` when attempting to extract an 78 | order ID from a :meth:`Client.place_order` response that was not successful. 79 | ''' 80 | 81 | 82 | class AccountIdMismatchException(ValueError): 83 | ''' 84 | Raised by :meth:`Utils.extract_order_id` when attempting to extract an 85 | order ID from a :meth:`Client.place_order` with a different account ID than 86 | the one with which the :class:`Utils` was initialized. 87 | ''' 88 | 89 | 90 | class LazyLog: 91 | 'Helper to defer evaluation of expensive variables in log messages' 92 | def __init__(self, func): 93 | self.func = func 94 | def __str__(self): 95 | return self.func() 96 | 97 | 98 | class Utils(EnumEnforcer): 99 | '''Helper for placing orders on equities. Provides easy-to-use 100 | implementations for common tasks such as market and limit orders.''' 101 | 102 | def __init__(self, client, account_id): 103 | '''Creates a new ``Utils`` instance. For convenience, this object 104 | assumes the user wants to work with a single account ID at a time.''' 105 | super().__init__(True) 106 | 107 | self.client = client 108 | self.account_id = account_id 109 | 110 | def set_account_id(self, account_id): 111 | '''Set the account ID used by this ``Utils`` instance.''' 112 | self.account_id = account_id 113 | 114 | def extract_order_id(self, place_order_response): 115 | '''Attempts to extract the order ID from a response object returned by 116 | :meth:`Client.place_order() `. Return 117 | ``None`` if the order location is not contained in the response. 118 | 119 | :param place_order_response: Order response as returned by 120 | :meth:`Client.place_order() 121 | `. Note this 122 | method requires that the order was 123 | successful. 124 | 125 | :raise ValueError: if the order was not succesful or if the order's 126 | account ID is not equal to the account ID set in this 127 | ``Utils`` object. 128 | 129 | ''' 130 | if place_order_response.is_error: 131 | raise UnsuccessfulOrderException( 132 | 'order not successful: status {}'.format(place_order_response.status_code)) 133 | 134 | try: 135 | location = place_order_response.headers['Location'] 136 | except KeyError: 137 | return None 138 | 139 | m = re.match( 140 | r'https://api.tdameritrade.com/v1/accounts/(\d+)/orders/(\d+)', 141 | location) 142 | 143 | if m is None: 144 | return None 145 | account_id, order_id = int(m.group(1)), int(m.group(2)) 146 | 147 | if str(account_id) != str(self.account_id): 148 | raise AccountIdMismatchException( 149 | 'order request account ID != Utils.account_id') 150 | 151 | return order_id 152 | -------------------------------------------------------------------------------- /tda/version.py: -------------------------------------------------------------------------------- 1 | version = '1.6.0' 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgolec/tda-api/d22076c14add202b711af793ad48b36208e1ff6c/tests/__init__.py -------------------------------------------------------------------------------- /tests/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgolec/tda-api/d22076c14add202b711af793ad48b36208e1ff6c/tests/contrib/__init__.py -------------------------------------------------------------------------------- /tests/contrib/util_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tda.contrib.util import HeuristicJsonDecoder 4 | 5 | 6 | class HeuristicJsonDecoderTest(unittest.TestCase): 7 | def test_raw_string_decodes(self): 8 | self.assertEqual(HeuristicJsonDecoder().decode_json_string( 9 | r'{"\\\\\\\\": "test"}'), 10 | {r'\\\\': 'test'}) 11 | 12 | 13 | def test_replace_backslashes(self): 14 | # TODO: Actually collect some failing use cases... 15 | pass 16 | -------------------------------------------------------------------------------- /tests/debug_test.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import io 3 | import json 4 | import logging 5 | import tda 6 | import unittest 7 | 8 | from tda.client import Client 9 | from .utils import MockResponse, no_duplicates 10 | from unittest.mock import Mock, patch 11 | 12 | 13 | class RedactorTest(unittest.TestCase): 14 | 15 | def setUp(self): 16 | self.redactor = tda.debug.LogRedactor() 17 | 18 | @no_duplicates 19 | def test_no_redactions(self): 20 | self.assertEqual('test message', self.redactor.redact('test message')) 21 | 22 | @no_duplicates 23 | def test_simple_redaction(self): 24 | self.redactor.register('secret', 'SECRET') 25 | 26 | self.assertEqual( 27 | ' message', 28 | self.redactor.redact('secret message')) 29 | 30 | @no_duplicates 31 | def test_multiple_registrations_same_string(self): 32 | self.redactor.register('secret', 'SECRET') 33 | self.redactor.register('secret', 'SECRET') 34 | 35 | self.assertEqual( 36 | ' message', 37 | self.redactor.redact('secret message')) 38 | 39 | @no_duplicates 40 | def test_multiple_registrations_same_string_different_label(self): 41 | self.redactor.register('secret-A', 'SECRET') 42 | self.redactor.register('secret-B', 'SECRET') 43 | 44 | self.assertEqual( 45 | ' message ', 46 | self.redactor.redact('secret-A message secret-B')) 47 | 48 | 49 | class RegisterRedactionsTest(unittest.TestCase): 50 | 51 | def setUp(self): 52 | self.captured = io.StringIO() 53 | self.logger = logging.getLogger('test') 54 | self.dump_logs = tda.debug._enable_bug_report_logging( 55 | output=self.captured, loggers=[self.logger]) 56 | tda.LOG_REDACTOR = tda.debug.LogRedactor() 57 | 58 | @no_duplicates 59 | def test_empty_string(self): 60 | tda.debug.register_redactions('') 61 | 62 | @no_duplicates 63 | def test_empty_dict(self): 64 | tda.debug.register_redactions({}) 65 | 66 | @no_duplicates 67 | def test_empty_list(self): 68 | tda.debug.register_redactions([]) 69 | 70 | @no_duplicates 71 | def test_dict(self): 72 | tda.debug.register_redactions( 73 | {'BadNumber': '100001'}, 74 | bad_patterns=['bad']) 75 | tda.debug.register_redactions( 76 | {'OtherBadNumber': '200002'}, 77 | bad_patterns=['bad']) 78 | 79 | self.logger.info('Bad Number: 100001') 80 | self.logger.info('Other Bad Number: 200002') 81 | 82 | self.dump_logs() 83 | self.assertRegex( 84 | self.captured.getvalue(), 85 | r'\[.*\] Bad Number: \n' + 86 | r'\[.*\] Other Bad Number: \n') 87 | 88 | @no_duplicates 89 | def test_list_of_dict(self): 90 | tda.debug.register_redactions( 91 | [{'GoodNumber': '900009'}, 92 | {'BadNumber': '100001'}, 93 | {'OtherBadNumber': '200002'}], 94 | bad_patterns=['bad']) 95 | 96 | self.logger.info('Good Number: 900009') 97 | self.logger.info('Bad Number: 100001') 98 | self.logger.info('Other Bad Number: 200002') 99 | 100 | self.dump_logs() 101 | self.assertRegex( 102 | self.captured.getvalue(), 103 | r'\[.*\] Good Number: 900009\n' + 104 | r'\[.*\] Bad Number: \n' + 105 | r'\[.*\] Other Bad Number: \n') 106 | 107 | @no_duplicates 108 | def test_whitelist(self): 109 | tda.debug.register_redactions( 110 | [{'GoodNumber': '900009'}, 111 | {'BadNumber': '100001'}, 112 | {'OtherBadNumber': '200002'}], 113 | bad_patterns=['bad'], 114 | whitelisted=['otherbadnumber']) 115 | 116 | self.logger.info('Good Number: 900009') 117 | self.logger.info('Bad Number: 100001') 118 | self.logger.info('Other Bad Number: 200002') 119 | 120 | self.dump_logs() 121 | self.assertRegex( 122 | self.captured.getvalue(), 123 | r'\[.*\] Good Number: 900009\n' + 124 | r'\[.*\] Bad Number: \n' + 125 | r'\[.*\] Other Bad Number: 200002\n') 126 | 127 | @no_duplicates 128 | @patch('tda.debug.register_redactions', new_callable=Mock) 129 | def test_register_from_request_success(self, register_redactions): 130 | resp = MockResponse({'success': 1}, 200) 131 | tda.debug.register_redactions_from_response(resp) 132 | register_redactions.assert_called_with({'success': 1}) 133 | 134 | @no_duplicates 135 | @patch('tda.debug.register_redactions', new_callable=Mock) 136 | def test_register_from_request_not_okay(self, register_redactions): 137 | resp = MockResponse({'success': 1}, 403) 138 | tda.debug.register_redactions_from_response(resp) 139 | register_redactions.assert_not_called() 140 | 141 | @no_duplicates 142 | @patch('tda.debug.register_redactions', new_callable=Mock) 143 | def test_register_unparseable_json(self, register_redactions): 144 | class MR(MockResponse): 145 | def json(self): 146 | raise json.decoder.JSONDecodeError('e243rtdagew', '', 0) 147 | 148 | resp = MR({'success': 1}, 200) 149 | tda.debug.register_redactions_from_response(resp) 150 | register_redactions.assert_not_called() 151 | 152 | class EnableDebugLoggingTest(unittest.TestCase): 153 | 154 | @patch('logging.Logger.addHandler') 155 | def test_enable_doesnt_throw_exceptions(self, _): 156 | try: 157 | tda.debug.enable_bug_report_logging() 158 | except AttributeError: 159 | self.fail("debug.enable_bug_report_logging() raised AttributeError unexpectedly") 160 | -------------------------------------------------------------------------------- /tests/orders/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgolec/tda-api/d22076c14add202b711af793ad48b36208e1ff6c/tests/orders/__init__.py -------------------------------------------------------------------------------- /tests/orders/common_test.py: -------------------------------------------------------------------------------- 1 | from ..utils import has_diff, no_duplicates 2 | from tda.orders.common import * 3 | from tda.orders.generic import OrderBuilder 4 | 5 | import unittest 6 | 7 | class MultiOrderTest(unittest.TestCase): 8 | 9 | @no_duplicates 10 | def test_oco(self): 11 | self.assertFalse(has_diff({ 12 | 'orderStrategyType': 'OCO', 13 | 'childOrderStrategies': [ 14 | {'session': 'NORMAL'}, 15 | {'duration': 'DAY'}, 16 | ] 17 | }, one_cancels_other( 18 | OrderBuilder().set_session(Session.NORMAL), 19 | OrderBuilder().set_duration(Duration.DAY)).build())) 20 | 21 | @no_duplicates 22 | def test_trigger(self): 23 | self.assertFalse(has_diff({ 24 | 'orderStrategyType': 'TRIGGER', 25 | 'session': 'NORMAL', 26 | 'childOrderStrategies': [ 27 | {'duration': 'DAY'}, 28 | ] 29 | }, first_triggers_second( 30 | OrderBuilder().set_session(Session.NORMAL), 31 | OrderBuilder().set_duration(Duration.DAY)).build())) 32 | -------------------------------------------------------------------------------- /tests/orders_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tda.orders.common import * 4 | from tda.orders.equities import * 5 | from .utils import has_diff, no_duplicates 6 | 7 | import imp 8 | from unittest.mock import patch 9 | 10 | 11 | class EquityOrderBuilderLegacy(unittest.TestCase): 12 | 13 | def test_import_EquityOrderBuilder(self): 14 | import sys 15 | assert sys.version_info[0] == 3 16 | 17 | if sys.version_info[1] >= 7: 18 | with self.assertRaisesRegex( 19 | ImportError, 'EquityOrderBuilder has been deleted'): 20 | from tda.orders import EquityOrderBuilder 21 | 22 | def test_import_EquityOrderBuilder_pre_3_7(self): 23 | import sys 24 | assert sys.version_info[0] == 3 25 | 26 | if sys.version_info[1] < 7: 27 | from tda import orders 28 | imp.reload(orders) 29 | 30 | from tda.orders import EquityOrderBuilder 31 | 32 | with self.assertRaisesRegex(NotImplementedError, 33 | 'EquityOrderBuilder has been deleted'): 34 | EquityOrderBuilder('args') 35 | 36 | def test_other_import(self): 37 | with self.assertRaisesRegex(ImportError, 'bogus'): 38 | from tda.orders import bogus 39 | 40 | def test_attribute_access(self): 41 | with self.assertRaisesRegex(AttributeError, 'bogus'): 42 | import tda 43 | print(tda.orders.bogus) 44 | 45 | 46 | class BuilderTemplates(unittest.TestCase): 47 | 48 | def test_equity_buy_market(self): 49 | self.assertFalse(has_diff({ 50 | 'orderType': 'MARKET', 51 | 'session': 'NORMAL', 52 | 'duration': 'DAY', 53 | 'orderStrategyType': 'SINGLE', 54 | 'orderLegCollection': [{ 55 | 'instruction': 'BUY', 56 | 'quantity': 10, 57 | 'instrument': { 58 | 'symbol': 'GOOG', 59 | 'assetType': 'EQUITY', 60 | } 61 | }] 62 | }, equity_buy_market('GOOG', 10).build())) 63 | 64 | def test_equity_buy_limit(self): 65 | self.assertFalse(has_diff({ 66 | 'orderType': 'LIMIT', 67 | 'session': 'NORMAL', 68 | 'duration': 'DAY', 69 | 'price': '199.99', 70 | 'orderStrategyType': 'SINGLE', 71 | 'orderLegCollection': [{ 72 | 'instruction': 'BUY', 73 | 'quantity': 10, 74 | 'instrument': { 75 | 'symbol': 'GOOG', 76 | 'assetType': 'EQUITY', 77 | } 78 | }] 79 | }, equity_buy_limit('GOOG', 10, 199.99).build())) 80 | 81 | def test_equity_sell_market(self): 82 | self.assertFalse(has_diff({ 83 | 'orderType': 'MARKET', 84 | 'session': 'NORMAL', 85 | 'duration': 'DAY', 86 | 'orderStrategyType': 'SINGLE', 87 | 'orderLegCollection': [{ 88 | 'instruction': 'SELL', 89 | 'quantity': 10, 90 | 'instrument': { 91 | 'symbol': 'GOOG', 92 | 'assetType': 'EQUITY', 93 | } 94 | }] 95 | }, equity_sell_market('GOOG', 10).build())) 96 | 97 | def test_equity_sell_limit(self): 98 | self.assertFalse(has_diff({ 99 | 'orderType': 'LIMIT', 100 | 'session': 'NORMAL', 101 | 'duration': 'DAY', 102 | 'price': '199.99', 103 | 'orderStrategyType': 'SINGLE', 104 | 'orderLegCollection': [{ 105 | 'instruction': 'SELL', 106 | 'quantity': 10, 107 | 'instrument': { 108 | 'symbol': 'GOOG', 109 | 'assetType': 'EQUITY', 110 | } 111 | }] 112 | }, equity_sell_limit('GOOG', 10, 199.99).build())) 113 | 114 | def test_equity_sell_short_market(self): 115 | self.assertFalse(has_diff({ 116 | 'orderType': 'MARKET', 117 | 'session': 'NORMAL', 118 | 'duration': 'DAY', 119 | 'orderStrategyType': 'SINGLE', 120 | 'orderLegCollection': [{ 121 | 'instruction': 'SELL_SHORT', 122 | 'quantity': 10, 123 | 'instrument': { 124 | 'symbol': 'GOOG', 125 | 'assetType': 'EQUITY', 126 | } 127 | }] 128 | }, equity_sell_short_market('GOOG', 10).build())) 129 | 130 | def test_equity_sell_short_limit(self): 131 | self.assertFalse(has_diff({ 132 | 'orderType': 'LIMIT', 133 | 'session': 'NORMAL', 134 | 'duration': 'DAY', 135 | 'price': '199.99', 136 | 'orderStrategyType': 'SINGLE', 137 | 'orderLegCollection': [{ 138 | 'instruction': 'SELL_SHORT', 139 | 'quantity': 10, 140 | 'instrument': { 141 | 'symbol': 'GOOG', 142 | 'assetType': 'EQUITY', 143 | } 144 | }] 145 | }, equity_sell_short_limit('GOOG', 10, 199.99).build())) 146 | 147 | def test_equity_buy_to_cover_market(self): 148 | self.assertFalse(has_diff({ 149 | 'orderType': 'MARKET', 150 | 'session': 'NORMAL', 151 | 'duration': 'DAY', 152 | 'orderStrategyType': 'SINGLE', 153 | 'orderLegCollection': [{ 154 | 'instruction': 'BUY_TO_COVER', 155 | 'quantity': 10, 156 | 'instrument': { 157 | 'symbol': 'GOOG', 158 | 'assetType': 'EQUITY', 159 | } 160 | }] 161 | }, equity_buy_to_cover_market('GOOG', 10).build())) 162 | 163 | def test_equity_buy_to_cover_limit(self): 164 | self.assertFalse(has_diff({ 165 | 'orderType': 'LIMIT', 166 | 'session': 'NORMAL', 167 | 'duration': 'DAY', 168 | 'price': '199.99', 169 | 'orderStrategyType': 'SINGLE', 170 | 'orderLegCollection': [{ 171 | 'instruction': 'BUY_TO_COVER', 172 | 'quantity': 10, 173 | 'instrument': { 174 | 'symbol': 'GOOG', 175 | 'assetType': 'EQUITY', 176 | } 177 | }] 178 | }, equity_buy_to_cover_limit('GOOG', 10, 199.99).build())) 179 | -------------------------------------------------------------------------------- /tests/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgolec/tda-api/d22076c14add202b711af793ad48b36208e1ff6c/tests/scripts/__init__.py -------------------------------------------------------------------------------- /tests/scripts/orders_codegen_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import call, MagicMock, patch 3 | 4 | from ..utils import AnyStringWith, no_duplicates 5 | from tda.scripts.orders_codegen import latest_order_main 6 | 7 | class LatestOrderTest(unittest.TestCase): 8 | 9 | def setUp(self): 10 | self.args = [] 11 | 12 | def add_arg(self, arg): 13 | self.args.append(arg) 14 | 15 | def main(self): 16 | return latest_order_main(self.args) 17 | 18 | @no_duplicates 19 | @patch('builtins.print') 20 | @patch('tda.scripts.orders_codegen.client_from_token_file') 21 | @patch('tda.scripts.orders_codegen.construct_repeat_order') 22 | @patch('tda.scripts.orders_codegen.code_for_builder') 23 | def test_success_no_account_id( 24 | self, 25 | mock_code_for_builder, 26 | mock_construct_repeat_order, 27 | mock_client_from_token_file, 28 | mock_print): 29 | self.add_arg('--token_file') 30 | self.add_arg('filename.json') 31 | self.add_arg('--api_key') 32 | self.add_arg('api-key') 33 | 34 | orders = [ 35 | {'orderId': 201}, 36 | {'orderId': 101}, 37 | {'orderId': 301}, 38 | {'orderId': 401}, 39 | ] 40 | 41 | mock_client = MagicMock() 42 | mock_client_from_token_file.return_value = mock_client 43 | mock_client.get_orders_by_query.return_value.json.return_value = orders 44 | 45 | self.assertEqual(self.main(), 0) 46 | 47 | mock_construct_repeat_order.assert_called_once_with(orders[3]) 48 | mock_print.assert_has_calls([ 49 | call('# Order ID', 401), 50 | call(mock_code_for_builder.return_value)]) 51 | 52 | 53 | @no_duplicates 54 | @patch('builtins.print') 55 | @patch('tda.scripts.orders_codegen.client_from_token_file') 56 | @patch('tda.scripts.orders_codegen.construct_repeat_order') 57 | @patch('tda.scripts.orders_codegen.code_for_builder') 58 | def test_no_account_id_no_such_order( 59 | self, 60 | mock_code_for_builder, 61 | mock_construct_repeat_order, 62 | mock_client_from_token_file, 63 | mock_print): 64 | self.add_arg('--token_file') 65 | self.add_arg('filename.json') 66 | self.add_arg('--api_key') 67 | self.add_arg('api-key') 68 | 69 | orders = [] 70 | 71 | mock_client = MagicMock() 72 | mock_client_from_token_file.return_value = mock_client 73 | mock_client.get_orders_by_query.return_value.json.return_value = orders 74 | 75 | self.assertEqual(self.main(), 0) 76 | 77 | mock_construct_repeat_order.assert_not_called() 78 | mock_print.assert_called_once_with('No recent orders found') 79 | 80 | 81 | @no_duplicates 82 | @patch('builtins.print') 83 | @patch('tda.scripts.orders_codegen.client_from_token_file') 84 | @patch('tda.scripts.orders_codegen.construct_repeat_order') 85 | @patch('tda.scripts.orders_codegen.code_for_builder') 86 | def test_no_account_error( 87 | self, 88 | mock_code_for_builder, 89 | mock_construct_repeat_order, 90 | mock_client_from_token_file, 91 | mock_print): 92 | self.add_arg('--token_file') 93 | self.add_arg('filename.json') 94 | self.add_arg('--api_key') 95 | self.add_arg('api-key') 96 | 97 | orders = {'error': 'invalid'} 98 | 99 | mock_client = MagicMock() 100 | mock_client_from_token_file.return_value = mock_client 101 | mock_client.get_orders_by_query.return_value.json.return_value = orders 102 | 103 | self.assertEqual(self.main(), -1) 104 | 105 | mock_construct_repeat_order.assert_not_called() 106 | mock_print.assert_called_once_with( 107 | AnyStringWith('TDA returned error: "invalid"')) 108 | 109 | 110 | @no_duplicates 111 | @patch('builtins.print') 112 | @patch('tda.scripts.orders_codegen.client_from_token_file') 113 | @patch('tda.scripts.orders_codegen.construct_repeat_order') 114 | @patch('tda.scripts.orders_codegen.code_for_builder') 115 | def test_success_account_id( 116 | self, 117 | mock_code_for_builder, 118 | mock_construct_repeat_order, 119 | mock_client_from_token_file, 120 | mock_print): 121 | self.add_arg('--token_file') 122 | self.add_arg('filename.json') 123 | self.add_arg('--api_key') 124 | self.add_arg('api-key') 125 | self.add_arg('--account_id') 126 | self.add_arg('123456') 127 | 128 | orders = [ 129 | {'orderId': 201}, 130 | {'orderId': 101}, 131 | {'orderId': 301}, 132 | {'orderId': 401}, 133 | ] 134 | 135 | mock_client = MagicMock() 136 | mock_client_from_token_file.return_value = mock_client 137 | mock_client.get_orders_by_path.return_value.json.return_value = orders 138 | 139 | self.assertEqual(self.main(), 0) 140 | 141 | mock_construct_repeat_order.assert_called_once_with(orders[3]) 142 | mock_print.assert_has_calls([ 143 | call('# Order ID', 401), 144 | call(mock_code_for_builder.return_value)]) 145 | 146 | 147 | @no_duplicates 148 | @patch('builtins.print') 149 | @patch('tda.scripts.orders_codegen.client_from_token_file') 150 | @patch('tda.scripts.orders_codegen.construct_repeat_order') 151 | @patch('tda.scripts.orders_codegen.code_for_builder') 152 | def test_account_id_no_orders( 153 | self, 154 | mock_code_for_builder, 155 | mock_construct_repeat_order, 156 | mock_client_from_token_file, 157 | mock_print): 158 | self.add_arg('--token_file') 159 | self.add_arg('filename.json') 160 | self.add_arg('--api_key') 161 | self.add_arg('api-key') 162 | self.add_arg('--account_id') 163 | self.add_arg('123456') 164 | 165 | orders = [] 166 | 167 | mock_client = MagicMock() 168 | mock_client_from_token_file.return_value = mock_client 169 | mock_client.get_orders_by_path.return_value.json.return_value = orders 170 | 171 | self.assertEqual(self.main(), 0) 172 | 173 | mock_construct_repeat_order.assert_not_called 174 | mock_print.assert_called_once_with('No recent orders found') 175 | 176 | 177 | @no_duplicates 178 | @patch('builtins.print') 179 | @patch('tda.scripts.orders_codegen.client_from_token_file') 180 | @patch('tda.scripts.orders_codegen.construct_repeat_order') 181 | @patch('tda.scripts.orders_codegen.code_for_builder') 182 | def test_account_id_error( 183 | self, 184 | mock_code_for_builder, 185 | mock_construct_repeat_order, 186 | mock_client_from_token_file, 187 | mock_print): 188 | self.add_arg('--token_file') 189 | self.add_arg('filename.json') 190 | self.add_arg('--api_key') 191 | self.add_arg('api-key') 192 | self.add_arg('--account_id') 193 | self.add_arg('123456') 194 | 195 | orders = {'error': 'invalid'} 196 | 197 | mock_client = MagicMock() 198 | mock_client_from_token_file.return_value = mock_client 199 | mock_client.get_orders_by_path.return_value.json.return_value = orders 200 | 201 | self.assertEqual(self.main(), -1) 202 | 203 | mock_construct_repeat_order.assert_not_called 204 | mock_print.assert_called_once_with( 205 | AnyStringWith('TDA returned error: "invalid"')) 206 | -------------------------------------------------------------------------------- /tests/testdata/principals.json: -------------------------------------------------------------------------------- 1 | { 2 | "authToken": "authToken", 3 | "userId": "user", 4 | "userCdDomainId": "userCdDomainId", 5 | "primaryAccountId": "100000001", 6 | "lastLoginTime": "2020-05-22T01:07:38+0000", 7 | "tokenExpirationTime": "2020-05-22T02:27:37+0000", 8 | "loginTime": "2020-05-22T01:57:37+0000", 9 | "accessLevel": "CUS", 10 | "stalePassword": false, 11 | "streamerInfo": { 12 | "streamerBinaryUrl": "streamer-bin.tdameritrade.com", 13 | "streamerSocketUrl": "streamer-ws.tdameritrade.com", 14 | "token": "streamerInfo-token", 15 | "tokenTimestamp": "2020-05-22T02:12:48+0000", 16 | "userGroup": "streamerInfo-userGroup", 17 | "accessLevel": "streamerInfo-accessLevel", 18 | "acl": "streamerInfo-acl", 19 | "appId": "streamerInfo-appId" 20 | }, 21 | "professionalStatus": "NON_PROFESSIONAL", 22 | "quotes": { 23 | "isNyseDelayed": false, 24 | "isNasdaqDelayed": false, 25 | "isOpraDelayed": false, 26 | "isAmexDelayed": false, 27 | "isCmeDelayed": false, 28 | "isIceDelayed": false, 29 | "isForexDelayed": false 30 | }, 31 | "streamerSubscriptionKeys": { 32 | "keys": [ 33 | { 34 | "key": "streamerSubscriptionKeys-keys-key" 35 | } 36 | ] 37 | }, 38 | "accounts": [ 39 | { 40 | "accountId": "1001", 41 | "displayName": "accounts-displayName", 42 | "accountCdDomainId": "accounts-accountCdDomainId", 43 | "company": "accounts-company", 44 | "segment": "AMER", 45 | "acl": "accounts-acl", 46 | "authorizations": { 47 | "apex": true, 48 | "levelTwoQuotes": true, 49 | "stockTrading": true, 50 | "marginTrading": true, 51 | "streamingNews": true, 52 | "optionTradingLevel": "LEVEL_THREE", 53 | "streamerAccess": true, 54 | "advancedMargin": true, 55 | "scottradeAccount": true 56 | } 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /tests/token_lifecycle_test.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Token management has become a rather complex affair: many ways of creating one, 3 | plus legacy vs. new formats, plus refresh token updating, etc. This test suite 4 | exercises the combinatorial explosion of possibilities. 5 | ''' 6 | 7 | from .utils import no_duplicates, MockOAuthClient, MockAsyncOAuthClient 8 | from unittest.mock import patch, MagicMock 9 | 10 | import copy 11 | import json 12 | import os 13 | import tda 14 | import tempfile 15 | import unittest 16 | 17 | 18 | CREATION_TIMESTAMP = 1614258912 19 | MOCK_NOW = CREATION_TIMESTAMP + 60*60*24*91 20 | RECENT_TIMESTAMP = MOCK_NOW - 1 21 | 22 | API_KEY = '123456789@AMER.OAUTHAP' 23 | REDIRECT_URL = 'https://redirect.url.com' 24 | 25 | 26 | class TokenLifecycleTest(unittest.TestCase): 27 | 28 | def asyncio(self): 29 | return False 30 | 31 | def setUp(self): 32 | self.maxDiff = None 33 | 34 | self.tmp_dir = tempfile.TemporaryDirectory() 35 | self.token_path = os.path.join(self.tmp_dir.name, 'token.json') 36 | 37 | self.old_token = { 38 | 'access_token': 'access_token_123', 39 | 'refresh_token': 'refresh_token_123', 40 | 'scope': 'PlaceTrades AccountAccess MoveMoney', 41 | 'expires_in': 1800, 42 | 'refresh_token_expires_in': 7776000, 43 | 'token_type': 'Bearer', 44 | 'expires_at': CREATION_TIMESTAMP + 1, 45 | } 46 | 47 | self.updated_token = copy.deepcopy(self.old_token) 48 | self.updated_token['refresh_token'] = 'refresh_token_123_updated' 49 | 50 | 51 | # Token setup methods 52 | 53 | def old_metadata_token(self): 54 | return { 55 | 'creation_timestamp': CREATION_TIMESTAMP, 56 | 'token': self.old_token, 57 | } 58 | 59 | 60 | def write_legacy_token(self): 61 | 'Token written using the old legacy format, which lacks metadata.' 62 | with open(self.token_path, 'w') as f: 63 | json.dump(self.old_token, f) 64 | 65 | def write_old_metadata_token(self): 66 | 'Token created with metadata, but with an old creation timestamp.' 67 | with open(self.token_path, 'w') as f: 68 | json.dump(self.old_metadata_token(), f) 69 | 70 | def write_recent_metadata_token(self): 71 | 'Token created with metadata, with a recent creation timestamp.' 72 | with open(self.token_path, 'w') as f: 73 | json.dump({ 74 | 'creation_timestamp': RECENT_TIMESTAMP, 75 | 'token': self.old_token, 76 | }, f) 77 | 78 | def write_metadata_token_no_timestamp(self): 79 | 'Token created with metadata, with no creation timestamp.' 80 | with open(self.token_path, 'w') as f: 81 | json.dump({ 82 | 'creation_timestamp': None, 83 | 'token': self.old_token, 84 | }, f) 85 | 86 | 87 | # On-disk verification methods 88 | 89 | def verify_updated_token(self): 90 | 'Verify that an updated token was written' 91 | with open(self.token_path, 'r') as f: 92 | token = json.load(f) 93 | self.assertEqual(token, { 94 | 'creation_timestamp': MOCK_NOW, 95 | 'token': self.updated_token, 96 | }) 97 | 98 | def verify_not_updated_token(self): 99 | 'Verify that the original token is still in place' 100 | with open(self.token_path, 'r') as f: 101 | token = json.load(f) 102 | self.assertEqual(token, { 103 | 'creation_timestamp': RECENT_TIMESTAMP, 104 | 'token': self.old_token, 105 | }) 106 | 107 | 108 | # Client creation methods. Note these rely on mocks being patched in by 109 | # calling tests. 110 | 111 | def client_from_token_file(self): 112 | return tda.auth.client_from_token_file( 113 | self.token_path, API_KEY, asyncio=self.asyncio()) 114 | 115 | 116 | def client_from_login_flow(self, mock_OAuth2Client): 117 | mock_webdriver = MagicMock() 118 | mock_webdriver.current_url = REDIRECT_URL + '/token_params' 119 | 120 | mock_oauth = MagicMock() 121 | mock_oauth.create_authorization_url.return_value = ( 122 | 'https://redirect-url.com/', None) 123 | mock_oauth.fetch_token.return_value = self.old_metadata_token()['token'] 124 | mock_OAuth2Client.return_value = mock_oauth 125 | 126 | return tda.auth.client_from_login_flow( 127 | mock_webdriver, API_KEY, REDIRECT_URL, self.token_path, 128 | asyncio=self.asyncio()) 129 | 130 | 131 | def client_from_manual_flow(self, mock_OAuth2Client): 132 | # Note we don't care about the mock input function because its output is 133 | # only passed to fetch_token, and we're not placing any expectations on 134 | # that method. 135 | mock_oauth = MagicMock() 136 | mock_oauth.create_authorization_url.return_value = ( 137 | 'https://redirect-url.com/', None) 138 | mock_oauth.fetch_token.return_value = self.old_metadata_token()['token'] 139 | mock_OAuth2Client.return_value = mock_oauth 140 | 141 | return tda.auth.client_from_manual_flow( 142 | API_KEY, REDIRECT_URL, self.token_path, asyncio=self.asyncio()) 143 | 144 | 145 | # Creation via client_from_token_file 146 | 147 | @no_duplicates 148 | @patch('tda.auth.OAuth2Client', new_callable=MockOAuthClient) 149 | @patch('tda.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) 150 | @patch('time.time', MagicMock(return_value=MOCK_NOW)) 151 | def test_client_from_token_file_legacy_token( 152 | self, mock_AsyncOAuth2Client, mock_OAuth2Client): 153 | self.write_legacy_token() 154 | client = self.client_from_token_file() 155 | 156 | mock_oauth = MagicMock() 157 | mock_oauth.fetch_token.return_value = self.updated_token 158 | mock_OAuth2Client.return_value = mock_oauth 159 | 160 | client.ensure_updated_refresh_token() 161 | 162 | self.verify_updated_token() 163 | 164 | 165 | @no_duplicates 166 | @patch('tda.auth.OAuth2Client', new_callable=MockOAuthClient) 167 | @patch('tda.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) 168 | @patch('time.time', MagicMock(return_value=MOCK_NOW)) 169 | def test_client_from_token_file_old_metadata_token( 170 | self, mock_AsyncOAuth2Client, mock_OAuth2Client): 171 | self.write_old_metadata_token() 172 | client = self.client_from_token_file() 173 | 174 | mock_oauth = MagicMock() 175 | mock_oauth.fetch_token.return_value = self.updated_token 176 | mock_OAuth2Client.return_value = mock_oauth 177 | 178 | client.ensure_updated_refresh_token() 179 | 180 | self.verify_updated_token() 181 | 182 | 183 | @no_duplicates 184 | @patch('tda.auth.OAuth2Client', new_callable=MockOAuthClient) 185 | @patch('tda.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) 186 | @patch('time.time', MagicMock(return_value=MOCK_NOW)) 187 | def test_client_from_token_file_recent_metadata_token( 188 | self, mock_AsyncOAuth2Client, mock_OAuth2Client): 189 | self.write_recent_metadata_token() 190 | client = self.client_from_token_file() 191 | 192 | mock_oauth = MagicMock() 193 | mock_oauth.fetch_token.return_value = self.updated_token 194 | mock_OAuth2Client.return_value = mock_oauth 195 | 196 | client.ensure_updated_refresh_token() 197 | 198 | self.verify_not_updated_token() 199 | 200 | @no_duplicates 201 | @patch('tda.auth.OAuth2Client', new_callable=MockOAuthClient) 202 | @patch('tda.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) 203 | @patch('time.time', MagicMock(return_value=MOCK_NOW)) 204 | def test_client_from_token_file_metadata_token_no_timestamp( 205 | self, mock_AsyncOAuth2Client, mock_OAuth2Client): 206 | self.write_metadata_token_no_timestamp() 207 | client = self.client_from_token_file() 208 | 209 | mock_oauth = MagicMock() 210 | mock_oauth.fetch_token.return_value = self.updated_token 211 | mock_OAuth2Client.return_value = mock_oauth 212 | 213 | client.ensure_updated_refresh_token() 214 | 215 | self.verify_updated_token() 216 | 217 | 218 | # Creation via client_from_login_flow 219 | 220 | @no_duplicates 221 | @patch('tda.auth.OAuth2Client', new_callable=MockOAuthClient) 222 | @patch('tda.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) 223 | @patch('time.time') 224 | def test_client_from_login_flow_old_token( 225 | self, mock_time, mock_AsyncOAuth2Client, mock_OAuth2Client): 226 | self.write_legacy_token() 227 | mock_time.return_value = CREATION_TIMESTAMP 228 | client = self.client_from_login_flow(mock_OAuth2Client) 229 | 230 | mock_oauth = MagicMock() 231 | mock_oauth.fetch_token.return_value = self.updated_token 232 | mock_OAuth2Client.return_value = mock_oauth 233 | 234 | mock_time.return_value = MOCK_NOW 235 | 236 | client.ensure_updated_refresh_token() 237 | 238 | self.verify_updated_token() 239 | 240 | 241 | @no_duplicates 242 | @patch('tda.auth.OAuth2Client', new_callable=MockOAuthClient) 243 | @patch('tda.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) 244 | @patch('time.time') 245 | def test_client_from_login_flow_recent_token( 246 | self, mock_time, mock_AsyncOAuth2Client, mock_OAuth2Client): 247 | self.write_legacy_token() 248 | mock_time.return_value = RECENT_TIMESTAMP 249 | client = self.client_from_login_flow(mock_OAuth2Client) 250 | 251 | mock_oauth = MagicMock() 252 | mock_oauth.fetch_token.return_value = self.updated_token 253 | mock_OAuth2Client.return_value = mock_oauth 254 | 255 | mock_time.return_value = MOCK_NOW 256 | 257 | client.ensure_updated_refresh_token() 258 | 259 | self.verify_not_updated_token() 260 | 261 | 262 | # Creation via client_from_manual_flow 263 | 264 | @no_duplicates 265 | @patch('tda.auth.OAuth2Client', new_callable=MockOAuthClient) 266 | @patch('tda.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) 267 | @patch('time.time') 268 | @patch('tda.auth.prompt', MagicMock()) 269 | def test_client_from_manual_flow_old_token( 270 | self, mock_time, mock_AsyncOAuth2Client, mock_OAuth2Client): 271 | self.write_legacy_token() 272 | mock_time.return_value = CREATION_TIMESTAMP 273 | client = self.client_from_manual_flow(mock_OAuth2Client) 274 | 275 | mock_oauth = MagicMock() 276 | mock_oauth.fetch_token.return_value = self.updated_token 277 | mock_OAuth2Client.return_value = mock_oauth 278 | 279 | mock_time.return_value = MOCK_NOW 280 | 281 | client.ensure_updated_refresh_token() 282 | 283 | self.verify_updated_token() 284 | 285 | 286 | @no_duplicates 287 | @patch('tda.auth.OAuth2Client', new_callable=MockOAuthClient) 288 | @patch('tda.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) 289 | @patch('time.time') 290 | @patch('tda.auth.prompt', MagicMock()) 291 | def test_client_from_manual_flow_recent_token( 292 | self, mock_time, mock_AsyncOAuth2Client, mock_OAuth2Client): 293 | self.write_legacy_token() 294 | mock_time.return_value = RECENT_TIMESTAMP 295 | client = self.client_from_manual_flow(mock_OAuth2Client) 296 | 297 | mock_oauth = MagicMock() 298 | mock_oauth.fetch_token.return_value = self.updated_token 299 | mock_OAuth2Client.return_value = mock_oauth 300 | 301 | mock_time.return_value = MOCK_NOW 302 | 303 | client.ensure_updated_refresh_token() 304 | 305 | self.verify_not_updated_token() 306 | 307 | 308 | # Same as above, except async 309 | class TokenLifecycleTestAsync(TokenLifecycleTest): 310 | def asyncio(self): 311 | return True 312 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from colorama import Fore, Back, Style, init 2 | from unittest.mock import MagicMock 3 | 4 | import asyncio 5 | import asynctest 6 | import difflib 7 | import inspect 8 | import httpx 9 | import json 10 | 11 | class AnyStringWith(str): 12 | ''' 13 | Utility for checking whether a function was called with the given string as 14 | a substring. 15 | ''' 16 | def __eq__(self, other): 17 | return self in other 18 | 19 | def account_principals(): 20 | with open('tests/testdata/principals.json', 'r') as f: 21 | return json.load(f) 22 | 23 | 24 | def real_order(): 25 | return { 26 | 'session': 'NORMAL', 27 | 'duration': 'DAY', 28 | 'orderType': 'MARKET', 29 | 'complexOrderStrategyType': 'NONE', 30 | 'quantity': 1.0, 31 | 'filledQuantity': 1.0, 32 | 'remainingQuantity': 0.0, 33 | 'requestedDestination': 'AUTO', 34 | 'destinationLinkName': 'ETMM', 35 | 'price': 58.41, 36 | 'orderLegCollection': [ 37 | { 38 | 'orderLegType': 'EQUITY', 39 | 'legId': 1, 40 | 'instrument': { 41 | 'assetType': 'EQUITY', 42 | 'cusip': '126650100', 43 | 'symbol': 'CVS' 44 | }, 45 | 'instruction': 'BUY', 46 | 'positionEffect': 'OPENING', 47 | 'quantity': 1.0 48 | } 49 | ], 50 | 'orderStrategyType': 'SINGLE', 51 | 'orderId': 100001, 52 | 'cancelable': False, 53 | 'editable': False, 54 | 'status': 'FILLED', 55 | 'enteredTime': '2020-03-30T15:36:12+0000', 56 | 'closeTime': '2020-03-30T15:36:12+0000', 57 | 'tag': 'API_TDAM:App', 58 | 'accountId': 100000, 59 | 'orderActivityCollection': [ 60 | { 61 | 'activityType': 'EXECUTION', 62 | 'executionType': 'FILL', 63 | 'quantity': 1.0, 64 | 'orderRemainingQuantity': 0.0, 65 | 'executionLegs': [ 66 | { 67 | 'legId': 1, 68 | 'quantity': 1.0, 69 | 'mismarkedQuantity': 0.0, 70 | 'price': 58.1853, 71 | 'time': '2020-03-30T15:36:12+0000' 72 | } 73 | ] 74 | } 75 | ] 76 | } 77 | 78 | class AsyncResync: 79 | """ 80 | Re-synchronizes every async function on a given object. 81 | NOTE: Every method runs on a new loop 82 | """ 83 | 84 | class _AsyncResyncMethod: 85 | def __init__(self, func): 86 | self.func = func 87 | def __call__(self, *args, **kwargs): 88 | coroutine = self.func(*args, **kwargs) 89 | loop = asyncio.new_event_loop() 90 | retval = loop.run_until_complete(coroutine) 91 | loop.close() 92 | return retval 93 | 94 | def __getattr__(self, attr): 95 | retval = super().__getattribute__(attr) 96 | if inspect.iscoroutinefunction(retval): 97 | return self._AsyncResyncMethod(retval) 98 | return retval 99 | __getattribute__ = __getattr__ 100 | 101 | 102 | class ResyncProxy: 103 | """ 104 | Proxies the underlying class, replacing coroutine methods 105 | with an auto-executing one 106 | """ 107 | def __init__(self, cls): 108 | self.cls = cls 109 | 110 | def __call__(self, *args, **kwargs): 111 | class DynamicResync(AsyncResync, self.cls): 112 | """ 113 | Forces a mixin of the underlying class and the AsyncResync 114 | class 115 | """ 116 | pass 117 | return DynamicResync(*args, **kwargs) 118 | 119 | def __getattr__(self, key): 120 | if key == 'cls': 121 | return super().__getattribute__(key) 122 | return getattr(self.cls, key) 123 | 124 | 125 | # TODO: Figure out if httpx supports cleaner response mocking, because this is 126 | # pretty janky. 127 | class MockResponse(httpx.Response): 128 | def __init__(self, json, status_code, headers=None): 129 | resp_args = { 130 | 'status_code': status_code, 131 | 'json': json, 132 | } 133 | if headers: 134 | resp_args['headers'] = headers 135 | httpx.Response.__init__( 136 | self, **resp_args) 137 | 138 | 139 | class AsyncMagicMock: 140 | """ 141 | Simple mock that returns a asynctest.CoroutineMock instance for every 142 | attribute. Useful for mocking async libraries 143 | """ 144 | def __init__(self): 145 | self.__attr_cache = {} 146 | 147 | def __getattr__(self, key): 148 | attr_cache = super().__getattribute__('_AsyncMagicMock__attr_cache') 149 | if key == '_AsyncMagicMock__attr_cache': return attr_cache 150 | 151 | try: 152 | return super().__getattribute__(key) 153 | except AttributeError: 154 | if key not in attr_cache: 155 | attr_cache[key] = asynctest.CoroutineMock() 156 | return attr_cache[key] 157 | 158 | def __setattr__(self, key, val): 159 | if key == '_AsyncMagicMock__attr_cache': 160 | return super().__setattr__(key, val) 161 | attr_cache = super().__getattribute__('_AsyncMagicMock__attr_cache') 162 | attr_cache[key] = val 163 | 164 | def __hasattr__(self, key): 165 | attr_cache = super().__getattribute__('_AsyncMagicMock__attr_cache') 166 | return attr_cache.has_key(key) 167 | 168 | def reset_mock(self): 169 | self.__attr_cache.clear() 170 | 171 | def has_diff(old, new): 172 | old_out = json.dumps(old, indent=4, sort_keys=True).splitlines() 173 | new_out = json.dumps(new, indent=4, sort_keys=True).splitlines() 174 | diff = difflib.ndiff(old_out, new_out) 175 | diff, has_diff = color_diff(diff) 176 | 177 | if has_diff: 178 | print('\n'.join(diff)) 179 | return has_diff 180 | 181 | 182 | def color_diff(diff): 183 | has_diff = False 184 | output = [] 185 | for line in diff: 186 | if line.startswith('+'): 187 | output.append(Fore.GREEN + line + Fore.RESET) 188 | has_diff = True 189 | elif line.startswith('-'): 190 | output.append(Fore.RED + line + Fore.RESET) 191 | has_diff = True 192 | elif line.startswith('^'): 193 | output.append(Fore.BLUE + line + Fore.RESET) 194 | has_diff = True 195 | else: 196 | output.append(line) 197 | return output, has_diff 198 | 199 | 200 | __NO_DUPLICATES_DEFINED_NAMES = set() 201 | 202 | 203 | def no_duplicates(f): 204 | name = f.__qualname__ 205 | if name in __NO_DUPLICATES_DEFINED_NAMES: 206 | raise AttributeError('duplicate definition of {}'.format(name)) 207 | __NO_DUPLICATES_DEFINED_NAMES.add(name) 208 | return f 209 | 210 | 211 | class MockOAuthClient(MagicMock): 212 | def __call__(self, *args, **kwargs): 213 | if 'update_token' in kwargs: 214 | assert not inspect.iscoroutinefunction(kwargs['update_token']) 215 | return MagicMock.__call__(self, *args, **kwargs) 216 | 217 | 218 | class MockAsyncOAuthClient(MagicMock): 219 | def __call__(self, *args, **kwargs): 220 | if 'update_token' in kwargs: 221 | assert inspect.iscoroutinefunction(kwargs['update_token']) 222 | return MagicMock.__call__(self, *args, **kwargs) 223 | -------------------------------------------------------------------------------- /tests/utils_test.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | from tda.utils import AccountIdMismatchException, Utils 3 | from tda.utils import UnsuccessfulOrderException 4 | from tda.utils import EnumEnforcer 5 | from .utils import no_duplicates, MockResponse 6 | 7 | import enum 8 | import unittest 9 | 10 | 11 | class EnumEnforcerTest(unittest.TestCase): 12 | 13 | class TestClass(EnumEnforcer): 14 | def test_enforcement(self, value): 15 | self.convert_enum(value, EnumEnforcerTest.TestEnum) 16 | 17 | 18 | class TestEnum(enum.Enum): 19 | VALUE_1 = 1 20 | VALUE_2 = 2 21 | 22 | 23 | def test_valid_enum(self): 24 | t = self.TestClass(enforce_enums=True) 25 | t.test_enforcement(self.TestEnum.VALUE_1) 26 | 27 | def test_invalid_enum_passed_as_string(self): 28 | t = self.TestClass(enforce_enums=True) 29 | with self.assertRaisesRegex( 30 | ValueError, 'tests.utils_test.TestEnum.VALUE_1'): 31 | t.test_enforcement('VALUE_1') 32 | 33 | def test_invalid_enum_passed_as_not_string(self): 34 | t = self.TestClass(enforce_enums=True) 35 | with self.assertRaises(ValueError): 36 | t.test_enforcement(123) 37 | 38 | 39 | class UtilsTest(unittest.TestCase): 40 | 41 | def setUp(self): 42 | self.mock_client = MagicMock() 43 | self.account_id = 10000 44 | self.utils = Utils(self.mock_client, self.account_id) 45 | 46 | self.order_id = 1 47 | 48 | self.maxDiff = None 49 | 50 | ########################################################################## 51 | # extract_order_id tests 52 | 53 | @no_duplicates 54 | def test_extract_order_id_order_not_ok(self): 55 | response = MockResponse({}, 403) 56 | with self.assertRaises( 57 | UnsuccessfulOrderException, msg='order not successful'): 58 | self.utils.extract_order_id(response) 59 | 60 | @no_duplicates 61 | def test_extract_order_id_no_location(self): 62 | response = MockResponse({}, 200, headers={}) 63 | self.assertIsNone(self.utils.extract_order_id(response)) 64 | 65 | @no_duplicates 66 | def test_extract_order_id_no_pattern_match(self): 67 | response = MockResponse({}, 200, headers={ 68 | 'Location': 'https://api.tdameritrade.com/v1/accounts/12345'}) 69 | self.assertIsNone(self.utils.extract_order_id(response)) 70 | 71 | @no_duplicates 72 | def test_get_order_nonmatching_account_id(self): 73 | response = MockResponse({}, 200, headers={ 74 | 'Location': 75 | 'https://api.tdameritrade.com/v1/accounts/{}/orders/456'.format( 76 | self.account_id + 1)}) 77 | with self.assertRaises( 78 | AccountIdMismatchException, 79 | msg='order request account ID != Utils.account_id'): 80 | self.utils.extract_order_id(response) 81 | 82 | @no_duplicates 83 | def test_get_order_nonmatching_account_id_str(self): 84 | self.utils = Utils(self.mock_client, str(self.account_id)) 85 | 86 | response = MockResponse({}, 200, headers={ 87 | 'Location': 88 | 'https://api.tdameritrade.com/v1/accounts/{}/orders/456'.format( 89 | self.account_id + 1)}) 90 | with self.assertRaises( 91 | AccountIdMismatchException, 92 | msg='order request account ID != Utils.account_id'): 93 | self.utils.extract_order_id(response) 94 | 95 | @no_duplicates 96 | def test_get_order_success_200(self): 97 | order_id = self.account_id + 100 98 | response = MockResponse({}, 200, headers={ 99 | 'Location': 100 | 'https://api.tdameritrade.com/v1/accounts/{}/orders/{}'.format( 101 | self.account_id, order_id)}) 102 | self.assertEqual(order_id, self.utils.extract_order_id(response)) 103 | 104 | @no_duplicates 105 | def test_get_order_success_201(self): 106 | order_id = self.account_id + 100 107 | response = MockResponse({}, 201, headers={ 108 | 'Location': 109 | 'https://api.tdameritrade.com/v1/accounts/{}/orders/{}'.format( 110 | self.account_id, order_id)}) 111 | self.assertEqual(order_id, self.utils.extract_order_id(response)) 112 | 113 | @no_duplicates 114 | def test_get_order_success_str_account_id(self): 115 | self.utils = Utils(self.mock_client, str(self.account_id)) 116 | 117 | order_id = self.account_id + 100 118 | response = MockResponse({}, 200, headers={ 119 | 'Location': 120 | 'https://api.tdameritrade.com/v1/accounts/{}/orders/{}'.format( 121 | self.account_id, order_id)}) 122 | self.assertEqual(order_id, self.utils.extract_order_id(response)) 123 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py37,py38,py39,py310,py311} 4 | coverage 5 | 6 | [testenv] 7 | setenv = 8 | TESTPATH=tests/ 9 | RCFILE=setup.cfg 10 | allowlist_externals=coverage 11 | commands = 12 | coverage run --rcfile={env:RCFILE} --source=tda -p -m pytest {env:TESTPATH} 13 | 14 | [testenv:coverage] 15 | skip_install = true 16 | commands = 17 | coverage combine 18 | coverage report 19 | coverage html 20 | --------------------------------------------------------------------------------