├── .dockerignore ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── docker-publish.yml │ ├── python-publish.yaml │ ├── python-test.yaml │ └── stale.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .taplo.toml ├── COPYING ├── Dockerfile ├── LICENSE ├── README.md ├── data └── jxbrowser-linux64-arm-7.29.jar ├── entrypoint.bash ├── extract-installer.sh ├── ibc-config.ini ├── pyproject.toml ├── sample.png ├── stubs ├── click_log │ ├── __init__.pyi │ ├── core.pyi │ └── options.pyi └── schema │ ├── __init__.pyi │ └── contextlib2 │ └── __init__.pyi ├── thetagang.jpg ├── thetagang.toml ├── thetagang ├── __init__.py ├── config.py ├── entry.py ├── exchange_hours.py ├── fmt.py ├── ibkr.py ├── log.py ├── main.py ├── options.py ├── orders.py ├── portfolio_manager.py ├── test_config.py ├── test_exchange_hours.py ├── test_ibkr.py ├── test_trades.py ├── test_util.py ├── thetagang.py ├── trades.py └── util.py └── uv.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | .git/ 4 | ibc.log 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | # pytype static type analyzer 139 | .pytype/ 140 | 141 | # Cython debug symbols 142 | cython_debug/ 143 | 144 | # Ignore dotenv/direnv files 145 | .env* 146 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: brndnmtthws 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "pip" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | groups: 12 | deps: 13 | patterns: 14 | - "*" 15 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker publish 2 | 3 | on: 4 | push: 5 | # Publish `main` as Docker `latest` image. 6 | branches: 7 | - main 8 | 9 | # Publish `v1.2.3` tags as releases. 10 | tags: 11 | - v* 12 | 13 | # Run tests for any PRs. 14 | pull_request: 15 | branches: [main] 16 | 17 | env: 18 | IMAGE_NAME: thetagang 19 | DOCKERHUB_ACCOUNT: brndnmtthws 20 | DOCKER_BUILDKIT: 1 21 | 22 | concurrency: 23 | group: ${{ github.workflow }}-${{ github.ref }} 24 | cancel-in-progress: true 25 | 26 | jobs: 27 | test: 28 | runs-on: ubuntu-latest 29 | strategy: 30 | matrix: 31 | python-version: ["3.10", "3.11", "3.12"] 32 | 33 | steps: 34 | - uses: actions/checkout@v4 35 | 36 | - name: Install uv and set the python version with caching 37 | uses: astral-sh/setup-uv@v6 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | enable-cache: true 41 | 42 | - name: Install the project 43 | run: uv sync --all-extras --dev 44 | 45 | - name: Test with pytest 46 | run: uv run py.test 47 | 48 | build-and-push: 49 | needs: test 50 | runs-on: ubuntu-latest 51 | 52 | steps: 53 | - uses: actions/checkout@v4 54 | 55 | - name: Cache TWS 56 | id: cache-tws 57 | uses: actions/cache@v4 58 | env: 59 | cache-name: cache-tws 60 | with: 61 | path: tws/ 62 | key: ${{ runner.os }}-publish-${{ env.cache-name }}-${{ hashFiles('extract-installer.sh') }} 63 | 64 | - name: Extract TWS installer 65 | if: steps.cache-tws.outputs.cache-hit != 'true' 66 | run: ./extract-installer.sh 67 | 68 | - name: Install uv and set the python version with caching 69 | uses: astral-sh/setup-uv@v6 70 | with: 71 | python-version: 3.11 72 | enable-cache: true 73 | 74 | - name: Install the project 75 | run: uv sync --all-extras 76 | 77 | - name: Build package 78 | run: uv build 79 | 80 | # Install the cosign tool except on PR 81 | # https://github.com/sigstore/cosign-installer 82 | - name: Install cosign 83 | if: github.event_name != 'pull_request' 84 | uses: sigstore/cosign-installer@main 85 | with: 86 | cosign-release: "v1.4.0" 87 | 88 | - name: Set up QEMU 89 | id: qemu 90 | uses: docker/setup-qemu-action@v3 91 | with: 92 | image: tonistiigi/binfmt:qemu-v6.1.0 93 | platforms: all 94 | 95 | # Workaround: https://github.com/docker/build-push-action/issues/461 96 | - name: Setup Docker buildx 97 | uses: docker/setup-buildx-action@v3 98 | 99 | # Login against a Docker registry except on PR 100 | # https://github.com/docker/login-action 101 | - name: Login to Docker Hub 102 | if: github.event_name != 'pull_request' 103 | uses: docker/login-action@v3 104 | with: 105 | username: ${{ env.DOCKERHUB_ACCOUNT }} 106 | password: ${{ secrets.DOCKERHUB_TOKEN }} 107 | 108 | # Extract metadata (tags, labels) for Docker 109 | # https://github.com/docker/metadata-action 110 | - name: Extract Docker metadata 111 | id: meta 112 | uses: docker/metadata-action@v5 113 | with: 114 | images: ${{ github.actor }}/${{ env.IMAGE_NAME }} 115 | 116 | # Build and push Docker image with Buildx (don't push on PR) 117 | # https://github.com/docker/build-push-action 118 | - name: Build and push Docker image 119 | id: build-and-push 120 | uses: docker/build-push-action@v6 121 | with: 122 | context: . 123 | platforms: linux/amd64,linux/arm64/v8 124 | push: ${{ github.event_name != 'pull_request' }} 125 | tags: ${{ steps.meta.outputs.tags }} 126 | labels: ${{ steps.meta.outputs.labels }} 127 | cache-from: type=gha 128 | cache-to: type=gha,mode=max 129 | no-cache: ${{ startsWith(github.ref, 'refs/tags/') }} 130 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Python Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ["3.10", "3.11", "3.12"] 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Install uv and set the python version with caching 17 | uses: astral-sh/setup-uv@v6 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | enable-cache: true 21 | 22 | - name: Install the project 23 | run: uv sync --all-extras --dev 24 | 25 | - name: Test with pytest 26 | run: uv run py.test 27 | 28 | build-and-publish: 29 | needs: test 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - name: Install uv and set the python version with caching 36 | uses: astral-sh/setup-uv@v6 37 | with: 38 | python-version: 3.11 39 | 40 | - name: Install the project 41 | run: uv sync --all-extras 42 | 43 | - name: Build 44 | run: uv build 45 | 46 | - name: Publish 47 | env: 48 | UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} 49 | run: uv publish 50 | -------------------------------------------------------------------------------- /.github/workflows/python-test.yaml: -------------------------------------------------------------------------------- 1 | name: Python Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ["3.10", "3.11", "3.12"] 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Install uv and set the python version with caching 23 | uses: astral-sh/setup-uv@v6 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | enable-cache: true 27 | 28 | - name: Install the project 29 | run: uv sync --all-extras --dev 30 | 31 | - name: Test with pytest 32 | run: uv run py.test 33 | 34 | - name: Check lints with ruff 35 | run: uv run ruff check --diff 36 | 37 | - name: Check formatting with ruff 38 | run: uv run ruff format --check --diff 39 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues and PRs" 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | jobs: 7 | stale: 8 | permissions: 9 | contents: write # for delete-branch option 10 | issues: write 11 | pull-requests: write 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/stale@v9 15 | with: 16 | stale-issue-message: "This issue is stale because it has been open 365 days with no activity. Remove stale label or comment, or this issue will be closed in 30 days." 17 | stale-pr-message: "This PR is stale because it has been open 365 days with no activity. Remove stale label or comment, or this PR will be closed in 30 days." 18 | close-issue-message: "This issue was closed because it has been stalled for 30 days with no activity." 19 | close-pr-message: "This PR was closed because it has been stalled for 30 days with no activity." 20 | days-before-issue-stale: 365 21 | days-before-pr-stale: 365 22 | days-before-issue-close: 30 23 | days-before-pr-close: 30 24 | delete-branch: true 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | ibc.log 3 | tws-installer.sh 4 | .idea/ 5 | # local configuration 6 | thetagang.local.toml 7 | 8 | # Ignore tws installer dir 9 | tws/ 10 | 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | share/python-wheels/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | MANIFEST 38 | 39 | # PyInstaller 40 | # Usually these files are written by a python script from a template 41 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .nox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *.cover 59 | *.py,cover 60 | .hypothesis/ 61 | .pytest_cache/ 62 | cover/ 63 | 64 | # Translations 65 | *.mo 66 | *.pot 67 | 68 | # Django stuff: 69 | *.log 70 | local_settings.py 71 | db.sqlite3 72 | db.sqlite3-journal 73 | 74 | # Flask stuff: 75 | instance/ 76 | .webassets-cache 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | .pybuilder/ 86 | target/ 87 | 88 | # Jupyter Notebook 89 | .ipynb_checkpoints 90 | 91 | # IPython 92 | profile_default/ 93 | ipython_config.py 94 | 95 | # pyenv 96 | # For a library or package, you might want to ignore these files since the code is 97 | # intended to run in multiple environments; otherwise, check them in: 98 | # .python-version 99 | 100 | # pipenv 101 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 102 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 103 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 104 | # install all needed dependencies. 105 | #Pipfile.lock 106 | 107 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 108 | __pypackages__/ 109 | 110 | # Celery stuff 111 | celerybeat-schedule 112 | celerybeat.pid 113 | 114 | # SageMath parsed files 115 | *.sage.py 116 | 117 | # Environments 118 | .env 119 | .venv 120 | env/ 121 | venv/ 122 | ENV/ 123 | env.bak/ 124 | venv.bak/ 125 | 126 | # Spyder project settings 127 | .spyderproject 128 | .spyproject 129 | 130 | # Rope project settings 131 | .ropeproject 132 | 133 | # mkdocs documentation 134 | /site 135 | 136 | # mypy 137 | .mypy_cache/ 138 | .dmypy.json 139 | dmypy.json 140 | 141 | # Pyre type checker 142 | .pyre/ 143 | 144 | # pytype static type analyzer 145 | .pytype/ 146 | 147 | # Cython debug symbols 148 | cython_debug/ 149 | 150 | # Ignore dotenv/direnv files 151 | .env* 152 | 153 | # Ignore notebooks 154 | *.ipynb 155 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | # Ruff version. 10 | rev: v0.9.1 11 | hooks: 12 | # Run the linter. 13 | - id: ruff 14 | args: [--fix] 15 | # Reorder imports. 16 | - id: ruff 17 | args: [check, --select, I, --fix] 18 | # Run the formatter. 19 | - id: ruff-format 20 | - repo: https://github.com/astral-sh/uv-pre-commit 21 | # uv version. 22 | rev: 0.5.18 23 | hooks: 24 | - id: uv-sync 25 | args: ["--locked", "--all-packages"] 26 | - repo: https://github.com/astral-sh/uv-pre-commit 27 | # uv version. 28 | rev: 0.5.18 29 | hooks: 30 | # Update the uv lockfile 31 | - id: uv-lock 32 | -------------------------------------------------------------------------------- /.taplo.toml: -------------------------------------------------------------------------------- 1 | [formatting] 2 | align_entries = true 3 | array_auto_collapse = false 4 | indent_tables = true 5 | reorder_arrays = false 6 | reorder_keys = true 7 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (C) 2020-2022 Brenden Matthews 2 | 3 | This program is free software: you can redistribute it and/or modify 4 | it under the terms of the GNU Affero General Public License as published by 5 | the Free Software Foundation, either version 3 of the License, or 6 | (at your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License 14 | along with this program. If not, see . 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM eclipse-temurin:17.0.8_7-jdk-jammy 2 | 3 | RUN apt-get update \ 4 | && DEBIAN_FRONTEND=noninteractive apt-get install -qy --no-install-recommends \ 5 | ca-certificates \ 6 | fonts-liberation \ 7 | libasound2 \ 8 | libatk-bridge2.0-0 \ 9 | libatk1.0-0 \ 10 | libatspi2.0-0 \ 11 | libc6 \ 12 | libcairo2 \ 13 | libcups2 \ 14 | libcurl4 \ 15 | libdbus-1-3 \ 16 | libdrm2 \ 17 | libexpat1 \ 18 | libgbm1 \ 19 | libglib2.0-0 \ 20 | libgtk-3-0 \ 21 | libnspr4 \ 22 | libnss3 \ 23 | libpango-1.0-0 \ 24 | libu2f-udev \ 25 | libvulkan1 \ 26 | libx11-6 \ 27 | libxcb1 \ 28 | libxcomposite1 \ 29 | libxdamage1 \ 30 | libxext6 \ 31 | libxfixes3 \ 32 | libxi6 \ 33 | libxkbcommon0 \ 34 | libxrandr2 \ 35 | libxrender1 \ 36 | libxtst6 \ 37 | openjfx \ 38 | python3-pip \ 39 | python3-setuptools \ 40 | unzip \ 41 | wget \ 42 | xdg-utils \ 43 | xvfb \ 44 | && if test "$(dpkg --print-architecture)" = "armhf" ; then python3 -m pip config set global.extra-index-url https://www.piwheels.org/simple ; fi \ 45 | && echo 'a3f9b93ea1ff6740d2880760fb73e1a6e63b454f86fe6366779ebd9cd41c1542 ibc.zip' | tee ibc.zip.sha256 \ 46 | && wget -q https://github.com/IbcAlpha/IBC/releases/download/3.20.0/IBCLinux-3.20.0.zip -O ibc.zip \ 47 | && sha256sum -c ibc.zip.sha256 \ 48 | && unzip ibc.zip -d /opt/ibc \ 49 | && chmod o+x /opt/ibc/*.sh /opt/ibc/*/*.sh \ 50 | && rm ibc.zip \ 51 | && apt-get clean \ 52 | && rm -rf /var/lib/apt/lists/* 53 | 54 | WORKDIR /src 55 | 56 | ADD ./tws/Jts /root/Jts 57 | ADD ./dist /src/dist 58 | ADD entrypoint.bash /src/entrypoint.bash 59 | ADD ./data/jxbrowser-linux64-arm-7.29.jar /root/Jts/1030/jars/ 60 | 61 | RUN python3 -m pip install dist/thetagang-*.whl \ 62 | && rm -rf /root/.cache \ 63 | && rm -rf dist \ 64 | && echo '--module-path /usr/share/openjfx/lib' | tee -a /root/Jts/*/tws.vmoptions \ 65 | && echo '--add-modules java.base,java.naming,java.management,javafx.base,javafx.controls,javafx.fxml,javafx.graphics,javafx.media,javafx.swing,javafx.web' | tee -a /root/Jts/*/tws.vmoptions \ 66 | && echo '--add-opens java.desktop/javax.swing=ALL-UNNAMED' | tee -a /root/Jts/*/tws.vmoptions \ 67 | && echo '--add-opens java.desktop/java.awt=ALL-UNNAMED' | tee -a /root/Jts/*/tws.vmoptions \ 68 | && echo '--add-opens java.base/java.util=ALL-UNNAMED' | tee -a /root/Jts/*/tws.vmoptions \ 69 | && echo '--add-opens javafx.graphics/com.sun.javafx.application=ALL-UNNAMED' | tee -a /root/Jts/*/tws.vmoptions \ 70 | && echo '[Logon]' | tee -a /root/Jts/jts.ini \ 71 | && echo 'UseSSL=true' | tee -a /root/Jts/jts.ini 72 | 73 | ENTRYPOINT [ "/src/entrypoint.bash" ] 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Docker publish](https://github.com/brndnmtthws/thetagang/workflows/Docker%20publish/badge.svg)](https://hub.docker.com/r/brndnmtthws/thetagang) [![Python Publish](https://github.com/brndnmtthws/thetagang/workflows/Python%20Publish/badge.svg)](https://pypi.org/project/thetagang/) [![Docker Pulls](https://img.shields.io/docker/pulls/brndnmtthws/thetagang)](https://hub.docker.com/r/brndnmtthws/thetagang) [![PyPI download month](https://img.shields.io/pypi/dm/thetagang?label=PyPI%20downloads)](https://pypi.python.org/pypi/thetagang/) 2 | 3 | [💬 Join the Matrix chat, we can get money together](https://matrix.to/#/#thetagang:frens.io). 4 | 5 | # Θ ThetaGang Θ 6 | 7 | _Beat the capitalists at their own game with ThetaGang 📈_ 8 | 9 | ![Decay my sweet babies](thetagang.jpg) 10 | 11 | ThetaGang is an [IBKR](https://www.interactivebrokers.com/) trading bot for 12 | collecting premium by selling options using "The Wheel" strategy. The Wheel 13 | is a strategy that [surfaced on 14 | Reddit](https://www.reddit.com/r/options/comments/a36k4j/the_wheel_aka_triple_income_strategy_explained/), 15 | but has been used by many in the past. This bot implements a slightly 16 | modified version of The Wheel, with my own personal tweaks. 17 | 18 | ## How it works 19 | 20 | Start by reading [the Reddit 21 | post](https://www.reddit.com/r/options/comments/a36k4j/the_wheel_aka_triple_income_strategy_explained/) 22 | to get some background. 23 | 24 | The strategy, as implemented here, does a few things differently from the one 25 | described in the post above. For one, it's intended to be used to augment a 26 | typical index-fund based portfolio with specific asset allocations. For 27 | example, you might want to use a 60/40 portfolio with SPY (S&P500 fund) and 28 | TLT (20 year treasury fund). This strategy reduces risk, but may also limit 29 | gains from big market swings. By reducing risk, one can increase leverage. 30 | 31 | ThetaGang is quite configurable, and you can adjust the parameters to suit your 32 | preferences and needs, but the default configuration is designed to be a good 33 | starting point. ThetaGang makes some assumptions about how to run this strategy, 34 | but you can tweak it to your liking by modifying the 35 | [`thetagang.toml`](https://github.com/brndnmtthws/thetagang/blob/main/thetagang.toml) 36 | file. 37 | 38 | The main difference between ThetaGang and simply buying and holding index funds 39 | is that this script will attempt to harvest volatility by selling options, 40 | rather than buying shares directly. This works because implied volatility is 41 | typically higher than realized volatility on average. Instead of buying shares, 42 | you write puts. This has pros and cons, which are outside the scope of this 43 | README. 44 | 45 | ThetaGang can also be used in combination with other strategies such as PMCCs, 46 | Zebra, stock replacement, and so forth. For these strategies, however, ThetaGang 47 | will not manage long positions for you. You will need to manage these positions 48 | yourself. ThetaGang will, however, continue to execute the short legs of these 49 | strategies as long as you have the buying power available and set the 50 | appropriate configuration (in particular, by setting 51 | `write_when.calculate_net_contracts = true`). 52 | 53 | You could use this tool on individual stocks, but I don't 54 | recommend it because I am not smart enough to understand which stocks to buy. 55 | That's why I buy index funds. 56 | 57 | ThetaGang will try to acquire your desired allocation of each stock or ETF 58 | according to the weights you specify in the config. To acquire the positions, 59 | the script will write puts when conditions are met (config parameters, adequate 60 | buying power, acceptable contracts are available, enough shares needed, etc). 61 | 62 | ThetaGang will continue to roll any open option positions indefinitely, with the 63 | only exception being ITM puts (although this is configurable). Once puts are in 64 | the money, they will be ignored until they expire and are exercised (after which 65 | you will own the underlying). When rolling puts, the strike of the new contracts 66 | are capped at the old strike plus the premium received (to prevent your account 67 | from blowing due to over-ratcheting up the buying power usage). 68 | 69 | If puts are exercised due to being ITM at expiration, you will own the stock, 70 | and ThetaGang switches from writing puts to writing calls at a strike at least 71 | as high as the average cost of the stock held. To avoid missing out on upward 72 | moves, you can limit the number of calls that are written with 73 | `write_when.calls.cap_factor`, such as setting this to 0.5 to limit the number 74 | of calls to 50% of the shares held. 75 | 76 | Please note: this strategy is based on the assumption that implied volatility 77 | is, on average, always higher than realized volatility. In cases where this 78 | is not true, this strategy will cause you to lose money. 79 | 80 | In the case of deep ITM calls, the bot will prefer to roll the calls to next 81 | strike or expiration rather than allowing the underlying to get called away. If 82 | you don't have adequate buying power available in your account, it's possible 83 | that the options may get exercised instead of rolling forward and the process 84 | starts back at the beginning. Please keep in mind this may have tax 85 | implications, but that is outside the scope of this README. 86 | 87 | In normal usage, you would run the script as a cronjob on a daily, weekly, or 88 | monthly basis according to your preferences. Running more frequently than 89 | daily is not recommended, but the choice is yours. 90 | 91 | ![Paper account sample output](sample.png) 92 | 93 | ### VIX call hedging 94 | 95 | ThetaGang can optionally hedge your account by purchasing VIX calls for the next 96 | month based on specified parameters. The strategy is based on the [Cboe VIX Tail 97 | Hedge Index](https://www.cboe.com/us/indices/dashboard/vxth/), which you can 98 | read about on the internet. You can enable this feature in `thetagang.toml` 99 | with: 100 | 101 | ```toml 102 | [vix_call_hedge] 103 | enabled = true 104 | ``` 105 | 106 | Default values are provided, based on the VXTH index, but you may configure 107 | them to your taste (refer to 108 | [`thetagang.toml`](https://github.com/brndnmtthws/thetagang/blob/6eab3823120c10c0563e02c5d7f30dfcc0e333fc/thetagang.toml#L294-L331) 109 | for details). 110 | 111 | Buying VIX calls is not free, and it _will_ create some drag on your portfolio, 112 | but in times of extreme volatility–such as the COVID-related 2020 market 113 | panic–VIX calls can provide outsized returns. 114 | 115 | ### Cash management 116 | 117 | At the time of writing, interest rates have reached yields that make bonds look 118 | attractive. To squeeze a little more juice, thetagang can do some simple cash 119 | management by purchasing a fund when you have extra cash. Although you do earn 120 | a yield on your cash balance, it's not the juiciest yield you can get, so a 121 | little optimization might help you earn 1 or 2 extra pennies to take the edge 122 | off your rent payments. 123 | 124 | There are quite a few ETFs that might be a decent place to stash your cash, and 125 | you should do some internet searches to find the most appropriate one for you 126 | and your feelings. Here are some internet web searches that you can test out to 127 | get some information on cash funds (ETFs): 128 | 129 | - ["cash etf reddit"](https://www.google.com/search?q=cash+etf+reddit) 130 | - ["sgov reddit"](https://www.google.com/search?q=sgov+reddit) 131 | - ["shv reddit"](https://www.google.com/search?q=shv+reddit) 132 | - ["short term government bond etf reddit"](https://www.google.com/search?q=short+term+government+bond+etf+reddit) 133 | 134 | You can enable cash management with: 135 | 136 | ```toml 137 | [cash_management] 138 | enabled = true 139 | ``` 140 | 141 | Refer to [`thetagang.toml`](https://github.com/brndnmtthws/thetagang/blob/4fc34653786ec17fe6ce6ec2406b2d861277f934/thetagang.toml#L330-L377) for all the options. 142 | 143 | ## Project status 144 | 145 | This project is, in its current state, considered to be complete. I'm open 146 | to contributions, but I am unlikely to accept PRs or feature requests that 147 | involve significant changes to the underlying algorithm. 148 | 149 | If you find something that you think is a bug, or some other issue, please 150 | [create a new issue](https://github.com/brndnmtthws/thetagang/issues/new). 151 | 152 | ## "Show me your gains bro" – i.e., what are the returns? 153 | 154 | As discussed elsewhere in this README, you must conduct your own research, and 155 | I suggest starting with resources such as CBOE's BXM and BXDM indices and 156 | comparing those to SPX. I've had a lot of people complain because "that 157 | strategy isn't better than buy and hold BRUH"–let me assure you, that is not my 158 | goal with this. 159 | 160 | There are conflicting opinions about whether selling options is good or bad, 161 | more or less risky, yadda yadda, but generally, the risk profile for covered 162 | calls and naked puts is no worse than the worst case for simply holding an 163 | ETF or stock. I'd argue that selling a naked put is better than 164 | buying SPY with a limit order, because at least if SPY goes to zero you keep 165 | the premium from selling the option. The main downside is that returns are 166 | capped on the upside. Depending on your goals, this may not matter. If you're 167 | like me, then you'd rather have consistent returns and give up a little bit 168 | of potential upside. 169 | 170 | Generally speaking, the point of selling options is not to exceed the returns 171 | of the underlying, but rather to reduce risk. Reducing risk is an important 172 | feature because it, in turn, allows one to increase risk in other ways 173 | (i.e., allocate a higher percentage to stocks or buy riskier assets). 174 | 175 | Whether you use this or not is up to you. I have not one single fuck to give, 176 | whether you use it or not. I am not here to convince you to use it, I merely 177 | want to share knowledge and perhaps help create a little bit of wealth 178 | redistribution. 179 | 180 | 💫 181 | 182 | ## Requirements 183 | 184 | The bot is based on the [ib_async](https://github.com/ib-api-reloaded/ib_async) 185 | library, and uses [IBC](https://github.com/IbcAlpha/IBC) for managing the API 186 | gateway. 187 | 188 | To use the bot, you'll need an Interactive Brokers account with a working 189 | installation of IBC. If you want to modify the bot, you'll need an 190 | installation of Python 3.10 or newer with the 191 | [`uv`](https://docs.astral.sh/uv/) package manager. 192 | 193 | One more thing: to run this on a live account, you'll require enough capital 194 | to purchase at least 100 shares of the stocks or ETFs you choose. For 195 | example, if SPY is trading at $300/share you'd need $30,000 available. You 196 | can search for lower priced alternatives, but these tend to have low volume 197 | on options which may not be appropriate for this strategy. You should 198 | generally avoid low volume ETFs/stocks. If you don't have that kind of 199 | capital, you'll need to keep renting out your time to the capitalists until 200 | you can become a capitalist yourself. That's the way the pyramid scheme we 201 | call capitalism works. 202 | 203 | ## Installation 204 | 205 | _Before running ThetaGang, you should set up an IBKR paper account to test the 206 | code._ 207 | 208 | ```console 209 | pip install thetagang 210 | ``` 211 | 212 | It's recommended you familiarize yourself with 213 | [IBC](https://github.com/IbcAlpha/IBC) so you know how it works. You'll need 214 | to know how to configure the various knows and settings, and make sure things 215 | like API ports are configured correctly. If you don't want to mess around too 216 | much, consider [running ThetaGang with Docker](#running-with-docker). 217 | 218 | ## Usage 219 | 220 | ```console 221 | thetagang -h 222 | ``` 223 | 224 | ## Up and running with Docker 225 | 226 | My preferred way for running ThetaGang is to use a cronjob to execute Docker 227 | commands. I've built a Docker image as part of this project, which you can 228 | use with your installation. There's a [prebuilt Docker image 229 | here](https://hub.docker.com/repository/docker/brndnmtthws/thetagang). 230 | 231 | To run ThetaGang within Docker, you'll need to pass `config.ini` for [IBC 232 | configuration](https://github.com/IbcAlpha/IBC/blob/master/userguide.md) and 233 | [`thetagang.toml`](https://github.com/brndnmtthws/thetagang/blob/main/thetagang.toml) for ThetaGang. There's a sample 234 | [`ibc-config.ini`](https://github.com/brndnmtthws/thetagang/blob/main/ibc-config.ini) included in this repo for your convenience. 235 | 236 | The easiest way to get the config files into the container is by mounting a 237 | volume. 238 | 239 | To get started, grab a copy of `thetagang.toml` and `config.ini`: 240 | 241 | ```console 242 | mkdir ~/thetagang 243 | cd ~/thetagang 244 | curl -Lq https://raw.githubusercontent.com/brndnmtthws/thetagang/main/thetagang.toml -o ./thetagang.toml 245 | curl -Lq https://raw.githubusercontent.com/brndnmtthws/thetagang/main/ibc-config.ini -o ./config.ini 246 | ``` 247 | 248 | Edit `~/thetagang/thetagang.toml` to suit your needs. Pay particular 249 | attention to the symbols and weights. At a minimum, you must change the 250 | username, password, and account number. You may also want to change the 251 | trading move from paper to live when needed. 252 | 253 | Now, to run ThetaGang with Docker: 254 | 255 | ```console 256 | docker run --rm -i --net host \ 257 | -v ~/thetagang:/etc/thetagang \ 258 | brndnmtthws/thetagang:main \ 259 | --config /etc/thetagang/thetagang.toml 260 | ``` 261 | 262 | Lastly, to run ThetaGang as a daily cronjob Monday to Friday at 9am, add 263 | something like this to your crontab (on systems with a cron installation, use 264 | `crontab -e` to edit your crontab): 265 | 266 | ```crontab 267 | 0 9 * * 1-5 docker run --rm -i -v ~/thetagang:/etc/thetagang brndnmtthws/thetagang:main --config /etc/thetagang/thetagang.toml 268 | ``` 269 | 270 | ## Determining which ETFs or stocks to run ThetaGang with 271 | 272 | I leave this as an exercise to the reader, however I will provide a few 273 | recommendations and resources: 274 | 275 | ### Recommendations 276 | 277 | - Stick with high volume ETFs or stocks 278 | - Careful with margin usage, you'll want to calculate the worst case scenario 279 | and provide plenty of cushion for yourself based on your portfolio 280 | 281 | ### Resources 282 | 283 | - For discussions about selling options, check out 284 | [r/thetagang](https://www.reddit.com/r/thetagang/) 285 | - For backtesting portfolios, you can use [this 286 | tool](https://www.portfoliovisualizer.com/backtest-portfolio) and [this 287 | tool](https://www.portfoliovisualizer.com/optimize-portfolio) to get an idea 288 | of drawdown and typical volatility 289 | 290 | ## Development 291 | 292 | Check out the code to your local machine and install the Python dependencies: 293 | 294 | ```console 295 | # Install the pre-commit hooks 296 | uv run pre-commit install 297 | # Run thetagang 298 | uv run thetagang -h 299 | ``` 300 | 301 | You are now ready to make a splash! 🐳 302 | 303 | ## FAQ 304 | 305 | | Error | Cause | Resolution | 306 | |---|---|---| 307 | | Requested market data is not subscribed. | Requisite market data subscriptions have not been set up on IBKR. | [Configure](https://www.interactivebrokers.com/en/software/am3/am/settings/marketdatasubscriptions.htm) your market data subscriptions. The default config that ships with this script uses the `Cboe One Add-On Bundle` and the `US Equity and Options Add-On Streaming Bundle`. **Note**: You _must_ fund your account before IBKR will send data for subscriptions. Without funding you can still subscribe but you will get an error from ibc. | 308 | | No market data during competing live session | Your account is logged in somewhere else, such as the IBKR web portal, the desktop app, or even another instance of this script. | Log out of all sessions and then re-run the script. | 309 | | `ib_async.wrapper ERROR Error 200, reqId 10: The contract description specified for SYMBOL is ambiguous.` | IBKR needs to know which exchange is the primary exchange for a given symbol. | You need to specify the primary exchange for the stock. This is normal for companies, typically. For ETFs it usually isn't required. Specify the `primary_exchange` parameter for the symbol, i.e., `primary_exchange = "NYSE"`. | 310 | | IBKey and MFA-related authentication issues | IBKR requires MFA for the primary account user. | Create a second account with limited permissions using the web portal (remove withdrawal/transfer, client management, IP restriction, etc permissions) and set an IP restriction if possible. When logging into the second account, ignore the MFA nags and do not enable MFA. A [more detailed set of instructions can be found here](https://github.com/Voyz/ibeam/wiki/Runtime-environment#using-a-secondary-account), from a different project. | 311 | 312 | ## Support and sponsorship 313 | 314 | If you get some value out of this, please consider [sponsoring me](https://github.com/sponsors/brndnmtthws) 315 | to continue maintaining this project well into the future. Like 316 | everyone else in the world, I'm just trying to survive. 317 | 318 | If you like what you see but want something different, I am willing 319 | to work on bespoke or custom trading bots for a fee. Reach out 320 | to me directly through my GitHub profile. 321 | 322 | ## Stargazers over time 323 | 324 | [![Stargazers over time](https://starchart.cc/brndnmtthws/thetagang.svg)](https://starchart.cc/brndnmtthws/thetagang) 325 | -------------------------------------------------------------------------------- /data/jxbrowser-linux64-arm-7.29.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brndnmtthws/thetagang/3fd5e5ae877b0b448a302be8cf72143380c23568/data/jxbrowser-linux64-arm-7.29.jar -------------------------------------------------------------------------------- /entrypoint.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -x 5 | 6 | Xvfb :1 -ac -screen 0 1024x768x24 & 7 | export DISPLAY=:1 8 | 9 | # make sure jni path is set 10 | export LD_LIBRARY_PATH="/usr/lib/$(arch)-linux-gnu/jni" 11 | 12 | exec /usr/local/bin/thetagang "$@" 13 | -------------------------------------------------------------------------------- /extract-installer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | set -x 5 | 6 | mkdir -p tws 7 | docker run -i --rm -v `pwd`/tws:/tws debian sh -c " \ 8 | apt-get update \ 9 | && DEBIAN_FRONTEND=noninteractive apt-get install -qy --no-install-recommends unzip curl ca-certificates \ 10 | && echo '5ead02a7d2bd4f3a7b30482c13630e98c04004fbe3c1e2e557589cfa13e2a361 tws-installer.sh' | tee tws-installer.sh.sha256 \ 11 | && curl -qL https://download2.interactivebrokers.com/installers/tws/stable-standalone/tws-stable-standalone-linux-x64.sh -o tws-installer.sh \ 12 | && yes '' | sh tws-installer.sh \ 13 | && rm -f /root/Jts/*/uninstall \ 14 | && cp -r /root/Jts /tws" 15 | 16 | # && sha256sum -c tws-installer.sh.sha256 \ 17 | -------------------------------------------------------------------------------- /ibc-config.ini: -------------------------------------------------------------------------------- 1 | # Note that in the comments in this file, TWS refers to both the Trader 2 | # Workstation and the IB Gateway, unless explicitly stated otherwise. 3 | # 4 | # When referred to below, the default value for a setting is the value 5 | # assumed if either the setting is included but no value is specified, or 6 | # the setting is not included at all. 7 | # 8 | # IBC may also be used to start the FIX CTCI Gateway. All settings 9 | # relating to this have names prefixed with FIX. 10 | # 11 | # The IB API Gateway and the FIX CTCI Gateway share the same code. Which 12 | # gateway actually runs is governed by an option on the initial gateway 13 | # login screen. The FIX setting described under IBC Startup 14 | # Settings below controls this. 15 | 16 | 17 | 18 | # ============================================================================= 19 | # 1. IBC Startup Settings 20 | # ============================================================================= 21 | 22 | 23 | # IBC may be used to start the IB Gateway for the FIX CTCI. This 24 | # setting must be set to 'yes' if you want to run the FIX CTCI gateway. The 25 | # default is 'no'. 26 | 27 | FIX=no 28 | 29 | 30 | 31 | # ============================================================================= 32 | # 2. Authentication Settings 33 | # ============================================================================= 34 | 35 | # TWS and the IB API gateway require a single username and password. 36 | # You may specify the username and password using the following settings: 37 | # 38 | # IbLoginId 39 | # IbPassword 40 | # 41 | # Alternatively, you can specify the username and password in the command 42 | # files used to start TWS or the Gateway, but this is not recommended for 43 | # security reasons. 44 | # 45 | # If you don't specify them, you will be prompted for them in the usual 46 | # login dialog when TWS starts (but whatever you have specified will be 47 | # included in the dialog automatically: for example you may specify the 48 | # username but not the password, and then you will be prompted for the 49 | # password via the login dialog). Note that if you specify either 50 | # the username or the password (or both) in the command file, then 51 | # IbLoginId and IbPassword settings defined in this file are ignored. 52 | # 53 | # 54 | # The FIX CTCI gateway requires one username and password for FIX order 55 | # routing, and optionally a separate username and password for market 56 | # data connections. You may specify the usernames and passwords using 57 | # the following settings: 58 | # 59 | # FIXLoginId 60 | # FIXPassword 61 | # IbLoginId (optional - for market data connections) 62 | # IbPassword (optional - for market data connections) 63 | # 64 | # Alternatively you can specify the FIX username and password in the 65 | # command file used to start the FIX CTCI Gateway, but this is not 66 | # recommended for security reasons. 67 | # 68 | # If you don't specify them, you will be prompted for them in the usual 69 | # login dialog when FIX CTCI gateway starts (but whatever you have 70 | # specified will be included in the dialog automatically: for example 71 | # you may specify the usernames but not the passwords, and then you will 72 | # be prompted for the passwords via the login dialog). Note that if you 73 | # specify either the FIX username or the FIX password (or both) on the 74 | # command line, then FIXLoginId and FIXPassword settings defined in this 75 | # file are ignored; he same applies to the market data username and 76 | # password. 77 | 78 | # IB API Authentication Settings 79 | # ------------------------------ 80 | 81 | # Your TWS username: 82 | 83 | IbLoginId=edemo 84 | 85 | 86 | # Your TWS password: 87 | 88 | IbPassword=demouser 89 | 90 | 91 | # FIX CTCI Authentication Settings 92 | # -------------------------------- 93 | 94 | # Your FIX CTCI username: 95 | 96 | FIXLoginId= 97 | 98 | 99 | # Your FIX CTCI password: 100 | 101 | FIXPassword= 102 | 103 | 104 | # Second Factor Authentication Settings 105 | # ------------------------------------- 106 | 107 | # If you have enabled more than one second factor authentication 108 | # device, TWS presents a list from which you must select the device 109 | # you want to use for this login. You can use this setting to 110 | # instruct IBC to select a particular item in the list on your 111 | # behalf. Note that you must spell this value exactly as it appears 112 | # in the list. If no value is set, you must manually select the 113 | # relevant list entry. 114 | 115 | SecondFactorDevice= 116 | 117 | 118 | # If you use the IBKR Mobile app for second factor authentication, 119 | # and you fail to complete the process before the time limit imposed 120 | # by IBKR, this setting tells IBC whether to automatically restart 121 | # the login sequence, giving you another opportunity to complete 122 | # second factor authentication. 123 | # 124 | # Permitted values are 'yes' and 'no'. 125 | # 126 | # If this setting is not present or has no value, then the value 127 | # of the deprecated ExitAfterSecondFactorAuthenticationTimeout is 128 | # used instead. If this also has no value, then this setting defaults 129 | # to 'no'. 130 | # 131 | # NB: you must be using IBC v3.14.0 or later to use this setting: 132 | # earlier versions ignore it. 133 | 134 | ReloginAfterSecondFactorAuthenticationTimeout=no 135 | 136 | 137 | # This setting is only relevant if 138 | # ReloginAfterSecondFactorAuthenticationTimeout is set to 'yes', 139 | # or if ExitAfterSecondFactorAuthenticationTimeout is set to 'yes'. 140 | # 141 | # It controls how long (in seconds) IBC waits for login to complete 142 | # after the user acknowledges the second factor authentication 143 | # alert at the IBKR Mobile app. If login has not completed after 144 | # this time, IBC terminates. 145 | # The default value is 40. 146 | 147 | SecondFactorAuthenticationExitInterval= 148 | 149 | 150 | # This setting specifies the timeout for second factor authentication 151 | # imposed by IB. The value is in seconds. You should not change this 152 | # setting unless you have reason to believe that IB has changed the 153 | # timeout. The default value is 180. 154 | 155 | SecondFactorAuthenticationTimeout=180 156 | 157 | 158 | # DEPRECATED SETTING 159 | # ------------------ 160 | # 161 | # ExitAfterSecondFactorAuthenticationTimeout - THIS SETTING WILL BE 162 | # REMOVED IN A FUTURE RELEASE. For IBC version 3.14.0 and later, see 163 | # the notes for ReloginAfterSecondFactorAuthenticationTimeout above. 164 | # 165 | # For IBC versions earlier than 3.14.0: If you use the IBKR Mobile 166 | # app for second factor authentication, and you fail to complete the 167 | # process before the time limit imposed by IBKR, you can use this 168 | # setting to tell IBC to exit: arrangements can then be made to 169 | # automatically restart IBC in order to initiate the login sequence 170 | # afresh. Otherwise, manual intervention at TWS's 171 | # Second Factor Authentication dialog is needed to complete the 172 | # login. 173 | # 174 | # Permitted values are 'yes' and 'no'. The default is 'no'. 175 | # 176 | # Note that the scripts provided with the IBC zips for Windows and 177 | # Linux provide options to automatically restart in these 178 | # circumstances, but only if this setting is also set to 'yes'. 179 | 180 | ExitAfterSecondFactorAuthenticationTimeout=no 181 | 182 | 183 | # Trading Mode 184 | # ------------ 185 | # 186 | # This indicates whether the live account or the paper trading 187 | # account corresponding to the supplied credentials is to be used. 188 | # The allowed values are 'live' (the default) and 'paper'. 189 | # 190 | # If this is set to 'live', then the credentials for the live 191 | # account must be supplied. If it is set to 'paper', then either 192 | # the live or the paper-trading credentials may be supplied. 193 | 194 | TradingMode=live 195 | 196 | 197 | # Paper-trading Account Warning 198 | # ----------------------------- 199 | # 200 | # Logging in to a paper-trading account results in TWS displaying 201 | # a dialog asking the user to confirm that they are aware that this 202 | # is not a brokerage account. Until this dialog has been accepted, 203 | # TWS will not allow API connections to succeed. Setting this 204 | # to 'yes' (the default) will cause IBC to automatically 205 | # confirm acceptance. Setting it to 'no' will leave the dialog 206 | # on display, and the user will have to deal with it manually. 207 | 208 | AcceptNonBrokerageAccountWarning=yes 209 | 210 | 211 | # Login Dialog Display Timeout 212 | #----------------------------- 213 | # 214 | # In some circumstances, starting TWS may result in failure to display 215 | # the login dialog. Restarting TWS may help to resolve this situation, 216 | # and IBC does this automatically. 217 | # 218 | # This setting controls how long (in seconds) IBC waits for the login 219 | # dialog to appear before restarting TWS. 220 | # 221 | # Note that in normal circumstances with a reasonably specified 222 | # computer the time to displaying the login dialog is typically less 223 | # than 20 seconds, and frequently much less. However many factors can 224 | # influence this, and it is unwise to set this value too low. 225 | # 226 | # The default value is 60. 227 | 228 | LoginDialogDisplayTimeout=60 229 | 230 | 231 | 232 | # ============================================================================= 233 | # 3. TWS Startup Settings 234 | # ============================================================================= 235 | 236 | # Path to settings store 237 | # ---------------------- 238 | # 239 | # Path to the directory where TWS should store its settings. This is 240 | # normally the folder in which TWS is installed. However you may set 241 | # it to some other location if you wish (for example if you want to 242 | # run multiple instances of TWS with different settings). 243 | # 244 | # It is recommended for clarity that you use an absolute path. The 245 | # effect of using a relative path is undefined. 246 | # 247 | # Linux and macOS users should use the appropriate path syntax. 248 | # 249 | # Note that, for Windows users, you MUST use double separator 250 | # characters to separate the elements of the folder path: for 251 | # example, IbDir=C:\\IBLiveSettings is valid, but 252 | # IbDir=C:\IBLiveSettings is NOT valid and will give unexpected 253 | # results. Linux and macOS users need not use double separators, 254 | # but they are acceptable. 255 | # 256 | # The default is the current working directory when IBC is 257 | # started, unless the TWS_SETTINGS_PATH setting in the relevant 258 | # start script is set. 259 | # 260 | # If both this setting and TWS_SETTINGS_PATH are set, then this 261 | # setting takes priority. Note that if they have different values, 262 | # auto-restart will not work. 263 | # 264 | # NB: this setting is now DEPRECATED. You should use the 265 | # TWS_SETTINGS_PATH setting in the relevant start script. 266 | 267 | IbDir= 268 | 269 | 270 | # Store settings on server 271 | # ------------------------ 272 | # 273 | # If you wish to store a copy of your TWS settings on IB's 274 | # servers as well as locally on your computer, set this to 275 | # 'yes': this enables you to run TWS on different computers 276 | # with the same configuration, market data lines, etc. If set 277 | # to 'no', running TWS on different computers will not share the 278 | # same settings. If no value is specified, TWS will obtain its 279 | # settings from the same place as the last time this user logged 280 | # in (whether manually or using IBC). 281 | 282 | StoreSettingsOnServer= 283 | 284 | 285 | # Minimize TWS on startup 286 | # ----------------------- 287 | # 288 | # Set to 'yes' to minimize TWS when it starts: 289 | 290 | MinimizeMainWindow=yes 291 | 292 | 293 | # Existing Session Detected Action 294 | # -------------------------------- 295 | # 296 | # When a user logs on to an IBKR account for trading purposes by any means, the 297 | # IBKR account server checks to see whether the account is already logged in 298 | # elsewhere. If so, a dialog is displayed to both the users that enables them 299 | # to determine what happens next. The 'ExistingSessionDetectedAction' setting 300 | # instructs TWS how to proceed when it displays this dialog: 301 | # 302 | # * If the new TWS session is set to 'secondary', the existing session continues 303 | # and the new session terminates. Thus a secondary TWS session can never 304 | # override any other session. 305 | # 306 | # * If the existing TWS session is set to 'primary', the existing session 307 | # continues and the new session terminates (even if the new session is also 308 | # set to primary). Thus a primary TWS session can never be overridden by 309 | # any new session). 310 | # 311 | # * If both the existing and the new TWS sessions are set to 'primaryoverride', 312 | # the existing session terminates and the new session proceeds. 313 | # 314 | # * If the existing TWS session is set to 'manual', the user must handle the 315 | # dialog. 316 | # 317 | # The difference between 'primary' and 'primaryoverride' is that a 318 | # 'primaryoverride' session can be overriden over by a new 'primary' session, 319 | # but a 'primary' session cannot be overriden by any other session. 320 | # 321 | # When set to 'primary', if another TWS session is started and manually told to 322 | # end the 'primary' session, the 'primary' session is automatically reconnected. 323 | # 324 | # The default is 'manual'. 325 | 326 | ExistingSessionDetectedAction=primaryoverride 327 | 328 | 329 | # Override TWS API Port Number 330 | # ---------------------------- 331 | # 332 | # If OverrideTwsApiPort is set to an integer, IBC changes the 333 | # 'Socket port' in TWS's API configuration to that number shortly 334 | # after startup. Leaving the setting blank will make no change to 335 | # the current setting. This setting is only intended for use in 336 | # certain specialized situations where the port number needs to 337 | # be set dynamically at run-time: most users will never need it, 338 | # so don't use it unless you know you need it. 339 | 340 | OverrideTwsApiPort=7497 341 | 342 | 343 | # Read-only Login 344 | # --------------- 345 | # 346 | # If ReadOnlyLogin is set to 'yes', and the user is enrolled in IB's 347 | # account security programme, the user will not be asked to perform 348 | # the second factor authentication action, and login to TWS will 349 | # occur automatically in read-only mode: in this mode, placing or 350 | # managing orders is not allowed. 351 | # 352 | # If set to 'no', and the user is enrolled in IB's account security 353 | # programme, the second factor authentication process is handled 354 | # according to the Second Factor Authentication Settings described 355 | # elsewhere in this file. 356 | # 357 | # If the user is not enrolled in IB's account security programme, 358 | # this setting is ignored. The default is 'no'. 359 | 360 | ReadOnlyLogin=no 361 | 362 | 363 | # Read-only API 364 | # ------------- 365 | # 366 | # If ReadOnlyApi is set to 'yes', API programs cannot submit, modify 367 | # or cancel orders. If set to 'no', API programs can do these things. 368 | # If not set, the existing TWS/Gateway configuration is unchanged. 369 | # NB: this setting is really only supplied for the benefit of new TWS 370 | # or Gateway instances that are being automatically installed and 371 | # started without user intervention (eg Docker containers). Where 372 | # a user is involved, they should use the Global Configuration to 373 | # set the relevant checkbox (this only needs to be done once) and 374 | # not provide a value for this setting. 375 | 376 | ReadOnlyApi=no 377 | 378 | 379 | # Market data size for US stocks - lots or shares 380 | # ----------------------------------------------- 381 | # 382 | # Since IB introduced the option of market data for US stocks showing 383 | # bid, ask and last sizes in shares rather than lots, TWS and Gateway 384 | # display a dialog immediately after login notifying the user about 385 | # this and requiring user input before allowing market data to be 386 | # accessed. The user can request that the dialog not be shown again. 387 | # 388 | # It is recommended that the user should handle this dialog manually 389 | # rather than using these settings, which are provided for situations 390 | # where the user interface is not easily accessible, or where user 391 | # settings are not preserved between sessions (eg some Docker images). 392 | # 393 | # - If this setting is set to 'accept', the dialog will be handled 394 | # automatically and the option to not show it again will be 395 | # selected. 396 | # 397 | # Note that in this case, the only way to allow the dialog to be 398 | # displayed again is to manually enable the 'Bid, Ask and Last 399 | # Size Display Update' message in the 'Messages' section of the TWS 400 | # configuration dialog. So you should only use 'Accept' if you are 401 | # sure you really don't want the dialog to be displayed again, or 402 | # you have easy access to the user interface. 403 | # 404 | # - If set to 'defer', the dialog will be handled automatically (so 405 | # that market data will start), but the option to not show it again 406 | # will not be selected, and it will be shown again after the next 407 | # login. 408 | # 409 | # - If set to 'ignore', the user has to deal with the dialog manually. 410 | # 411 | # The default value is 'ignore'. 412 | # 413 | # Note if set to 'accept' or 'defer', TWS also automatically sets 414 | # the API settings checkbox labelled 'Send market data in lots for 415 | # US stocks for dual-mode API clients'. IBC cannot prevent this. 416 | # However you can change this immmediately by setting 417 | # SendMarketDataInLotsForUSstocks (see below) to 'no' . 418 | 419 | AcceptBidAskLastSizeDisplayUpdateNotification= 420 | 421 | 422 | # This setting determines whether the API settings checkbox labelled 423 | # 'Send market data in lots for US stocks for dual-mode API clients' 424 | # is set or cleared. If set to 'yes', the checkbox is set. If set to 425 | # 'no' the checkbox is cleared. If defaulted, the checkbox is 426 | # unchanged. 427 | 428 | SendMarketDataInLotsForUSstocks= 429 | 430 | 431 | 432 | # ============================================================================= 433 | # 4. TWS Auto-Logoff and Auto-Restart 434 | # ============================================================================= 435 | # 436 | # TWS and Gateway insist on being restarted every day. Two alternative 437 | # automatic options are offered: 438 | # 439 | # - Auto-Logoff: at a specified time, TWS shuts down tidily, without 440 | # restarting. 441 | # 442 | # - Auto-Restart: at a specified time, TWS shuts down and then restarts 443 | # without the user having to re-autheticate. 444 | # 445 | # The normal way to configure the time at which this happens is via the Lock 446 | # and Exit section of the Configuration dialog. Once this time has been 447 | # configured in this way, the setting persists until the user changes it again. 448 | # 449 | # However, there are situations where there is no user available to do this 450 | # configuration, or where there is no persistent storage (for example some 451 | # Docker images). In such cases, the auto-restart or auto-logoff time can be 452 | # set whenever IBC starts with the settings below. 453 | # 454 | # The value, if specified, must be a time in HH:MM AM/PM format, for example 455 | # 08:00 AM or 10:00 PM. Note that there must be a single space between the 456 | # two parts of this value; also that midnight is "12:00 AM" and midday is 457 | # "12:00 PM". 458 | # 459 | # If no value is specified for either setting, the currently configured 460 | # settings will apply. If a value is supplied for one setting, the other 461 | # setting is cleared. If values are supplied for both settings, only the 462 | # auto-restart time is set, and the auto-logoff time is cleared. 463 | # 464 | # Note that for a normal TWS/Gateway installation with persistent storage 465 | # (for example on a desktop computer) the value will be persisted as if the 466 | # user had set it via the configuration dialog. 467 | # 468 | # If you choose to auto-restart, you should take note of the considerations 469 | # described at the link below. Note that where this information mentions 470 | # 'manual authentication', restarting IBC will do the job (IBKR does not 471 | # recognise the existence of IBC in its docuemntation). 472 | # 473 | # https://www.interactivebrokers.com/en/software/tws/twsguide.htm#usersguidebook/configuretws/auto_restart_info.htm 474 | # 475 | # If you use the "RESTART" command via the IBC command server, and IBC is 476 | # running any version of the Gateway (or a version of TWS earlier than 1018), 477 | # note that this will set the Auto-Restart time in Gateway/TWS's configuration 478 | # dialog to the time at which the restart actually happens (which may be up to 479 | # a minute after the RESTART command is issued). To prevent future auto- 480 | # restarts at this time, you must make sure you have set AutoLogoffTime or 481 | # AutoRestartTime to your desired value before running IBC. NB: this does not 482 | # apply to TWS from version 1018 onwards. 483 | 484 | AutoLogoffTime= 485 | 486 | AutoRestartTime= 487 | 488 | 489 | # ============================================================================= 490 | # 5. TWS Tidy Closedown Time 491 | # ============================================================================= 492 | # 493 | # Specifies a time at which TWS will close down tidily, with no restart. 494 | # 495 | # There is little reason to use this setting. It is similar to AutoLogoffTime, 496 | # but can include a day-of-the-week, whereas AutoLogoffTime and AutoRestartTime 497 | # apply every day. So for example you could use ClosedownAt in conjunction with 498 | # AutoRestartTime to shut down TWS on Friday evenings after the markets 499 | # close, without it running on Saturday as well. 500 | # 501 | # To tell IBC to tidily close TWS at a specified time every 502 | # day, set this value to , for example: 503 | # ClosedownAt=22:00 504 | # 505 | # To tell IBC to tidily close TWS at a specified day and time 506 | # each week, set this value to , for example: 507 | # ClosedownAt=Friday 22:00 508 | # 509 | # Note that the day of the week must be specified using your 510 | # default locale. Also note that Java will only accept 511 | # characters encoded to ISO 8859-1 (Latin-1). This means that 512 | # if the day name in your default locale uses any non-Latin-1 513 | # characters you need to encode them using Unicode escapes 514 | # (see http://java.sun.com/docs/books/jls/third_edition/html/lexical.html#3.3 515 | # for details). For example, to tidily close TWS at 12:00 on 516 | # Saturday where the default locale is Simplified Chinese, 517 | # use the following: 518 | # #ClosedownAt=\u661F\u671F\u516D 12:00 519 | 520 | ClosedownAt= 521 | 522 | 523 | 524 | # ============================================================================= 525 | # 6. Other TWS Settings 526 | # ============================================================================= 527 | 528 | # Accept Incoming Connection 529 | # -------------------------- 530 | # 531 | # If set to 'accept', IBC automatically accepts incoming 532 | # API connection dialogs. If set to 'reject', IBC 533 | # automatically rejects incoming API connection dialogs. If 534 | # set to 'manual', the user must decide whether to accept or reject 535 | # incoming API connection dialogs. The default is 'manual'. 536 | # NB: it is recommended to set this to 'reject', and to explicitly 537 | # configure which IP addresses can connect to the API in TWS's API 538 | # configuration page, as this is much more secure (in this case, no 539 | # incoming API connection dialogs will occur for those IP addresses). 540 | 541 | AcceptIncomingConnectionAction=accept 542 | 543 | 544 | # Allow Blind Trading 545 | # ------------------- 546 | # 547 | # If you attempt to place an order for a contract for which 548 | # you have no market data subscription, TWS displays a dialog 549 | # to warn you against such blind trading. 550 | # 551 | # yes means the dialog is dismissed as though the user had 552 | # clicked the 'Ok' button: this means that you accept 553 | # the risk and want the order to be submitted. 554 | # 555 | # no means the dialog remains on display and must be 556 | # handled by the user. 557 | 558 | AllowBlindTrading=no 559 | 560 | 561 | # Save Settings on a Schedule 562 | # --------------------------- 563 | # 564 | # You can tell TWS to automatically save its settings on a schedule 565 | # of your choosing. You can specify one or more specific times, 566 | # like this: 567 | # 568 | # SaveTwsSettingsAt=HH:MM [ HH:MM]... 569 | # 570 | # for example: 571 | # SaveTwsSettingsAt=08:00 12:30 17:30 572 | # 573 | # Or you can specify an interval at which settings are to be saved, 574 | # optionally starting at a specific time and continuing until another 575 | # time, like this: 576 | # 577 | #SaveTwsSettingsAt=Every n [{mins | hours}] [hh:mm] [hh:mm] 578 | # 579 | # where the first hh:mm is the start time and the second is the end 580 | # time. If you don't specify the end time, settings are saved regularly 581 | # from the start time till midnight. If you don't specify the start time. 582 | # settings are saved regularly all day, beginning at 00:00. Note that 583 | # settings will always be saved at the end time, even if that is not 584 | # exactly one interval later than the previous time. If neither 'mins' 585 | # nor 'hours' is specified, 'mins' is assumed. Examples: 586 | # 587 | # To save every 30 minutes all day starting at 00:00 588 | #SaveTwsSettingsAt=Every 30 589 | #SaveTwsSettingsAt=Every 30 mins 590 | # 591 | # To save every hour starting at 08:00 and ending at midnight 592 | #SaveTwsSettingsAt=Every 1 hours 08:00 593 | #SaveTwsSettingsAt=Every 1 hours 08:00 00:00 594 | # 595 | # To save every 90 minutes starting at 08:00 up to and including 17:43 596 | #SaveTwsSettingsAt=Every 90 08:00 17:43 597 | 598 | SaveTwsSettingsAt= 599 | 600 | 601 | # Confirm Crypto Currency Orders Automatically 602 | # -------------------------------------------- 603 | # 604 | # When you place an order for a cryptocurrency contract, a dialog is displayed 605 | # asking you to confirm that you want to place the order, and notifying you 606 | # that you are placing an order to trade cryptocurrency with Paxos, a New York 607 | # limited trust company, and not at Interactive Brokers. 608 | # 609 | # transmit means that the order will be placed automatically, and the 610 | # dialog will then be closed 611 | # 612 | # cancel means that the order will not be placed, and the dialog will 613 | # then be closed 614 | # 615 | # manual means that IBC will take no action and the user must deal 616 | # with the dialog 617 | 618 | ConfirmCryptoCurrencyOrders=manual 619 | 620 | 621 | 622 | # ============================================================================= 623 | # 7. Settings Specific to Indian Versions of TWS 624 | # ============================================================================= 625 | 626 | # Indian versions of TWS may display a password expiry 627 | # notification dialog and a NSE Compliance dialog. These can be 628 | # dismissed by setting the following to yes. By default the 629 | # password expiry notice is not dismissed, but the NSE Compliance 630 | # notice is dismissed. 631 | 632 | # Warning: setting DismissPasswordExpiryWarning=yes will mean 633 | # you will not be notified when your password is about to expire. 634 | # You must then take other measures to ensure that your password 635 | # is changed within the expiry period, otherwise IBC will 636 | # not be able to login successfully. 637 | 638 | DismissPasswordExpiryWarning=no 639 | DismissNSEComplianceNotice=yes 640 | 641 | 642 | 643 | # ============================================================================= 644 | # 8. IBC Command Server Settings 645 | # ============================================================================= 646 | 647 | # Do NOT CHANGE THE FOLLOWING SETTINGS unless you 648 | # intend to issue commands to IBC (for example 649 | # using telnet). Note that these settings have nothing to 650 | # do with running programs that use the TWS API. 651 | 652 | # Command Server Port Number 653 | # -------------------------- 654 | # 655 | # The port number that IBC listens on for commands 656 | # such as "STOP". DO NOT set this to the port number 657 | # used for TWS API connections. 658 | # 659 | # The convention is to use 7462 for this port, 660 | # but it must be set to a different value from any other 661 | # IBC instance that might run at the same time. 662 | # 663 | # The default value is 0, which tells IBC not to start 664 | # the command server 665 | 666 | #CommandServerPort=7462 667 | CommandServerPort=0 668 | 669 | 670 | # Permitted Command Sources 671 | # ------------------------- 672 | # 673 | # A comma separated list of IP addresses, or host names, 674 | # which are allowed addresses for sending commands to 675 | # IBC. Commands can always be sent from the 676 | # same host as IBC is running on. 677 | 678 | ControlFrom= 679 | 680 | 681 | # Address for Receiving Commands 682 | # ------------------------------ 683 | # 684 | # Specifies the IP address on which the Command Server 685 | # is to listen. For a multi-homed host, this can be used 686 | # to specify that connection requests are only to be 687 | # accepted on the specified address. The default is to 688 | # accept connection requests on all local addresses. 689 | 690 | BindAddress= 691 | 692 | 693 | # Command Prompt 694 | # -------------- 695 | # 696 | # The specified string is output by the server when 697 | # the connection is first opened and after the completion 698 | # of each command. This can be useful if sending commands 699 | # using an interactive program such as telnet. The default 700 | # is that no prompt is output. 701 | # For example: 702 | # 703 | # CommandPrompt=> 704 | 705 | CommandPrompt= 706 | 707 | 708 | # Suppress Command Server Info Messages 709 | # ------------------------------------- 710 | # 711 | # Some commands can return intermediate information about 712 | # their progress. This setting controls whether such 713 | # information is sent. The default is that such information 714 | # is not sent. 715 | 716 | SuppressInfoMessages=yes 717 | 718 | 719 | 720 | # ============================================================================= 721 | # 9. Diagnostic Settings 722 | # ============================================================================= 723 | # 724 | # IBC can log information about the structure of windows 725 | # displayed by TWS. This information is useful when adding 726 | # new features to IBC or when behaviour is not as expected. 727 | # 728 | # The logged information shows the hierarchical organisation 729 | # of all the components of the window, and includes the 730 | # current values of text boxes and labels. 731 | # 732 | # Note that this structure logging has a small performance 733 | # impact, and depending on the settings can cause the logfile 734 | # size to be significantly increased. It is therefore 735 | # recommended that the LogStructureWhen setting be set to 736 | # 'never' (the default) unless there is a specific reason 737 | # that this information is needed. 738 | 739 | 740 | # Scope of Structure Logging 741 | # -------------------------- 742 | # 743 | # The LogStructureScope setting indicates which windows are 744 | # eligible for structure logging: 745 | # 746 | # - (default value) if set to 'known', only windows that 747 | # IBC recognizes are eligible - these are windows that 748 | # IBC has some interest in monitoring, usually to take 749 | # some action on the user's behalf; 750 | # 751 | # - if set to 'unknown', only windows that IBC does not 752 | # recognize are eligible. Most windows displayed by 753 | # TWS fall into this category; 754 | # 755 | # - if set to 'untitled', only windows that IBC does not 756 | # recognize and that have no title are eligible. These 757 | # are usually message boxes or similar small windows, 758 | # 759 | # - if set to 'all', then every window displayed by TWS 760 | # is eligible. 761 | # 762 | 763 | LogStructureScope=known 764 | 765 | 766 | # When to Log Window Structure 767 | # ---------------------------- 768 | # 769 | # The LogStructureWhen setting specifies the circumstances 770 | # when eligible TWS windows have their structure logged: 771 | # 772 | # - if set to 'open' or 'yes' or 'true', IBC logs the 773 | # structure of an eligible window the first time it 774 | # is encountered; 775 | # 776 | # - if set to 'openclose', the structure is logged every 777 | # time an eligible window is opened or closed; 778 | # 779 | # - if set to 'activate', the structure is logged every 780 | # time an eligible window is made active; 781 | # 782 | # - (default value) if set to 'never' or 'no' or 'false', 783 | # structure information is never logged. 784 | # 785 | 786 | LogStructureWhen=never 787 | 788 | 789 | # DEPRECATED SETTING 790 | # ------------------ 791 | # 792 | # LogComponents - THIS SETTING WILL BE REMOVED IN A FUTURE 793 | # RELEASE 794 | # 795 | # If LogComponents is set to any value, this is equivalent 796 | # to setting LogStructureWhen to that same value and 797 | # LogStructureScope to 'all': the actual values of those 798 | # settings are ignored. The default is that the values 799 | # of LogStructureScope and LogStructureWhen are honoured. 800 | 801 | #LogComponents= 802 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | authors = [{ name = "Brenden Matthews", email = "brenden@brndn.io" }] 3 | dependencies = [ 4 | "click>=8.1.3,<9", 5 | "click-log>=0.4.0,<0.5", 6 | "more-itertools>=9.1,<11.0", 7 | "numpy>=1.26,<3.0", 8 | "python-dateutil>=2.8.1,<3", 9 | "pytimeparse>=1.1.8,<2", 10 | "rich>=13.7.0,<15", 11 | "schema>=0.7.5,<0.8", 12 | "toml>=0.10.2,<0.11", 13 | "ib-async>=1.0.3,<2", 14 | "pydantic>=2.10.2,<3", 15 | "annotated-types>=0.7.0,<0.8", 16 | "polyfactory>=2.18.1,<3", 17 | "exchange-calendars>=4.8", 18 | ] 19 | description = "ThetaGang is an IBKR bot for getting money" 20 | license = "AGPL-3.0-only" 21 | name = "thetagang" 22 | readme = "README.md" 23 | requires-python = ">=3.10,<3.13" 24 | version = "1.14.2" 25 | 26 | [project.urls] 27 | "Bug Tracker" = "https://github.com/brndnmtthws/thetagang/issues" 28 | Documentation = "https://github.com/brndnmtthws/thetagang/blob/master/README.md" 29 | GitHub = "https://github.com/brndnmtthws/thetagang" 30 | Homepage = "https://github.com/brndnmtthws/thetagang" 31 | Repository = "https://github.com/brndnmtthws/thetagang.git" 32 | 33 | [project.scripts] 34 | thetagang = "thetagang.entry:cli" 35 | 36 | [dependency-groups] 37 | dev = [ 38 | "pre-commit>=4.0.1", 39 | "pytest>=8.0.0,<9", 40 | "pytest-mock>=3.14.0,<4", 41 | "pytest-asyncio>=0.23.0,<0.24", 42 | "pytest-watch>=4.2.0,<5", 43 | "ruff>=0.9.1,<0.10.0", 44 | ] 45 | 46 | [tool.lint] 47 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 48 | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or 49 | # McCabe complexity (`C901`) by default. "A" enables type annotation checks. 50 | select = ["ANN", "E4", "E7", "E9", "F", "I", "W"] 51 | # ignore self method type annotations 52 | ignore = ["ANN1"] 53 | 54 | exclude = ["stubs"] 55 | 56 | # Allow fix for all enabled rules (when `--fix`) is provided. 57 | fixable = ["ALL"] 58 | unfixable = [] 59 | 60 | # Same as Black. 61 | line-length = 88 62 | 63 | target-version = "py310" 64 | 65 | [tool.pyright] 66 | defineConstant = { DEBUG = true } 67 | pythonVersion = "3.10" 68 | reportUnknownLambdaType = false 69 | stubPath = "stubs" 70 | venv = ".venv" 71 | venvPath = "." 72 | 73 | [build-system] 74 | build-backend = "hatchling.build" 75 | requires = ["hatchling"] 76 | 77 | [tool.uv] 78 | package = true 79 | -------------------------------------------------------------------------------- /sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brndnmtthws/thetagang/3fd5e5ae877b0b448a302be8cf72143380c23568/sample.png -------------------------------------------------------------------------------- /stubs/click_log/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import click 6 | 7 | __version__ = ... 8 | if not hasattr(click, "get_current_context"): ... 9 | -------------------------------------------------------------------------------- /stubs/click_log/core.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | import logging 6 | 7 | _ctx = ... 8 | LOGGER_KEY = ... 9 | DEFAULT_LEVEL = ... 10 | PY2 = ... 11 | if PY2: 12 | text_type = ... 13 | else: 14 | text_type = ... 15 | 16 | class ColorFormatter(logging.Formatter): 17 | colors = ... 18 | def format(self, record): # -> str: 19 | ... 20 | 21 | class ClickHandler(logging.Handler): 22 | _use_stderr = ... 23 | def emit(self, record): # -> None: 24 | ... 25 | 26 | _default_handler = ... 27 | 28 | def basic_config(logger=...): # -> Logger: 29 | """Set up the default handler (:py:class:`ClickHandler`) and formatter 30 | (:py:class:`ColorFormatter`) on the given logger.""" 31 | ... 32 | -------------------------------------------------------------------------------- /stubs/click_log/options.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | def simple_verbosity_option(logger=..., *names, **kwargs): # -> Callable[..., Any]: 6 | """A decorator that adds a `--verbosity, -v` option to the decorated 7 | command. 8 | 9 | Name can be configured through ``*names``. Keyword arguments are passed to 10 | the underlying ``click.option`` decorator. 11 | """ 12 | ... 13 | -------------------------------------------------------------------------------- /stubs/schema/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | """schema is a library for validating Python data structures, such as those 6 | obtained from config-files, forms, external services or command-line 7 | parsing, converted from JSON/YAML (or something else) to Python data-types.""" 8 | __version__ = ... 9 | __all__ = [ 10 | "Schema", 11 | "And", 12 | "Or", 13 | "Regex", 14 | "Optional", 15 | "Use", 16 | "Forbidden", 17 | "Const", 18 | "Literal", 19 | "SchemaError", 20 | "SchemaWrongKeyError", 21 | "SchemaMissingKeyError", 22 | "SchemaForbiddenKeyError", 23 | "SchemaUnexpectedTypeError", 24 | "SchemaOnlyOneAllowedError", 25 | ] 26 | 27 | class SchemaError(Exception): 28 | """Error during Schema validation.""" 29 | def __init__(self, autos, errors=...) -> None: ... 30 | @property 31 | def code(self): # -> LiteralString: 32 | """ 33 | Removes duplicates values in auto and error list. 34 | parameters. 35 | """ 36 | ... 37 | 38 | class SchemaWrongKeyError(SchemaError): 39 | """Error Should be raised when an unexpected key is detected within the 40 | data set being.""" 41 | 42 | ... 43 | 44 | class SchemaMissingKeyError(SchemaError): 45 | """Error should be raised when a mandatory key is not found within the 46 | data set being validated""" 47 | 48 | ... 49 | 50 | class SchemaOnlyOneAllowedError(SchemaError): 51 | """Error should be raised when an only_one Or key has multiple matching candidates""" 52 | 53 | ... 54 | 55 | class SchemaForbiddenKeyError(SchemaError): 56 | """Error should be raised when a forbidden key is found within the 57 | data set being validated, and its value matches the value that was specified""" 58 | 59 | ... 60 | 61 | class SchemaUnexpectedTypeError(SchemaError): 62 | """Error should be raised when a type mismatch is detected within the 63 | data set being validated.""" 64 | 65 | ... 66 | 67 | class And: 68 | """ 69 | Utility function to combine validation directives in AND Boolean fashion. 70 | """ 71 | def __init__(self, *args, **kw) -> None: ... 72 | def __repr__(self): # -> str: 73 | ... 74 | @property 75 | def args(self): # -> tuple[Any, ...]: 76 | """The provided parameters""" 77 | ... 78 | 79 | def validate(self, data, **kwargs): 80 | """ 81 | Validate data using defined sub schema/expressions ensuring all 82 | values are valid. 83 | :param data: to be validated with sub defined schemas. 84 | :return: returns validated data 85 | """ 86 | ... 87 | 88 | class Or(And): 89 | """Utility function to combine validation directives in a OR Boolean 90 | fashion.""" 91 | def __init__(self, *args, **kwargs) -> None: ... 92 | def reset(self): # -> None: 93 | ... 94 | def validate(self, data, **kwargs): 95 | """ 96 | Validate data using sub defined schema/expressions ensuring at least 97 | one value is valid. 98 | :param data: data to be validated by provided schema. 99 | :return: return validated data if not validation 100 | """ 101 | ... 102 | 103 | class Regex: 104 | """ 105 | Enables schema.py to validate string using regular expressions. 106 | """ 107 | 108 | NAMES = ... 109 | def __init__(self, pattern_str, flags=..., error=...) -> None: ... 110 | def __repr__(self): # -> str: 111 | ... 112 | @property 113 | def pattern_str(self): # -> Any: 114 | """The pattern for the represented regular expression""" 115 | ... 116 | 117 | def validate(self, data, **kwargs): 118 | """ 119 | Validated data using defined regex. 120 | :param data: data to be validated 121 | :return: return validated data. 122 | """ 123 | ... 124 | 125 | class Use: 126 | """ 127 | For more general use cases, you can use the Use class to transform 128 | the data while it is being validate. 129 | """ 130 | def __init__(self, callable_, error=...) -> None: ... 131 | def __repr__(self): # -> str: 132 | ... 133 | def validate(self, data, **kwargs): ... 134 | 135 | class Schema: 136 | """ 137 | Entry point of the library, use this class to instantiate validation 138 | schema for the data that will be validated. 139 | """ 140 | def __init__( 141 | self, 142 | schema, 143 | error=..., 144 | ignore_extra_keys=..., 145 | name=..., 146 | description=..., 147 | as_reference=..., 148 | ) -> None: ... 149 | def __repr__(self): # -> str: 150 | ... 151 | @property 152 | def schema(self): # -> Any: 153 | ... 154 | @property 155 | def description(self): # -> None: 156 | ... 157 | @property 158 | def name(self): # -> None: 159 | ... 160 | @property 161 | def ignore_extra_keys(self): # -> bool: 162 | ... 163 | def is_valid(self, data, **kwargs): # -> bool: 164 | """Return whether the given data has passed all the validations 165 | that were specified in the given schema. 166 | """ 167 | ... 168 | 169 | def validate(self, data, **kwargs): ... 170 | def json_schema(self, schema_id, use_refs=..., **kwargs): 171 | """Generate a draft-07 JSON schema dict representing the Schema. 172 | This method must be called with a schema_id. 173 | 174 | :param schema_id: The value of the $id on the main schema 175 | :param use_refs: Enable reusing object references in the resulting JSON schema. 176 | Schemas with references are harder to read by humans, but are a lot smaller when there 177 | is a lot of reuse 178 | """ 179 | ... 180 | 181 | class Optional(Schema): 182 | """Marker for an optional part of the validation Schema.""" 183 | 184 | _MARKER = ... 185 | def __init__(self, *args, **kwargs) -> None: ... 186 | def __hash__(self) -> int: ... 187 | def __eq__(self, other) -> bool: ... 188 | def reset(self): # -> None: 189 | ... 190 | 191 | class Hook(Schema): 192 | def __init__(self, *args, **kwargs) -> None: ... 193 | 194 | class Forbidden(Hook): 195 | def __init__(self, *args, **kwargs) -> None: ... 196 | 197 | class Literal: 198 | def __init__(self, value, description=...) -> None: ... 199 | def __str__(self) -> str: ... 200 | def __repr__(self): # -> LiteralString: 201 | ... 202 | @property 203 | def description(self): # -> None: 204 | ... 205 | @property 206 | def schema(self): # -> Any: 207 | ... 208 | 209 | class Const(Schema): 210 | def validate(self, data, **kwargs): ... 211 | -------------------------------------------------------------------------------- /stubs/schema/contextlib2/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | from types import TracebackType 6 | from typing import ( 7 | IO, 8 | Any, 9 | AsyncContextManager, 10 | AsyncIterator, 11 | Awaitable, 12 | Callable, 13 | ContextManager, 14 | Iterator, 15 | Optional, 16 | Type, 17 | TypeVar, 18 | overload, 19 | ) 20 | 21 | from typing_extensions import ParamSpec, Protocol 22 | 23 | AbstractContextManager = ContextManager 24 | if True: 25 | AbstractAsyncContextManager = AsyncContextManager 26 | _T = TypeVar("_T") 27 | _T_co = TypeVar("_T_co", covariant=True) 28 | _T_io = TypeVar("_T_io", bound=Optional[IO[str]]) 29 | _F = TypeVar("_F", bound=Callable[..., Any]) 30 | _P = ParamSpec("_P") 31 | _ExitFunc = Callable[ 32 | [Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType]], 33 | bool, 34 | ] 35 | _CM_EF = TypeVar("_CM_EF", ContextManager[Any], _ExitFunc) 36 | 37 | class _GeneratorContextManager(ContextManager[_T_co]): 38 | def __call__(self, func: _F) -> _F: ... 39 | 40 | def contextmanager( 41 | func: Callable[_P, Iterator[_T]], 42 | ) -> Callable[_P, _GeneratorContextManager[_T]]: ... 43 | 44 | if True: 45 | def asynccontextmanager( 46 | func: Callable[_P, AsyncIterator[_T]], 47 | ) -> Callable[_P, AsyncContextManager[_T]]: ... 48 | 49 | class _SupportsClose(Protocol): 50 | def close(self) -> object: ... 51 | 52 | _SupportsCloseT = TypeVar("_SupportsCloseT", bound=_SupportsClose) 53 | 54 | class closing(ContextManager[_SupportsCloseT]): 55 | def __init__(self, thing: _SupportsCloseT) -> None: ... 56 | 57 | if True: 58 | class _SupportsAclose(Protocol): 59 | async def aclose(self) -> object: ... 60 | 61 | _SupportsAcloseT = TypeVar("_SupportsAcloseT", bound=_SupportsAclose) 62 | class aclosing(AsyncContextManager[_SupportsAcloseT]): 63 | def __init__(self, thing: _SupportsAcloseT) -> None: ... 64 | 65 | _AF = TypeVar("_AF", bound=Callable[..., Awaitable[Any]]) 66 | class AsyncContextDecorator: 67 | def __call__(self, func: _AF) -> _AF: ... 68 | 69 | class suppress(ContextManager[None]): 70 | def __init__(self, *exceptions: Type[BaseException]) -> None: ... 71 | def __exit__( 72 | self, 73 | exctype: Optional[Type[BaseException]], 74 | excinst: Optional[BaseException], 75 | exctb: Optional[TracebackType], 76 | ) -> bool: ... 77 | 78 | class redirect_stdout(ContextManager[_T_io]): 79 | def __init__(self, new_target: _T_io) -> None: ... 80 | 81 | class redirect_stderr(ContextManager[_T_io]): 82 | def __init__(self, new_target: _T_io) -> None: ... 83 | 84 | class ContextDecorator: 85 | def __call__(self, func: _F) -> _F: ... 86 | 87 | _U = TypeVar("_U", bound=ExitStack) 88 | 89 | class ExitStack(ContextManager[ExitStack]): 90 | def __init__(self) -> None: ... 91 | def enter_context(self, cm: ContextManager[_T]) -> _T: ... 92 | def push(self, exit: _CM_EF) -> _CM_EF: ... 93 | def callback( 94 | self, callback: Callable[..., Any], *args: Any, **kwds: Any 95 | ) -> Callable[..., Any]: ... 96 | def pop_all(self: _U) -> _U: ... 97 | def close(self) -> None: ... 98 | def __enter__(self: _U) -> _U: ... 99 | def __exit__( 100 | self, 101 | __exc_type: Optional[Type[BaseException]], 102 | __exc_value: Optional[BaseException], 103 | __traceback: Optional[TracebackType], 104 | ) -> bool: ... 105 | 106 | if True: 107 | _S = TypeVar("_S", bound=AsyncExitStack) 108 | _ExitCoroFunc = Callable[ 109 | [ 110 | Optional[Type[BaseException]], 111 | Optional[BaseException], 112 | Optional[TracebackType], 113 | ], 114 | Awaitable[bool], 115 | ] 116 | _CallbackCoroFunc = Callable[..., Awaitable[Any]] 117 | _ACM_EF = TypeVar("_ACM_EF", AsyncContextManager[Any], _ExitCoroFunc) 118 | class AsyncExitStack(AsyncContextManager[AsyncExitStack]): 119 | def __init__(self) -> None: ... 120 | def enter_context(self, cm: ContextManager[_T]) -> _T: ... 121 | def enter_async_context(self, cm: AsyncContextManager[_T]) -> Awaitable[_T]: ... 122 | def push(self, exit: _CM_EF) -> _CM_EF: ... 123 | def push_async_exit(self, exit: _ACM_EF) -> _ACM_EF: ... 124 | def callback( 125 | self, callback: Callable[..., Any], *args: Any, **kwds: Any 126 | ) -> Callable[..., Any]: ... 127 | def push_async_callback( 128 | self, callback: _CallbackCoroFunc, *args: Any, **kwds: Any 129 | ) -> _CallbackCoroFunc: ... 130 | def pop_all(self: _S) -> _S: ... 131 | def aclose(self) -> Awaitable[None]: ... 132 | def __aenter__(self: _S) -> Awaitable[_S]: ... 133 | def __aexit__( 134 | self, 135 | __exc_type: Optional[Type[BaseException]], 136 | __exc_value: Optional[BaseException], 137 | __traceback: Optional[TracebackType], 138 | ) -> Awaitable[bool]: ... 139 | 140 | if True: 141 | class nullcontext(AbstractContextManager[_T]): 142 | enter_result: _T 143 | @overload 144 | def __init__(self: nullcontext[None], enter_result: None = ...) -> None: ... 145 | @overload 146 | def __init__(self: nullcontext[_T], enter_result: _T) -> None: ... 147 | def __enter__(self) -> _T: ... 148 | def __exit__(self, *exctype: Any) -> bool: ... 149 | -------------------------------------------------------------------------------- /thetagang.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brndnmtthws/thetagang/3fd5e5ae877b0b448a302be8cf72143380c23568/thetagang.jpg -------------------------------------------------------------------------------- /thetagang.toml: -------------------------------------------------------------------------------- 1 | # NOTE: It is STRONGLY recommended you read through all notes, config options, 2 | # and documentation before proceeding. Be sure to update the configuration 3 | # values according to your preferences. Additionally, any default values in 4 | # this config do not constitute a recommendation or endorsement, or any provide 5 | # claims abount returns or performance. 6 | # 7 | # Should you decide to use ThetaGang, please experiment with a paper trading 8 | # account before trying on a live account. 9 | [account] 10 | # The account number to operate on 11 | number = "DU1234567" 12 | 13 | # Cancel any existing orders for the symbols configured at startup 14 | cancel_orders = true 15 | 16 | # Maximum amount of margin to use, as a ratio of net liquidation. IB lets 17 | # you use varying amounts of margin, depending on the assets. To use up to 4x 18 | # margin, set this to 4. It's recommended you always leave some additional 19 | # cushion. IB will start to close positions if you go below certain thresholds 20 | # of available margin in your account. 21 | # 22 | # For details on margin usage, see: 23 | # https://www.interactivebrokers.com/en/index.php?f=24176 24 | # 25 | # The default value uses 50% of your available net liquidation value 26 | # (i.e., half of your funds). Set this to 1.0 to use all your funds, 27 | # or 1.5 to use 150% (which may incur margin interest charges). 28 | # 29 | # In other words, ThetaGang's buying power is calculated by taking your NLV and 30 | # multiplying it by margin_usage. 31 | margin_usage = 0.5 32 | 33 | # Market data type (see 34 | # https://interactivebrokers.github.io/tws-api/market_data_type.html) 35 | market_data_type = 1 36 | 37 | [constants] 38 | # Refer to `durationString` at 39 | # https://interactivebrokers.github.io/tws-api/historical_bars.html for details. 40 | # Valid values include "30 D", "60 D", "6 M", "1 Y", etc. 41 | daily_stddev_window = "30 D" 42 | 43 | # Optionally, specify a write threshold either as percent, or sigma, either for 44 | # all contracts, or separately for puts/calls. Refer to the comments for 45 | # `write_threshold` and `write_threshold_sigma` under `symbols` for details. 46 | # 47 | # # Applies to both puts and calls 48 | # write_threshold = 0.01 # 1% 49 | # write_threshold_sigma = 1.0 # 1𝜎 50 | # [puts] # Applies only to puts 51 | # write_threshold = 0.01 # 1% 52 | # write_threshold_sigma = 1.0 # 1𝜎 53 | # [calls] # Applies only to calls 54 | # write_threshold = 0.01 # 1% 55 | # write_threshold_sigma = 1.0 # 1𝜎 56 | 57 | [orders] 58 | # The exchange to route orders to. Can be overridden if desired. This is also 59 | # used for fetching tickers/prices. 60 | exchange = "SMART" 61 | 62 | # Range of time to delay, in seconds, before resubmitting an order with updated 63 | # midpoint price if `symbol..adjust_price_after_delay = true`. 64 | price_update_delay = [30, 60] 65 | 66 | # Set a minimum credit order price, to avoid orders where the credit (or debit) 67 | # is so low that it doesn't even cover broker commission. We default to $0.05, 68 | # but you can set this to 0.0 (or comment it out) if you want to permit any 69 | # order price. This doesn't apply to debit orders. 70 | minimum_credit = 0.05 71 | 72 | [orders.algo] 73 | # By default we use adaptive orders with patient priority which gives reasonable 74 | # results. You can also experiment with TWAP or other options, however the 75 | # available order algos vary depending on what you trade. 76 | # 77 | # Note that the algo orders don't seem to work with combo orders, which are used 78 | # when rolling positions, so AFAIK this has no effect for those orders. It only 79 | # seems to take effect with regular open/close orders. 80 | # Optional IBKR algo strategy. See 81 | # https://interactivebrokers.github.io/tws-api/ibalgos.html for option details. 82 | strategy = "Adaptive" 83 | 84 | # For `algoParams`, the TagValue parameter has 2 values, so any values with 85 | # anything other than 2 parameters are invalid. Pass an empty list to use the 86 | # defaults (i.e., params = []). 87 | params = [ 88 | [ 89 | "adaptivePriority", 90 | "Patient", 91 | ], 92 | ] 93 | 94 | [option_chains] 95 | # The option chains are lazy loaded, and before you can determine the greeks 96 | # (delta) or prices, you need to scan the chains. The settings here tell 97 | # thetagang how many contracts to load. Don't make these values too high, as 98 | # they will cause the chain scanning process to take too long, and it may fail. 99 | # 100 | # If you have issues where thetagang can't find suitable contracts, try 101 | # increasing these values slightly. 102 | # 103 | # Number of expirations to load from option chains 104 | expirations = 4 105 | 106 | # Number of strikes to load from option chains 107 | strikes = 15 108 | 109 | [roll_when] 110 | # Roll when P&L reaches 90% 111 | pnl = 0.9 112 | # Or, roll options when there are <= 15 days to expiry and P&L is at least 113 | # min_pnl (min_pnl defaults to 0) 114 | # 115 | # NOTE: For cases where an option ends up deep ITM, notably when selling 116 | # covered calls, it's possible that the P&L would be significantly negative, 117 | # i.e., -100%. If you want to roll anyway in these situations, set min_pnl to a 118 | # negative value such as -1 (for -100%). 119 | dte = 15 120 | min_pnl = 0.0 121 | 122 | # Optional: Don't roll contracts when the current DTE is greater than this 123 | # number of days. This helps avoid cases where you end up rolling out to LEAPs. 124 | # 125 | # max_dte = 180 126 | 127 | # Optional: Create a closing order when the P&L reaches this threshold. This 128 | # overrides the other parameters, i.e., it ignores DTE and everything else. 129 | # If not specified, it has no effect. This can handle the case where you have 130 | # long-dated options that have slowly become worthless and you just want to get 131 | # them out of your portfolio. This only applies to short contract positions, 132 | # long positions are ignored. 133 | # 134 | # close_at_pnl = 0.99 135 | 136 | # Optional: if we try to roll the position and it fails, just close it (but only 137 | # if the position currently has positive P&L). This can happen if the underlying 138 | # moves too much and there are no suitable contracts. See 139 | # https://github.com/brndnmtthws/thetagang/issues/347 and 140 | # https://github.com/brndnmtthws/thetagang/issues/439 for a discussion on this. 141 | # 142 | # If `roll_when.max_dte` is set, this will only close the position if the DTE is 143 | # <= `roll_when.max_dte`. 144 | # 145 | # This can also be set per-symbol, with 146 | # `symbols..close_if_unable_to_roll`. 147 | close_if_unable_to_roll = false 148 | 149 | [roll_when.calls] 150 | # Roll calls to the next expiration even if they're in the money. Defaults to 151 | # true if not specified. 152 | itm = true 153 | 154 | # Always roll calls (short-circuit) when they're in the money, regardless of 155 | # the P&L. This allows you to avoid assignment risk by eating the cost of 156 | # rolling the calls. This is useful if you want to avoid assignment risk 157 | # either because you don't own the underlying (i.e., with spreads) or because 158 | # you don't want to realize a gain/loss yet. Note that if it's rolling early 159 | # (i.e., before `roll_when.dte`) it may only roll a subset of the contracts 160 | # based on the `target.maximum_new_contracts_percent` value. 161 | # 162 | # This can also be set for puts with `roll_when.puts.always_when_itm`. This 163 | # option, when enabled, takes precedence over `roll_when.pnl` and 164 | # `roll_when.min_pnl`. 165 | always_when_itm = false 166 | 167 | # Only roll when there's a suitable contract available that will result in a 168 | # credit. Enabling this may result in the target delta value being ignored in 169 | # circumstances where we can't find a contract that will result in both a 170 | # credit _and_ satisfying the target delta (i.e., having a credit takes 171 | # precedence). 172 | credit_only = false 173 | 174 | # If set to false, calls will not be rolled if there are any number of calls in 175 | # excess of the target call quantity. A truthy value means thetagang will keep 176 | # rolling calls regardless of the total quantity. 177 | has_excess = true 178 | 179 | # We can optionally maintain the high water mark for covered calls by never 180 | # rolling CCs down, only out. We do this because it's not uncommon for a sharp 181 | # bounce up after a stock/index falls enough to make the contract eligible for 182 | # rolling. If we roll the CC down and out, we might be capping our gains when it 183 | # eventually does bounce back. The trade off here is that you end up with 184 | # slightly less downside protection. 185 | # 186 | # This only applies to rolling calls, and is essentially equivalent to setting 187 | # the minimum strike of the next contract to the previous strike and only 188 | # allowing the value to ratchet upwards. 189 | # 190 | # While this is off by default, it would be sensible to turn it on if you're 191 | # trying to capture upside, rather than protect downside. 192 | # 193 | # This value can also be set per-symbol, with 194 | # `symbols..calls.maintain_high_water_mark` (see `symbols.QQQ` example 195 | # below). 196 | maintain_high_water_mark = false 197 | 198 | [roll_when.puts] 199 | # Roll puts if they're in the money. Defaults to false if not specified. 200 | itm = false 201 | 202 | # See comments above for `roll_when.calls.always_when_itm` for details on this 203 | # option (which behaves the same for puts as with calls). 204 | always_when_itm = false 205 | 206 | # Only roll when there's a suitable contract available that will result in a 207 | # credit. Enabling this may result in the target delta value being ignored in 208 | # circumstances where we can't find a contract that will result in both a 209 | # credit _and_ satisfying the target delta (i.e., having a credit takes 210 | # precedence). 211 | credit_only = false 212 | 213 | # If set to false, puts will not be rolled if there are any number of puts in 214 | # excess of the target put quantity. A truthy value means thetagang will keep 215 | # rolling puts regardless of the total quantity. 216 | has_excess = true 217 | 218 | [write_when] 219 | # By default, we ignore long option positions when calculating whether we can 220 | # write new contracts. This, however, limits the ability to utilize spreads. For 221 | # example, we might want to buy some long-dated puts when they're cheap, and 222 | # then we can sell additional short-dated puts (i.e., a calendar spread). 223 | # ThetaGang won't open a calendar spread for you by purchasing longer-dated 224 | # contracts, but if you were to buy some LEAPs yourself, ThetaGang could take 225 | # advantage of those positions when calculating whether to write additional 226 | # contracts. 227 | # 228 | # If you set this value to true, ThetaGang will calculate the net positions by 229 | # including the long contracts in its calculations. It does this by greedily 230 | # matching short positions with long positions such that they cancel each other 231 | # out. ThetaGang will only match on those positions where the long leg has a 232 | # greater DTE, and the strike is closer to the money (i.e., the strike for puts 233 | # must be >=, and the strike for calls must be <=). 234 | # 235 | # IMPORTANT NOTE: ThetaGang doesn't manage long contracts at all, so you 236 | # introduce the risk of having excess short positions when the long contracts 237 | # eventually expire. For the time being, you need to manage the long legs 238 | # yourself (by either closing the short side when the long positions expire, or 239 | # rolling the long positions as they approach expiration). You can avoid rolling 240 | # excess positions with `roll_when.calls/puts.has_excess = false`. 241 | calculate_net_contracts = false 242 | 243 | [write_when.calls] 244 | # Optionally, only write calls when the underlying is green 245 | green = true 246 | red = false 247 | 248 | # With covered calls, we can cap the number of calls to write by this factor. At 249 | # 1.0, we write covered calls on 100% of our positions. At 0.5, we'd only write 250 | # on 50% of our positions. This value must be between 1 and 0 inclusive. 251 | # 252 | # This can also be set per-symbol with 253 | # `symbols..calls.cap_factor`. 254 | cap_factor = 1.0 255 | 256 | # We may want to leave some percentage of our underlying stock perpetually 257 | # uncovered so we don't miss out on big upside movements. For example, if our 258 | # target number of shares for a given symbol is 1,000, we may want to ensure 259 | # 500 shares are _always_ uncovered. This is a bit different from `cap_factor`, 260 | # as it applies to the _target_ number of shares, as opposed to the current 261 | # positions. A value of 0.5 (50%) means that we always leave 50% (half) of the 262 | # target shares uncovered. A bigger number (up to 1.0, 100%) is more bullish, 263 | # and a smaller number (down to 0.0, 0%) is more bearish (i.e., cover all 264 | # positions with calls). This essentially sets a floor on the number of shares 265 | # we try to hold on to in order to avoid missing out on potential upside. 266 | # 267 | # This can also be set per-symbol with 268 | # `symbols..calls.cap_target_floor`. 269 | cap_target_floor = 0.0 270 | 271 | # If set to true, calls are only written on the underlying when the underlying 272 | # has an excess of shares. This is useful for covered calls, where you only 273 | # want to write calls when you have more shares than you want to hold 274 | # long-term. It also provides a way to rebalance your portfolio by writing 275 | # calls and taking profits. 276 | # 277 | # This may also be set per-symbol with `symbols..calls.excess_only`, 278 | # which takes precedence. 279 | # 280 | # When this is set to true, the `cap_factor` and `cap_target_floor` values are 281 | # ignored. 282 | excess_only = false 283 | 284 | [write_when.puts] 285 | # Optionally, only write puts when the underlying is red 286 | green = false 287 | red = true 288 | 289 | [target] 290 | # Target 45 or more days to expiry 291 | # This value can also be set per-symbol, with `symbols..dte` 292 | # (see `symbols.QQQ` example below). 293 | dte = 45 294 | 295 | # Optionally, we can prevent contracts from being rolled insanely far out with 296 | # `target.max_dte`. Comment this setting out if you don't care about it. You can 297 | # also specify this per-symbol, with `symbols..max_dte`, or for VIX if 298 | # you use the VIX call hedging. 299 | max_dte = 180 300 | 301 | # Target delta of 0.3 or less. Defaults to 0.3 if not specified. 302 | delta = 0.3 303 | 304 | # When writing new contracts (either covered calls or naked puts), or rolling 305 | # before `roll_when.dte` is reached, never write more than this amount of 306 | # contracts at once. This can be useful to avoid bunching by spreading contract 307 | # placement out over time (and possibly expirations) in order to protect 308 | # yourself from large swings. This value does not affect rolling existing 309 | # contracts to the next expiration. This value is expressed as a percentage of 310 | # buying power based on the market price of the underlying ticker, as a range 311 | # from [0.0-1.0]. 312 | # 313 | # Once the `roll_when.dte` date is reached, all the remaining positions are 314 | # rolled regardless of the current position quantity. 315 | # 316 | # Defaults to 5% of buying power. Set this to 1.0 to effectively disable the 317 | # limit. 318 | maximum_new_contracts_percent = 0.05 319 | 320 | # Minimum amount of open interest for a contract to qualify 321 | minimum_open_interest = 10 322 | 323 | # Optional: specify delta separately for puts/calls. Takes precedent over 324 | # target.delta. 325 | # 326 | # [target.puts] 327 | # delta = 0.5 328 | # [target.calls] 329 | # delta = 0.3 330 | [symbols] 331 | # NOTE: Please change these symbols and weights according to your preferences. 332 | # These are provided only as an example for the purpose of configuration. These 333 | # values were chosen as sane values should someone decide to run this code 334 | # without changes, however it is in no way a recommendation or endorsement. 335 | # 336 | # You can specify the weight either as a percentage of your buying power (which 337 | # is calculated as your NLV * account.margin_usage), or in terms of parts. Parts 338 | # are summed from all symbols, then the weight is calculated by dividing the 339 | # parts by the total parts. 340 | # 341 | # You should try to choose ETFs or stocks that: 342 | # 343 | # 1) Have sufficient trading volume for the underlying 344 | # 2) Have standard options contracts (100 shares per contract) 345 | # 3) Have options with sufficient open interest and trading volume 346 | # 347 | # The target delta may also be specified per-symbol, and takes precedence over 348 | # `target.delta` or `target.puts/calls.delta`. You can specify a value for the 349 | # symbol, or override individually for puts/calls. 350 | [symbols.SPY] 351 | weight = 0.4 352 | 353 | # OR: specify in terms of parts. Must use either weight or parts, but cannot mix 354 | # both. 355 | # parts = 40 356 | 357 | # Sometimes, particularly for stocks/ETFs with limited liquidity, the spreads 358 | # are too wide to get an order filled at the midpoint on the first attempt. For 359 | # those, you can try setting this to `true`, and thetagang will wait a random 360 | # amount of time, then resubmit orders that haven't filled (but only for the 361 | # symbols with this set to true). The amount of time we'll wait is chosen 362 | # randomly from the range defined by `orders.price_update_delay`. 363 | adjust_price_after_delay = false 364 | 365 | # You can include a symbol, but instruct ThetaGang not to place any trades for 366 | # that symbol by setting `no_trading = true`. This allows you to include 367 | # placeholders (such as for tracking purposes, or because you want to trade 368 | # manually) without actually trading them. 369 | # 370 | # no_trading = true 371 | 372 | [symbols.QQQ] 373 | weight = 0.3 374 | # The target DTE may also be specified per-symbol, and takes precedence over 375 | # `target.dte` 376 | dte = 60 377 | # Optional: specify the maximum contract DTE for this symbol (i.e., don't open 378 | # or roll positions past this DTE). This will override the `target.max_dte` 379 | # value. 380 | # max_dte = 45 381 | 382 | # Optional: If we try to roll the position and it fails, just close it (but 383 | # only if it's profitable). 384 | close_if_unable_to_roll = true 385 | 386 | # parts = 30 387 | [symbols.QQQ.puts] 388 | # Override delta just for QQQ puts 389 | delta = 0.5 390 | # Also, optionally specify a strike limit, for either puts or calls. 391 | # Interpreted as an upper bound for puts, and a lower bound for calls. 392 | strike_limit = 1000.0 # never write a put with a strike above $1000 393 | 394 | # Optionally, if we only write new contracts when the underlying is green or 395 | # red (`write_when.*.green=true` && `write_when.*.red=false` or vice versa), 396 | # specify a minimum threshold as an absolute value daily percentage change 397 | # (in this example, use 1% for puts only, but could also be specified as 398 | # `symbols.QQQ.write_threshold`). This can also be specified under 399 | # `constants.write_threshold` and `constants.puts/calls.write_threshold` to 400 | # apply to all symbols, either for both puts or calls, or individually. 401 | # 402 | # In this example, we'd only write puts on QQQ when the daily change is -1% or 403 | # greater (provided that we also set `write_when.puts.red=true`). 404 | write_threshold = 0.01 # 1%, absolute value 405 | 406 | # Alternatively, you can express write threshold value in terms of sigma with 407 | # `write_threshold_sigma`. A value of 2.0 means that we'll write new contracts 408 | # when the daily change is twice the standard deviation of the log returns. 409 | # 410 | # If write_threshold_sigma is specified, it supersedes (overrides) the value of 411 | # write_threshold. 412 | # 413 | # The daily standard deviation of log returns is calculated based on the past 414 | # 30 days of data by default, but you can adjust this by altering the value of 415 | # `constants.daily_stddev_window`. 416 | # 417 | # Note that we'll need to retrieve this historical data for every execution to 418 | # calculate this value, so you should be sure there's 1) sufficient history 419 | # available and 2) the data can be retrieved sufficiently fast. 420 | # 421 | # write_threshold_sigma = 1.0 # 1x the standard devation of log returns for the daily stddev window 422 | 423 | # the values for `write_when.*.green` and `write_when.*.red` can also be set per-symbol 424 | [symbols.QQQ.puts.write_when] 425 | green = false 426 | red = true 427 | 428 | [symbols.QQQ.calls] 429 | strike_limit = 100.0 # never write a call with a strike below $100 430 | 431 | maintain_high_water_mark = true # maintain the high water mark when rolling calls 432 | 433 | # These values can (optionally) be set on a per-symbol basis, in addition to 434 | # `write_when.calls.cap_factor` and `write_when.calls.cap_target_floor. 435 | cap_factor = 1.0 436 | cap_target_floor = 0.0 437 | 438 | # Optionally, only write calls when the underlying has an excess of shares 439 | # when set to `true`. This overrides the `write_when.calls.excess_only` 440 | # value. 441 | excess_only = false 442 | 443 | [symbols.TLT] 444 | weight = 0.2 445 | # parts = 20 446 | # Override delta for this particular symbol, for both puts and calls. 447 | delta = 0.4 448 | 449 | [symbols.ABNB] 450 | # For symbols that require an exchange, which is typically any company stock, 451 | # you must specify the primary exchange. 452 | primary_exchange = "NASDAQ" 453 | weight = 0.05 454 | 455 | # parts = 5 456 | # Sometimes you may need to wrap the symbol in quotes. 457 | [symbols."BRK B"] 458 | # For symbols that require an exchange, which is typically any company stock, 459 | # you must specify the primary exchange. 460 | primary_exchange = "NYSE" 461 | weight = 0.05 462 | 463 | # parts = 5 464 | [ib_async] 465 | logfile = '/etc/thetagang/ib_async.log' 466 | 467 | # Typically the amount of time needed when waiting on data from the IBKR API. 468 | # Sometimes it can take a while to retrieve data, and it's lazy-loaded by the 469 | # API, so getting this number right is largely a matter of guesswork. 470 | # Increasing the number would reduce the throughput since we have to wait maximum X seconds 471 | # to make sure all the required data fields are ready. 472 | # You can speed up the thetagang by reducing this number, but you may miss the best opportunities. 473 | # The rule of thumb to choose the best value is assuming the maximum run time of thetagang 474 | # will be around 6 (call,puts,roll calls, roll puts, ...) * api_response_wait_time * number_of_symbols you have in the configuration. 475 | api_response_wait_time = 60 476 | 477 | [ibc] 478 | # IBC configuration parameters. See 479 | # https://ib-insync.readthedocs.io/api.html#ibc for details. 480 | gateway = true 481 | ibcPath = '/opt/ibc' 482 | tradingMode = 'paper' 483 | # Set this to true if you want to raise an exception on request errors. Under 484 | # normal operation this should be false because we often try to make "invalid" 485 | # requests when scanning option chains for example. 486 | RaiseRequestErrors = false 487 | password = 'demo' 488 | userid = 'demo' 489 | # Change this to point to your config.ini for IBC 490 | ibcIni = '/etc/thetagang/config.ini' 491 | # Change or unset this to use something other than the Docker bundled OpenJDK. 492 | javaPath = '/opt/java/openjdk/bin' 493 | 494 | # twsPath = '' 495 | # twsSettingsPath = '' 496 | # fixuserid = '' 497 | # fixpassword = '' 498 | [watchdog] 499 | # Watchdog configuration params. See 500 | # https://ib-insync.readthedocs.io/api.html#watchdog for details. 501 | appStartupTime = 30 502 | appTimeout = 20 503 | clientId = 1 504 | connectTimeout = 2 505 | host = '127.0.0.1' 506 | port = 7497 507 | probeTimeout = 4 508 | readonly = false 509 | retryDelay = 2 510 | 511 | [watchdog.probeContract] 512 | currency = 'USD' 513 | exchange = 'SMART' 514 | secType = 'STK' 515 | symbol = 'SPY' 516 | 517 | # Optional VIX call hedging, based on the methodology described by the Cboe VIX 518 | # Tail Hedge Index, described here: 519 | # https://www.cboe.com/us/indices/dashboard/vxth/ 520 | [vix_call_hedge] 521 | enabled = false 522 | 523 | # Target delta for calls that are purchased 524 | delta = 0.30 525 | 526 | # Target DTE for new positions 527 | target_dte = 30 528 | 529 | # Optionally specify a maximum DTE for VIX positions, which takes precedence 530 | # over `target.max_dte`. 531 | # max_dte = 180 532 | 533 | # If the current spot VIX exceeds this value, long VIX call positions will be 534 | # closed. Comment out to disable. 535 | close_hedges_when_vix_exceeds = 50.0 536 | 537 | # Don't count any VIX positions where the DTE is <= this value. Increase this 538 | # value to create a call ladder. For example, if you set this to 5, thetagang 539 | # will ignore current VIX positions starting 5 days before expiry, and 540 | # potentially adding more. This allows you to create a simple call ladder. 541 | ignore_dte = 0 542 | 543 | # The allocations are specified as an ordered list of allocation weights 544 | # according to an upper/lower bound on VIXMO (the 30 day VIX). Default values 545 | # are the same as those described in the VXTH methodology. These are evaluated 546 | # in order, and the weight from the first allocation that matches will be 547 | # applied. The lower bound is inclusive, and the upper bound is exclusive 548 | # (.i.e., the code checks that lower <= VIXMO < upper). The upper/lower bounds 549 | # are only checked if they're present. 550 | # 551 | # The allocation weights are multiplied by the account's net liquidation value, 552 | # and that amount is allocated to purchasing VIX calls. 553 | [[vix_call_hedge.allocation]] 554 | upper_bound = 15.0 555 | weight = 0.00 556 | 557 | [[vix_call_hedge.allocation]] 558 | lower_bound = 15.0 559 | upper_bound = 30.0 560 | weight = 0.01 561 | 562 | [[vix_call_hedge.allocation]] 563 | lower_bound = 30.0 564 | upper_bound = 50.0 565 | weight = 0.005 566 | 567 | [[vix_call_hedge.allocation]] 568 | lower_bound = 50.0 569 | weight = 0.00 570 | 571 | [cash_management] 572 | # Cash management gives us a way to earn a little extra yield from excess cash 573 | # sitting in your account. When the cash balance exceeds a threshold, we buy 574 | # the cash fund, and when the cash balance drops below the cash threshold we 575 | # sell the cash fund to get back to the target cash balance. 576 | # 577 | # Enables cash management 578 | enabled = false 579 | 580 | # The fund to purchase with your cash. Example of cash funds are SGOV or SHV, 581 | # which are short-term treasury ETFs with reasonable fees. Be sure to check the 582 | # expense ratio before jumping in on ETFs that appear juicier. 583 | cash_fund = "SGOV" 584 | 585 | # You don't usually need to specify the primary exchange for ETFs, but if you 586 | # do, you can do so with this: 587 | # primary_exchange = "NYSE" 588 | 589 | # The cash balance to target. This is used as a lower bound, so with a value of 590 | # 0, we try not to let the cash balance go below zero. Simple enough. 591 | target_cash_balance = 0 592 | 593 | # We don't want to transact too frequently because of commissions, so the buy 594 | # threshold is the amount above target_cash_balance we need to reach before 595 | # placing a buy order. 596 | buy_threshold = 10000 597 | 598 | # The sell threshold is the amount below target_cash_balance where we'll place 599 | # a sell order to shore up cash. 600 | sell_threshold = 10000 601 | 602 | [cash_management.orders] 603 | # The exchange to route orders to. Can be overridden if desired. This is also 604 | # used for fetching tickers/prices. 605 | exchange = "SMART" 606 | 607 | [cash_management.orders.algo] 608 | # By default, use a VWAP order for cash trades. You can comment out the whole 609 | # `cash_management.orders` section to use the same value as `orders` and 610 | # `orders.algo`. 611 | params = [ 612 | # Optionally, uncomment the following line to be opportunistic by avoiding 613 | # taking liquidity. This gives us somewhat better pricing and lower 614 | # commissions, at the expense of possibly not getting the order filled in 615 | # the day. 616 | # ["noTakeLiq", "1"], 617 | ] 618 | strategy = "Vwap" 619 | 620 | [exchange_hours] 621 | # ThetaGang can check whether the market is open before running. This is useful 622 | # to avoid placing orders when the market is closed. We can also (for example) 623 | # avoid running too early (i.e., immediately after market open) to avoid the 624 | # initial volatility, or too late (i.e., immediately before market close) to 625 | # avoid the closing volatility. 626 | 627 | # The exchange to check for market hours. XNYS is the NYSE, but you can use any 628 | # of the ISO exchange codes from 629 | # https://github.com/gerrymanoim/exchange_calendars?tab=readme-ov-file#Calendars. 630 | # 631 | # For example, you'd use "XTKS" for the Tokyo Stock Exchange, "XLON" for the 632 | # London Stock Exchange, or "XHKG" for the Hong Kong Stock Exchange. 633 | exchange = "XNYS" 634 | 635 | # If the market is closed, we can either exit immediately, wait until it's 636 | # time to begin, or continue as normal. Specify "exit" to exit, "wait" to wait, 637 | # and "continue" to continue. 638 | # 639 | # Setting this to "continue" is equivalent to disabling this feature. 640 | action_when_closed = "exit" 641 | 642 | # The maximum amount of time, in seconds, to wait for the market to open if you 643 | # set `exchange_hours.action_when_closed = "wait"`. For example, a value of 3600 644 | # means ThetaGang will only wait for the market to open if it's within 1 hour of 645 | # the current time. 646 | max_wait_until_open = 3600 647 | 648 | # The amount of time, in seconds, after the market open, to wait before running 649 | # ThetaGang. For example, if this is set to 1800, ThetaGang will consider the 650 | # market closed until 30 minutes after the open. 651 | delay_after_open = 1800 652 | 653 | # The amount of time, in seconds, before the market close, to stop running 654 | # ThetaGang. For example, if set to 1800, ThetaGang will consider the market 655 | # closed 30 minutes prior to the actual close. 656 | delay_before_close = 1800 657 | -------------------------------------------------------------------------------- /thetagang/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brndnmtthws/thetagang/3fd5e5ae877b0b448a302be8cf72143380c23568/thetagang/__init__.py -------------------------------------------------------------------------------- /thetagang/config.py: -------------------------------------------------------------------------------- 1 | import math 2 | from enum import Enum 3 | from typing import Any, Dict, List, Literal, Optional, Tuple 4 | 5 | from pydantic import BaseModel, Field, model_validator 6 | from rich import box 7 | from rich.console import Console, Group 8 | from rich.panel import Panel 9 | from rich.table import Table 10 | from rich.tree import Tree 11 | from typing_extensions import Self 12 | 13 | from thetagang.fmt import dfmt, ffmt, pfmt 14 | 15 | error_console = Console(stderr=True, style="bold red") 16 | 17 | 18 | class DisplayMixin: 19 | def add_to_table(self, table: Table, section: str = "") -> None: 20 | raise NotImplementedError 21 | 22 | 23 | class AccountConfig(BaseModel, DisplayMixin): 24 | number: str = Field(...) 25 | margin_usage: float = Field(..., ge=0.0) 26 | cancel_orders: bool = Field(default=True) 27 | market_data_type: int = Field(default=1, ge=1, le=4) 28 | 29 | def add_to_table(self, table: Table, section: str = "") -> None: 30 | table.add_row("[spring_green1]Account details") 31 | table.add_row("", "Account number", "=", self.number) 32 | table.add_row("", "Cancel existing orders", "=", f"{self.cancel_orders}") 33 | table.add_row( 34 | "", 35 | "Margin usage", 36 | "=", 37 | f"{self.margin_usage} ({pfmt(self.margin_usage, 0)})", 38 | ) 39 | table.add_row("", "Market data type", "=", f"{self.market_data_type}") 40 | 41 | 42 | class ConstantsConfig(BaseModel, DisplayMixin): 43 | class WriteThreshold(BaseModel): 44 | write_threshold: Optional[float] = Field(default=None, ge=0.0, le=1.0) 45 | write_threshold_sigma: Optional[float] = Field(default=None, ge=0.0) 46 | 47 | write_threshold: Optional[float] = Field(default=None, ge=0.0, le=1.0) 48 | write_threshold_sigma: Optional[float] = Field(default=None, ge=0.0) 49 | daily_stddev_window: str = Field(default="30 D") 50 | calls: Optional["ConstantsConfig.WriteThreshold"] = None 51 | puts: Optional["ConstantsConfig.WriteThreshold"] = None 52 | 53 | def add_to_table(self, table: Table, section: str = "") -> None: 54 | table.add_section() 55 | table.add_row("[spring_green1]Constants") 56 | table.add_row("", "Daily stddev window", "=", self.daily_stddev_window) 57 | 58 | c_write_thresh = ( 59 | f"{ffmt(self.calls.write_threshold_sigma)}σ" 60 | if self.calls and self.calls.write_threshold_sigma 61 | else pfmt(self.calls.write_threshold if self.calls else None) 62 | ) 63 | p_write_thresh = ( 64 | f"{ffmt(self.puts.write_threshold_sigma)}σ" 65 | if self.puts and self.puts.write_threshold_sigma 66 | else pfmt(self.puts.write_threshold if self.puts else None) 67 | ) 68 | 69 | table.add_row("", "Write threshold for puts", "=", p_write_thresh) 70 | table.add_row("", "Write threshold for calls", "=", c_write_thresh) 71 | 72 | 73 | class OptionChainsConfig(BaseModel): 74 | expirations: int = Field(..., ge=1) 75 | strikes: int = Field(..., ge=1) 76 | 77 | 78 | class AlgoSettingsConfig(BaseModel): 79 | strategy: str = Field("Adaptive") 80 | params: List[List[str]] = Field( 81 | default_factory=lambda: [["adaptivePriority", "Patient"]], 82 | min_length=0, 83 | max_length=1, 84 | ) 85 | 86 | 87 | class OrdersConfig(BaseModel, DisplayMixin): 88 | minimum_credit: float = Field(default=0.0, ge=0.0) 89 | exchange: str = Field(default="SMART") 90 | algo: AlgoSettingsConfig = Field( 91 | default=AlgoSettingsConfig( 92 | strategy="Adaptive", params=[["adaptivePriority", "Patient"]] 93 | ) 94 | ) 95 | price_update_delay: List[int] = Field( 96 | default_factory=lambda: [30, 60], min_length=2, max_length=2 97 | ) 98 | 99 | def add_to_table(self, table: Table, section: str = "") -> None: 100 | table.add_section() 101 | table.add_row("[spring_green1]Order settings") 102 | table.add_row("", "Exchange", "=", self.exchange) 103 | table.add_row("", "Params", "=", f"{self.algo.params}") 104 | table.add_row("", "Price update delay", "=", f"{self.price_update_delay}") 105 | table.add_row("", "Minimum credit", "=", f"{dfmt(self.minimum_credit)}") 106 | 107 | 108 | class IBAsyncConfig(BaseModel): 109 | api_response_wait_time: int = Field(default=60, ge=0) 110 | logfile: Optional[str] = None 111 | 112 | 113 | class IBCConfig(BaseModel): 114 | tradingMode: Literal["live", "paper"] = Field(default="paper") 115 | password: Optional[str] = None 116 | userid: Optional[str] = None 117 | gateway: bool = Field(default=True) 118 | RaiseRequestErrors: bool = Field(default=False) 119 | ibcPath: str = Field(default="/opt/ibc") 120 | ibcIni: str = Field(default="/etc/thetagang/config.ini") 121 | twsPath: Optional[str] = None 122 | twsSettingsPath: Optional[str] = None 123 | javaPath: str = Field(default="/opt/java/openjdk/bin") 124 | fixuserid: Optional[str] = None 125 | fixpassword: Optional[str] = None 126 | 127 | def to_dict(self) -> Dict[str, Any]: 128 | return { 129 | "tradingMode": self.tradingMode, 130 | "password": self.password, 131 | "userid": self.userid, 132 | "gateway": self.gateway, 133 | "ibcPath": self.ibcPath, 134 | "ibcIni": self.ibcIni, 135 | "twsPath": self.twsPath, 136 | "twsSettingsPath": self.twsSettingsPath, 137 | "javaPath": self.javaPath, 138 | "fixuserid": self.fixuserid, 139 | "fixpassword": self.fixpassword, 140 | } 141 | 142 | 143 | class WatchdogConfig(BaseModel): 144 | class ProbeContract(BaseModel): 145 | currency: str = Field(default="USD") 146 | exchange: str = Field(default="SMART") 147 | secType: str = Field(default="STK") 148 | symbol: str = Field(default="SPY") 149 | 150 | appStartupTime: int = Field(default=30) 151 | appTimeout: int = Field(default=20) 152 | clientId: int = Field(default=1) 153 | connectTimeout: int = Field(default=2) 154 | host: str = Field(default="127.0.0.1") 155 | port: int = Field(default=7497) 156 | probeTimeout: int = Field(default=4) 157 | readonly: bool = Field(default=False) 158 | retryDelay: int = Field(default=2) 159 | probeContract: "WatchdogConfig.ProbeContract" = Field( 160 | default_factory=lambda: WatchdogConfig.ProbeContract() 161 | ) 162 | 163 | def to_dict(self) -> Dict[str, Any]: 164 | return { 165 | "appStartupTime": self.appStartupTime, 166 | "appTimeout": self.appTimeout, 167 | "clientId": self.clientId, 168 | "connectTimeout": self.connectTimeout, 169 | "host": self.host, 170 | "port": self.port, 171 | "probeTimeout": self.probeTimeout, 172 | "readonly": self.readonly, 173 | "retryDelay": self.retryDelay, 174 | } 175 | 176 | 177 | class CashManagementConfig(BaseModel, DisplayMixin): 178 | class Orders(BaseModel): 179 | exchange: str = Field(default="SMART") 180 | algo: AlgoSettingsConfig = Field( 181 | default_factory=lambda: AlgoSettingsConfig(strategy="Vwap", params=[]) 182 | ) 183 | 184 | enabled: bool = Field(default=False) 185 | cash_fund: str = Field(default="SGOV") 186 | target_cash_balance: int = Field(default=0, ge=0) 187 | buy_threshold: int = Field(default=10000, ge=0) 188 | sell_threshold: int = Field(default=10000, ge=0) 189 | primary_exchange: str = Field(default="") 190 | orders: "CashManagementConfig.Orders" = Field( 191 | default_factory=lambda: CashManagementConfig.Orders() 192 | ) 193 | 194 | def add_to_table(self, table: Table, section: str = "") -> None: 195 | table.add_section() 196 | table.add_row("[spring_green1]Cash management") 197 | table.add_row("", "Enabled", "=", f"{self.enabled}") 198 | table.add_row("", "Cash fund", "=", f"{self.cash_fund}") 199 | table.add_row("", "Target cash", "=", f"{dfmt(self.target_cash_balance)}") 200 | table.add_row("", "Buy threshold", "=", f"{dfmt(self.buy_threshold)}") 201 | table.add_row("", "Sell threshold", "=", f"{dfmt(self.sell_threshold)}") 202 | 203 | 204 | class VIXCallHedgeConfig(BaseModel, DisplayMixin): 205 | class Allocation(BaseModel): 206 | weight: float = Field(..., ge=0.0) 207 | lower_bound: Optional[float] = Field(default=None, ge=0.0) 208 | upper_bound: Optional[float] = Field(default=None, ge=0.0) 209 | 210 | enabled: bool = Field(default=False) 211 | delta: float = Field(default=0.3, ge=0.0, le=1.0) 212 | target_dte: int = Field(default=30, gt=0) 213 | ignore_dte: int = Field(default=0, ge=0) 214 | max_dte: Optional[int] = Field(default=None, ge=1) 215 | close_hedges_when_vix_exceeds: Optional[float] = None 216 | allocation: List["VIXCallHedgeConfig.Allocation"] = Field( 217 | default_factory=lambda: [ 218 | VIXCallHedgeConfig.Allocation( 219 | lower_bound=None, upper_bound=15.0, weight=0.0 220 | ), 221 | VIXCallHedgeConfig.Allocation( 222 | lower_bound=15.0, upper_bound=30.0, weight=0.01 223 | ), 224 | VIXCallHedgeConfig.Allocation( 225 | lower_bound=30.0, upper_bound=50.0, weight=0.005 226 | ), 227 | VIXCallHedgeConfig.Allocation( 228 | lower_bound=50.0, upper_bound=None, weight=0.0 229 | ), 230 | ] 231 | ) 232 | 233 | def add_to_table(self, table: Table, section: str = "") -> None: 234 | table.add_section() 235 | table.add_row("[spring_green1]Hedging with VIX calls") 236 | table.add_row("", "Enabled", "=", f"{self.enabled}") 237 | table.add_row("", "Target delta", "<=", f"{self.delta}") 238 | table.add_row("", "Target DTE", ">=", f"{self.target_dte}") 239 | table.add_row("", "Ignore DTE", "<=", f"{self.ignore_dte}") 240 | if self.close_hedges_when_vix_exceeds: 241 | table.add_row( 242 | "", 243 | "Close hedges when VIX", 244 | ">=", 245 | f"{self.close_hedges_when_vix_exceeds}", 246 | ) 247 | 248 | for alloc in self.allocation: 249 | if alloc.lower_bound or alloc.upper_bound: 250 | table.add_row() 251 | if alloc.lower_bound: 252 | table.add_row( 253 | "", 254 | f"Allocate {pfmt(alloc.weight)} when VIXMO", 255 | ">=", 256 | f"{alloc.lower_bound}", 257 | ) 258 | if alloc.upper_bound: 259 | table.add_row( 260 | "", 261 | f"Allocate {pfmt(alloc.weight)} when VIXMO", 262 | "<=", 263 | f"{alloc.upper_bound}", 264 | ) 265 | 266 | 267 | class WriteWhenConfig(BaseModel, DisplayMixin): 268 | class Puts(BaseModel): 269 | green: bool = Field(default=False) 270 | red: bool = Field(default=True) 271 | 272 | class Calls(BaseModel): 273 | green: bool = Field(default=True) 274 | red: bool = Field(default=False) 275 | cap_factor: float = Field(default=1.0, ge=0.0, le=1.0) 276 | cap_target_floor: float = Field(default=0.0, ge=0.0, le=1.0) 277 | excess_only: bool = Field(default=False) 278 | 279 | calculate_net_contracts: bool = Field(default=False) 280 | calls: "WriteWhenConfig.Calls" = Field( 281 | default_factory=lambda: WriteWhenConfig.Calls() 282 | ) 283 | puts: "WriteWhenConfig.Puts" = Field(default_factory=lambda: WriteWhenConfig.Puts()) 284 | 285 | def add_to_table(self, table: Table, section: str = "") -> None: 286 | table.add_section() 287 | table.add_row("[spring_green1]When writing new contracts") 288 | table.add_row( 289 | "", 290 | "Calculate net contract positions", 291 | "=", 292 | f"{self.calculate_net_contracts}", 293 | ) 294 | table.add_row("", "Puts, write when red", "=", f"{self.puts.red}") 295 | table.add_row("", "Puts, write when green", "=", f"{self.puts.green}") 296 | table.add_row("", "Calls, write when green", "=", f"{self.calls.green}") 297 | table.add_row("", "Calls, write when red", "=", f"{self.calls.red}") 298 | table.add_row("", "Call cap factor", "=", f"{pfmt(self.calls.cap_factor)}") 299 | table.add_row( 300 | "", "Call cap target floor", "=", f"{pfmt(self.calls.cap_target_floor)}" 301 | ) 302 | table.add_row("", "Excess only", "=", f"{self.calls.excess_only}") 303 | 304 | 305 | class RollWhenConfig(BaseModel, DisplayMixin): 306 | class Calls(BaseModel): 307 | itm: bool = Field(default=True) 308 | always_when_itm: bool = Field(default=False) 309 | credit_only: bool = Field(default=False) 310 | has_excess: bool = Field(default=True) 311 | maintain_high_water_mark: bool = Field(default=False) 312 | 313 | class Puts(BaseModel): 314 | itm: bool = Field(default=False) 315 | always_when_itm: bool = Field(default=False) 316 | credit_only: bool = Field(default=False) 317 | has_excess: bool = Field(default=True) 318 | 319 | dte: int = Field(..., ge=0) 320 | pnl: float = Field(default=0.0, ge=0.0, le=1.0) 321 | min_pnl: float = Field(default=0.0) 322 | close_at_pnl: float = Field(default=1.0) 323 | close_if_unable_to_roll: bool = Field(default=False) 324 | max_dte: Optional[int] = Field(default=None, ge=1) 325 | calls: "RollWhenConfig.Calls" = Field( 326 | default_factory=lambda: RollWhenConfig.Calls() 327 | ) 328 | puts: "RollWhenConfig.Puts" = Field(default_factory=lambda: RollWhenConfig.Puts()) 329 | 330 | def add_to_table(self, table: Table, section: str = "") -> None: 331 | table.add_section() 332 | table.add_row("[spring_green1]Close option positions") 333 | table.add_row("", "When P&L", ">=", f"{pfmt(self.close_at_pnl, 0)}") 334 | table.add_row( 335 | "", "Close if unable to roll", "=", f"{self.close_if_unable_to_roll}" 336 | ) 337 | 338 | table.add_section() 339 | table.add_row("[spring_green1]Roll options when either condition is true") 340 | table.add_row( 341 | "", 342 | "Days to expiry", 343 | "<=", 344 | f"{self.dte} and P&L >= {self.min_pnl} ({pfmt(self.min_pnl, 0)})", 345 | ) 346 | 347 | if self.max_dte: 348 | table.add_row( 349 | "", 350 | "P&L", 351 | ">=", 352 | f"{self.pnl} ({pfmt(self.pnl, 0)}) and DTE <= {self.max_dte}", 353 | ) 354 | else: 355 | table.add_row("", "P&L", ">=", f"{self.pnl} ({pfmt(self.pnl, 0)})") 356 | 357 | table.add_row("", "Puts: credit only", "=", f"{self.puts.credit_only}") 358 | table.add_row("", "Puts: roll excess", "=", f"{self.puts.has_excess}") 359 | table.add_row("", "Calls: credit only", "=", f"{self.calls.credit_only}") 360 | table.add_row("", "Calls: roll excess", "=", f"{self.calls.has_excess}") 361 | table.add_row( 362 | "", 363 | "Calls: maintain high water mark", 364 | "=", 365 | f"{self.calls.maintain_high_water_mark}", 366 | ) 367 | 368 | table.add_section() 369 | table.add_row("[spring_green1]When contracts are ITM") 370 | table.add_row( 371 | "", 372 | "Roll puts", 373 | "=", 374 | f"{self.puts.itm}", 375 | ) 376 | table.add_row( 377 | "", 378 | "Roll puts always", 379 | "=", 380 | f"{self.puts.always_when_itm}", 381 | ) 382 | table.add_row( 383 | "", 384 | "Roll calls", 385 | "=", 386 | f"{self.calls.itm}", 387 | ) 388 | table.add_row( 389 | "", 390 | "Roll calls always", 391 | "=", 392 | f"{self.calls.always_when_itm}", 393 | ) 394 | 395 | 396 | class TargetConfig(BaseModel, DisplayMixin): 397 | class Puts(BaseModel): 398 | delta: Optional[float] = Field(default=None, ge=0.0, le=1.0) 399 | 400 | class Calls(BaseModel): 401 | delta: Optional[float] = Field(default=None, ge=0.0, le=1.0) 402 | 403 | dte: int = Field(..., ge=0) 404 | minimum_open_interest: int = Field(..., ge=0) 405 | maximum_new_contracts_percent: float = Field(0.05, ge=0.0, le=1.0) 406 | delta: float = Field(default=0.3, ge=0.0, le=1.0) 407 | max_dte: Optional[int] = Field(default=None, ge=1) 408 | maximum_new_contracts: Optional[int] = Field(default=None, ge=1) 409 | calls: Optional["TargetConfig.Calls"] = None 410 | puts: Optional["TargetConfig.Puts"] = None 411 | 412 | def add_to_table(self, table: Table, section: str = "") -> None: 413 | table.add_section() 414 | table.add_row("[spring_green1]Write options with targets of") 415 | table.add_row("", "Days to expiry", ">=", f"{self.dte}") 416 | if self.max_dte: 417 | table.add_row("", "Days to expiry", "<=", f"{self.max_dte}") 418 | table.add_row("", "Default delta", "<=", f"{self.delta}") 419 | if self.puts and self.puts.delta: 420 | table.add_row("", "Delta for puts", "<=", f"{self.puts.delta}") 421 | if self.calls and self.calls.delta: 422 | table.add_row("", "Delta for calls", "<=", f"{self.calls.delta}") 423 | table.add_row( 424 | "", 425 | "Maximum new contracts", 426 | "=", 427 | f"{pfmt(self.maximum_new_contracts_percent, 0)} of buying power", 428 | ) 429 | table.add_row("", "Minimum open interest", "=", f"{self.minimum_open_interest}") 430 | 431 | 432 | class SymbolConfig(BaseModel): 433 | class WriteWhen(BaseModel): 434 | green: Optional[bool] = None 435 | red: Optional[bool] = None 436 | 437 | class Calls(BaseModel): 438 | cap_factor: Optional[float] = Field(default=None, ge=0, le=1) 439 | cap_target_floor: Optional[float] = Field(default=None, ge=0, le=1) 440 | excess_only: Optional[bool] = None 441 | delta: Optional[float] = Field(default=None, ge=0, le=1) 442 | write_threshold: Optional[float] = Field(default=None, ge=0, le=1) 443 | write_threshold_sigma: Optional[float] = Field(default=None, gt=0) 444 | strike_limit: Optional[float] = Field(default=None, gt=0) 445 | maintain_high_water_mark: Optional[bool] = None 446 | write_when: Optional["SymbolConfig.WriteWhen"] = Field( 447 | default_factory=lambda: SymbolConfig.WriteWhen() 448 | ) 449 | 450 | class Puts(BaseModel): 451 | delta: Optional[float] = Field(default=None, ge=0, le=1) 452 | write_threshold: Optional[float] = Field(default=None, ge=0, le=1) 453 | write_threshold_sigma: Optional[float] = Field(default=None, gt=0) 454 | strike_limit: Optional[float] = Field(default=None, gt=0) 455 | write_when: Optional["SymbolConfig.WriteWhen"] = Field( 456 | default_factory=lambda: SymbolConfig.WriteWhen() 457 | ) 458 | 459 | weight: float = Field(..., ge=0, le=1) 460 | primary_exchange: str = Field(default="", min_length=1) 461 | delta: Optional[float] = Field(default=None, ge=0, le=1) 462 | write_threshold: Optional[float] = Field(default=None, ge=0, le=1) 463 | write_threshold_sigma: Optional[float] = Field(default=None, gt=0) 464 | max_dte: Optional[int] = Field(default=None, ge=1) 465 | dte: Optional[int] = Field(default=None, ge=0) 466 | close_if_unable_to_roll: Optional[bool] = None 467 | calls: Optional["SymbolConfig.Calls"] = None 468 | puts: Optional["SymbolConfig.Puts"] = None 469 | adjust_price_after_delay: bool = Field(default=False) 470 | no_trading: Optional[bool] = None 471 | 472 | 473 | class ActionWhenClosedEnum(str, Enum): 474 | wait = "wait" 475 | exit = "exit" 476 | continue_ = "continue" 477 | 478 | 479 | class ExchangeHoursConfig(BaseModel, DisplayMixin): 480 | exchange: str = Field(default="XNYS") 481 | action_when_closed: ActionWhenClosedEnum = Field(default=ActionWhenClosedEnum.exit) 482 | delay_after_open: int = Field(default=1800, ge=0) 483 | delay_before_close: int = Field(default=1800, ge=0) 484 | max_wait_until_open: int = Field(default=3600, ge=0) 485 | 486 | def add_to_table(self, table: Table, section: str = "") -> None: 487 | table.add_row("[spring_green1]Exchange hours") 488 | table.add_row("", "Exchange", "=", self.exchange) 489 | table.add_row("", "Action when closed", "=", self.action_when_closed) 490 | table.add_row("", "Delay after open", "=", f"{self.delay_after_open}s") 491 | table.add_row("", "Delay before close", "=", f"{self.delay_before_close}s") 492 | table.add_row("", "Max wait until open", "=", f"{self.max_wait_until_open}s") 493 | 494 | 495 | class Config(BaseModel, DisplayMixin): 496 | account: AccountConfig 497 | option_chains: OptionChainsConfig 498 | roll_when: RollWhenConfig 499 | target: TargetConfig 500 | exchange_hours: ExchangeHoursConfig = Field(default_factory=ExchangeHoursConfig) 501 | 502 | orders: OrdersConfig = Field(default_factory=OrdersConfig) 503 | ib_async: IBAsyncConfig = Field(default_factory=IBAsyncConfig) 504 | ibc: IBCConfig = Field(default_factory=IBCConfig) 505 | watchdog: WatchdogConfig = Field(default_factory=WatchdogConfig) 506 | cash_management: CashManagementConfig = Field(default_factory=CashManagementConfig) 507 | vix_call_hedge: VIXCallHedgeConfig = Field(default_factory=VIXCallHedgeConfig) 508 | write_when: WriteWhenConfig = Field(default_factory=WriteWhenConfig) 509 | symbols: Dict[str, SymbolConfig] = Field(default_factory=dict) 510 | constants: ConstantsConfig = Field(default_factory=ConstantsConfig) 511 | 512 | def trading_is_allowed(self, symbol: str) -> bool: 513 | symbol_config = self.symbols.get(symbol) 514 | return not symbol_config or not symbol_config.no_trading 515 | 516 | def symbol_config(self, symbol: str) -> Optional[SymbolConfig]: 517 | return self.symbols.get(symbol) 518 | 519 | @model_validator(mode="after") 520 | def check_symbols(self) -> Self: 521 | if not self.symbols: 522 | raise ValueError("At least one symbol must be specified") 523 | return self 524 | 525 | @model_validator(mode="after") 526 | def check_symbol_weights(self) -> Self: 527 | if not math.isclose( 528 | 1, sum([s.weight or 0.0 for s in self.symbols.values()]), rel_tol=1e-5 529 | ): 530 | raise ValueError("Symbol weights must sum to 1.0") 531 | return self 532 | 533 | def get_target_delta(self, symbol: str, right: str) -> float: 534 | p_or_c = "calls" if right.upper().startswith("C") else "puts" 535 | symbol_config = self.symbols.get(symbol) 536 | 537 | if symbol_config: 538 | option_config = getattr(symbol_config, p_or_c, None) 539 | if option_config and option_config.delta is not None: 540 | return option_config.delta 541 | if symbol_config.delta is not None: 542 | return symbol_config.delta 543 | 544 | target_option = getattr(self.target, p_or_c, None) 545 | if target_option and target_option.delta is not None: 546 | return target_option.delta 547 | 548 | return self.target.delta 549 | 550 | def maintain_high_water_mark(self, symbol: str) -> bool: 551 | symbol_config = self.symbols.get(symbol) 552 | if ( 553 | symbol_config 554 | and symbol_config.calls 555 | and symbol_config.calls.maintain_high_water_mark is not None 556 | ): 557 | return symbol_config.calls.maintain_high_water_mark 558 | return self.roll_when.calls.maintain_high_water_mark 559 | 560 | def get_write_threshold_sigma( 561 | self, 562 | symbol: str, 563 | right: str, 564 | ) -> Optional[float]: 565 | p_or_c = "calls" if right.upper().startswith("C") else "puts" 566 | symbol_config = self.symbols.get(symbol) 567 | 568 | if symbol_config: 569 | option_config = getattr(symbol_config, p_or_c, None) 570 | if option_config: 571 | if option_config.write_threshold_sigma is not None: 572 | return option_config.write_threshold_sigma 573 | if option_config.write_threshold is not None: 574 | return None 575 | 576 | if symbol_config.write_threshold_sigma is not None: 577 | return symbol_config.write_threshold_sigma 578 | if symbol_config.write_threshold is not None: 579 | return None 580 | 581 | option_constants = getattr(self.constants, p_or_c, None) 582 | if option_constants and option_constants.write_threshold_sigma is not None: 583 | return option_constants.write_threshold_sigma 584 | if self.constants.write_threshold_sigma is not None: 585 | return self.constants.write_threshold_sigma 586 | 587 | return None 588 | 589 | def get_write_threshold_perc( 590 | self, 591 | symbol: str, 592 | right: str, 593 | ) -> float: 594 | p_or_c = "calls" if right.upper().startswith("C") else "puts" 595 | symbol_config = self.symbols.get(symbol) 596 | 597 | if symbol_config: 598 | option_config = getattr(symbol_config, p_or_c, None) 599 | if option_config and option_config.write_threshold is not None: 600 | return option_config.write_threshold 601 | if symbol_config.write_threshold is not None: 602 | return symbol_config.write_threshold 603 | 604 | option_constants = getattr(self.constants, p_or_c, None) 605 | if option_constants and option_constants.write_threshold is not None: 606 | return option_constants.write_threshold 607 | if self.constants.write_threshold is not None: 608 | return self.constants.write_threshold 609 | 610 | return 0.0 611 | 612 | def create_symbols_table(self) -> Table: 613 | table = Table( 614 | title="Configured symbols and target weights", 615 | box=box.SIMPLE_HEAVY, 616 | show_lines=True, 617 | ) 618 | table.add_column("Symbol") 619 | table.add_column("Weight", justify="right") 620 | table.add_column("Call delta", justify="right") 621 | table.add_column("Call strike limit", justify="right") 622 | table.add_column("Call threshold", justify="right") 623 | table.add_column("HWM", justify="right") 624 | table.add_column("Put delta", justify="right") 625 | table.add_column("Put strike limit", justify="right") 626 | table.add_column("Put threshold", justify="right") 627 | 628 | for symbol, sconfig in self.symbols.items(): 629 | call_thresh = ( 630 | f"{ffmt(self.get_write_threshold_sigma(symbol, 'C'))}σ" 631 | if self.get_write_threshold_sigma(symbol, "C") 632 | else pfmt(self.get_write_threshold_perc(symbol, "C")) 633 | ) 634 | put_thresh = ( 635 | f"{ffmt(self.get_write_threshold_sigma(symbol, 'P'))}σ" 636 | if self.get_write_threshold_sigma(symbol, "P") 637 | else pfmt(self.get_write_threshold_perc(symbol, "P")) 638 | ) 639 | 640 | table.add_row( 641 | symbol, 642 | pfmt(sconfig.weight or 0.0), 643 | ffmt(self.get_target_delta(symbol, "C")), 644 | dfmt(sconfig.calls.strike_limit if sconfig.calls else None), 645 | call_thresh, 646 | str(self.maintain_high_water_mark(symbol)), 647 | ffmt(self.get_target_delta(symbol, "P")), 648 | dfmt(sconfig.puts.strike_limit if sconfig.puts else None), 649 | put_thresh, 650 | ) 651 | return table 652 | 653 | def display(self, config_path: str) -> None: 654 | console = Console() 655 | config_table = Table(box=box.SIMPLE_HEAVY) 656 | config_table.add_column("Section") 657 | config_table.add_column("Setting") 658 | config_table.add_column("") 659 | config_table.add_column("Value") 660 | 661 | # Add all component tables 662 | self.account.add_to_table(config_table) 663 | self.exchange_hours.add_to_table(config_table) 664 | if self.constants: 665 | self.constants.add_to_table(config_table) 666 | self.orders.add_to_table(config_table) 667 | self.roll_when.add_to_table(config_table) 668 | self.write_when.add_to_table(config_table) 669 | self.target.add_to_table(config_table) 670 | self.cash_management.add_to_table(config_table) 671 | self.vix_call_hedge.add_to_table(config_table) 672 | 673 | # Create tree and add tables 674 | tree = Tree(":control_knobs:") 675 | tree.add(Group(f":file_cabinet: Loaded from {config_path}", config_table)) 676 | tree.add(Group(":yin_yang: Symbology", self.create_symbols_table())) 677 | 678 | console.print(Panel(tree, title="Config")) 679 | 680 | def get_target_dte(self, symbol: str) -> int: 681 | symbol_config = self.symbols.get(symbol) 682 | return ( 683 | symbol_config.dte 684 | if symbol_config and symbol_config.dte is not None 685 | else self.target.dte 686 | ) 687 | 688 | def get_cap_factor(self, symbol: str) -> float: 689 | symbol_config = self.symbols.get(symbol) 690 | if ( 691 | symbol_config is not None 692 | and symbol_config.calls is not None 693 | and symbol_config.calls.cap_factor is not None 694 | ): 695 | return symbol_config.calls.cap_factor 696 | return self.write_when.calls.cap_factor 697 | 698 | def get_cap_target_floor(self, symbol: str) -> float: 699 | symbol_config = self.symbols.get(symbol) 700 | if ( 701 | symbol_config is not None 702 | and symbol_config.calls is not None 703 | and symbol_config.calls.cap_target_floor is not None 704 | ): 705 | return symbol_config.calls.cap_target_floor 706 | return self.write_when.calls.cap_target_floor 707 | 708 | def get_strike_limit(self, symbol: str, right: str) -> Optional[float]: 709 | p_or_c = "calls" if right.upper().startswith("C") else "puts" 710 | symbol_config = self.symbols.get(symbol) 711 | option_config = getattr(symbol_config, p_or_c, None) if symbol_config else None 712 | return option_config.strike_limit if option_config else None 713 | 714 | def write_excess_calls_only(self, symbol: str) -> bool: 715 | symbol_config = self.symbols.get(symbol) 716 | if ( 717 | symbol_config is not None 718 | and symbol_config.calls is not None 719 | and symbol_config.calls.excess_only is not None 720 | ): 721 | return symbol_config.calls.excess_only 722 | return self.write_when.calls.excess_only 723 | 724 | def get_max_dte_for(self, symbol: str) -> Optional[int]: 725 | if symbol == "VIX" and self.vix_call_hedge.max_dte is not None: 726 | return self.vix_call_hedge.max_dte 727 | symbol_config = self.symbols.get(symbol) 728 | if symbol_config is not None and symbol_config.max_dte is not None: 729 | return symbol_config.max_dte 730 | return self.target.max_dte 731 | 732 | def can_write_when(self, symbol: str, right: str) -> Tuple[bool, bool]: 733 | symbol_config = self.symbols.get(symbol) 734 | p_or_c = "calls" if right.upper().startswith("C") else "puts" 735 | option_config = ( 736 | getattr(symbol_config, p_or_c, None) if symbol_config is not None else None 737 | ) 738 | default_config = getattr(self.write_when, p_or_c) 739 | can_write_when_green = ( 740 | option_config.write_when.green 741 | if option_config is not None 742 | and option_config.write_when is not None 743 | and option_config.write_when.green is not None 744 | else default_config.green 745 | ) 746 | can_write_when_red = ( 747 | option_config.write_when.red 748 | if option_config is not None 749 | and option_config.write_when is not None 750 | and option_config.write_when.red is not None 751 | else default_config.red 752 | ) 753 | return (can_write_when_green, can_write_when_red) 754 | 755 | def close_if_unable_to_roll(self, symbol: str) -> bool: 756 | symbol_config = self.symbols.get(symbol) 757 | return ( 758 | symbol_config.close_if_unable_to_roll 759 | if symbol_config is not None 760 | and symbol_config.close_if_unable_to_roll is not None 761 | else self.roll_when.close_if_unable_to_roll 762 | ) 763 | 764 | 765 | def normalize_config(config: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: 766 | # Do any pre-processing necessary to the config here, such as handling 767 | # defaults, deprecated values, config changes, etc. 768 | if "minimum_cushion" in config["account"]: 769 | raise RuntimeError( 770 | "Config error: minimum_cushion is deprecated and replaced with margin_usage. See sample config for details." 771 | ) 772 | 773 | if "ib_insync" in config: 774 | error_console.print( 775 | "WARNING: config param `ib_insync` is deprecated, please rename it to the equivalent `ib_async`.", 776 | ) 777 | 778 | if "ib_async" not in config: 779 | # swap the old ib_insync key to the new ib_async key 780 | config["ib_async"] = config["ib_insync"] 781 | del config["ib_insync"] 782 | 783 | if "twsVersion" in config["ibc"]: 784 | error_console.print( 785 | "WARNING: config param ibc.twsVersion is deprecated, please remove it from your config.", 786 | ) 787 | 788 | # TWS version is pinned to latest stable, delete any existing config if it's present 789 | del config["ibc"]["twsVersion"] 790 | 791 | if "maximum_new_contracts" in config["target"]: 792 | error_console.print( 793 | "WARNING: config param target.maximum_new_contracts is deprecated, please remove it from your config.", 794 | ) 795 | 796 | del config["target"]["maximum_new_contracts"] 797 | 798 | # xor: should have weight OR parts, but not both 799 | if any(["weight" in s for s in config["symbols"].values()]) == any( 800 | ["parts" in s for s in config["symbols"].values()] 801 | ): 802 | raise RuntimeError( 803 | "ERROR: all symbols should have either a weight or parts specified, but parts and weights cannot be mixed." 804 | ) 805 | 806 | if "parts" in list(config["symbols"].values())[0]: 807 | # If using "parts" instead of "weight", convert parts into weights 808 | total_parts = float(sum([s["parts"] for s in config["symbols"].values()])) 809 | for k in config["symbols"].keys(): 810 | config["symbols"][k]["weight"] = config["symbols"][k]["parts"] / total_parts 811 | for s in config["symbols"].values(): 812 | del s["parts"] 813 | 814 | if ( 815 | "close_at_pnl" in config["roll_when"] 816 | and config["roll_when"]["close_at_pnl"] 817 | and config["roll_when"]["close_at_pnl"] <= config["roll_when"]["min_pnl"] 818 | ): 819 | raise RuntimeError( 820 | "ERROR: roll_when.close_at_pnl needs to be greater than roll_when.min_pnl." 821 | ) 822 | 823 | return config 824 | -------------------------------------------------------------------------------- /thetagang/entry.py: -------------------------------------------------------------------------------- 1 | # Do not reorder imports 2 | from .main import * # NOQA: F403 3 | -------------------------------------------------------------------------------- /thetagang/exchange_hours.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime, timezone 3 | 4 | import exchange_calendars as xcals 5 | import pandas as pd 6 | from rich import box 7 | from rich.table import Table 8 | 9 | from thetagang import log 10 | from thetagang.config import ExchangeHoursConfig 11 | 12 | 13 | def determine_action(config: ExchangeHoursConfig, now: datetime) -> str: 14 | if config.action_when_closed == "continue": 15 | return "continue" 16 | 17 | calendar = xcals.get_calendar(config.exchange) 18 | today = now.date() 19 | 20 | if calendar.is_session(today): # type: ignore 21 | open = calendar.session_open(today) # type: ignore 22 | close = calendar.session_close(today) # type: ignore 23 | 24 | start = open + pd.Timedelta(seconds=config.delay_after_open) 25 | end = close - pd.Timedelta(seconds=config.delay_before_close) 26 | 27 | table = Table(box=box.SIMPLE) 28 | table.add_column("Exchange Hours") 29 | table.add_column(config.exchange) 30 | table.add_row("Open", str(open)) 31 | table.add_row("Close", str(close)) 32 | table.add_row("Start", str(start)) 33 | table.add_row("End", str(end)) 34 | log.print(table) 35 | 36 | if start <= now <= end: 37 | # Exchange is open 38 | return "continue" 39 | elif config.action_when_closed == "exit": 40 | log.info("Exchange is closed") 41 | return "exit" 42 | elif config.action_when_closed == "wait": 43 | log.info("Exchange is closed") 44 | return "wait" 45 | elif config.action_when_closed == "wait": 46 | return "wait" 47 | 48 | log.info("Exchange is closed") 49 | return "exit" 50 | 51 | 52 | def waited_for_open(config: ExchangeHoursConfig, now: datetime) -> bool: 53 | calendar = xcals.get_calendar(config.exchange) 54 | today = now.date() 55 | 56 | next_session = calendar.date_to_session(today, direction="next") # type: ignore 57 | 58 | open = calendar.session_open(next_session) # type: ignore 59 | start = open + pd.Timedelta(seconds=config.delay_after_open) 60 | 61 | seconds_until_start = (start - now).total_seconds() 62 | 63 | if seconds_until_start < config.max_wait_until_open: 64 | log.info( 65 | f"Waiting for exchange to open, start={start} seconds_until_start={seconds_until_start}" 66 | ) 67 | time.sleep(seconds_until_start) 68 | return True 69 | else: 70 | log.info( 71 | f"Max wait time exceeded, exiting (seconds_until_start={seconds_until_start}, max_wait_until_open={config.max_wait_until_open})" 72 | ) 73 | 74 | return False 75 | 76 | 77 | def need_to_exit(config: ExchangeHoursConfig) -> bool: 78 | now = datetime.now(tz=timezone.utc) 79 | action = determine_action(config, now) 80 | if action == "exit": 81 | return True 82 | if action == "wait": 83 | return not waited_for_open(config, now) 84 | 85 | # action is "continue" 86 | return False 87 | -------------------------------------------------------------------------------- /thetagang/fmt.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | 4 | def redgreen(value: Union[int, float]) -> str: 5 | if value >= 0: 6 | return "green" 7 | return "red" 8 | 9 | 10 | def dfmt(amount: Optional[Union[int, str, float]], precision: int = 2) -> str: 11 | if amount is not None: 12 | amount = float(amount) 13 | rg = redgreen(amount) 14 | return f"[{rg}]${amount:,.{precision}f}[/{rg}]" 15 | return "" 16 | 17 | 18 | def pfmt(amount: Union[str, float, None], precision: int = 2) -> str: 19 | if amount is None: 20 | return "" 21 | amount = float(amount) * 100.0 22 | rg = redgreen(amount) 23 | return f"[{rg}]{amount:.{precision}f}%[/{rg}]" 24 | 25 | 26 | def ffmt(amount: Optional[float], precision: int = 2) -> str: 27 | if amount is not None: 28 | amount = float(amount) 29 | rg = redgreen(amount) 30 | return f"[{rg}]{amount:.{precision}f}[/{rg}]" 31 | return "" 32 | 33 | 34 | def ifmt(amount: Optional[int]) -> str: 35 | if amount is not None: 36 | amount = int(amount) 37 | rg = redgreen(amount) 38 | return f"[{rg}]{amount:,d}[/{rg}]" 39 | return "" 40 | 41 | 42 | def to_camel_case(snake_str: str) -> str: 43 | components = snake_str.split("_") 44 | # We capitalize the first letter of each component except the first one 45 | # with the 'title' method and join them together. 46 | return components[0] + "".join(x.title() for x in components[1:]) 47 | -------------------------------------------------------------------------------- /thetagang/ibkr.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from enum import Enum 3 | from typing import Any, Awaitable, Callable, List, Optional 4 | 5 | from ib_async import ( 6 | IB, 7 | AccountValue, 8 | BarDataList, 9 | Contract, 10 | OptionChain, 11 | Order, 12 | PortfolioItem, 13 | Stock, 14 | Ticker, 15 | Trade, 16 | util, 17 | ) 18 | from rich.console import Console 19 | 20 | from thetagang import log 21 | 22 | console = Console() 23 | 24 | 25 | class TickerField(Enum): 26 | MIDPOINT = "midpoint" 27 | MARKET_PRICE = "market_price" 28 | GREEKS = "greeks" 29 | OPEN_INTEREST = "open_interest" 30 | 31 | 32 | class RequiredFieldValidationError(Exception): 33 | def __init__(self, message: str) -> None: 34 | self.message = message 35 | super().__init__(self.message) 36 | 37 | 38 | class IBKR: 39 | def __init__( 40 | self, ib: IB, api_response_wait_time: int, default_order_exchange: str 41 | ) -> None: 42 | self.ib = ib 43 | self.ib.orderStatusEvent += self.orderStatusEvent 44 | self.api_response_wait_time = api_response_wait_time 45 | self.default_order_exchange = default_order_exchange 46 | 47 | def portfolio(self, account: str) -> List[PortfolioItem]: 48 | return self.ib.portfolio(account) 49 | 50 | async def account_summary(self, account: str) -> List[AccountValue]: 51 | return await self.ib.accountSummaryAsync(account) 52 | 53 | async def request_historical_data( 54 | self, 55 | contract: Contract, 56 | duration: str, 57 | ) -> BarDataList: 58 | return await self.ib.reqHistoricalDataAsync( 59 | contract, 60 | "", 61 | duration, 62 | "1 day", 63 | "TRADES", 64 | True, 65 | ) 66 | 67 | def set_market_data_type( 68 | self, 69 | data_type: int, 70 | ) -> None: 71 | self.ib.reqMarketDataType(data_type) 72 | 73 | def open_trades(self) -> List[Trade]: 74 | return self.ib.openTrades() 75 | 76 | def place_order(self, contract: Contract, order: Order) -> Trade: 77 | return self.ib.placeOrder(contract, order) 78 | 79 | def cancel_order(self, order: Order) -> None: 80 | self.ib.cancelOrder(order) 81 | 82 | async def get_chains_for_contract(self, contract: Contract) -> List[OptionChain]: 83 | return await self.ib.reqSecDefOptParamsAsync( 84 | contract.symbol, "", contract.secType, contract.conId 85 | ) 86 | 87 | async def qualify_contracts(self, *contracts: Contract) -> List[Contract]: 88 | return await self.ib.qualifyContractsAsync(*contracts) 89 | 90 | async def get_ticker_for_stock( 91 | self, 92 | symbol: str, 93 | primary_exchange: str, 94 | order_exchange: Optional[str] = None, 95 | generic_tick_list: str = "", 96 | required_fields: List[TickerField] = [TickerField.MARKET_PRICE], 97 | optional_fields: List[TickerField] = [TickerField.MIDPOINT], 98 | ) -> Ticker: 99 | stock = Stock( 100 | symbol, 101 | order_exchange or self.default_order_exchange, 102 | currency="USD", 103 | primaryExchange=primary_exchange, 104 | ) 105 | return await self.get_ticker_for_contract( 106 | stock, generic_tick_list, required_fields, optional_fields 107 | ) 108 | 109 | async def get_tickers_for_contracts( 110 | self, 111 | underlying_symbol: str, 112 | contracts: List[Contract], 113 | generic_tick_list: str = "", 114 | required_fields: List[TickerField] = [TickerField.MARKET_PRICE], 115 | optional_fields: List[TickerField] = [TickerField.MIDPOINT], 116 | ) -> List[Ticker]: 117 | async def get_ticker_task(contract: Contract) -> Ticker: 118 | return await self.get_ticker_for_contract( 119 | contract, generic_tick_list, required_fields, optional_fields 120 | ) 121 | 122 | tasks = [get_ticker_task(contract) for contract in contracts] 123 | tickers = await log.track_async( 124 | tasks, 125 | description=f"{underlying_symbol}: Gathering tickers, waiting for required & optional fields...", 126 | ) 127 | return tickers 128 | 129 | async def get_ticker_for_contract( 130 | self, 131 | contract: Contract, 132 | generic_tick_list: str = "", 133 | required_fields: List[TickerField] = [TickerField.MARKET_PRICE], 134 | optional_fields: List[TickerField] = [TickerField.MIDPOINT], 135 | ) -> Ticker: 136 | required_handlers = [ 137 | (field, self.__ticker_field_handler__(field)) for field in required_fields 138 | ] 139 | optional_handlers = [ 140 | (field, self.__ticker_field_handler__(field)) for field in optional_fields 141 | ] 142 | 143 | async def ticker_handler(ticker: Ticker) -> None: 144 | required_tasks = [handler(ticker) for _, handler in required_handlers] 145 | optional_tasks = [handler(ticker) for _, handler in optional_handlers] 146 | 147 | # Gather results, allowing optional tasks to potentially fail (timeout) 148 | results = await asyncio.gather( 149 | asyncio.gather(*required_tasks), 150 | asyncio.gather( 151 | *optional_tasks, return_exceptions=False 152 | ), # Don't raise exceptions here for optional 153 | ) 154 | required_results = results[0] 155 | optional_results = results[1] 156 | 157 | # Check required results 158 | failed_required_fields = [ 159 | field.name 160 | for i, (field, _) in enumerate(required_handlers) 161 | if not required_results[i] 162 | ] 163 | if failed_required_fields: 164 | raise RequiredFieldValidationError( 165 | f"Required fields timed out for {contract.localSymbol}: {', '.join(failed_required_fields)}" 166 | ) 167 | 168 | # Log warnings for optional results that timed out 169 | failed_optional_fields = [ 170 | field.name 171 | for i, (field, _) in enumerate(optional_handlers) 172 | if not optional_results[i] 173 | ] 174 | if failed_optional_fields: 175 | log.warning( 176 | f"Optional fields timed out for {contract.localSymbol}: {', '.join(failed_optional_fields)}" 177 | ) 178 | 179 | return await self.__market_data_streaming_handler__( 180 | contract, 181 | generic_tick_list, 182 | lambda ticker: ticker_handler(ticker), 183 | ) 184 | 185 | async def __wait_for_midpoint_price__(self, ticker: Ticker) -> bool: 186 | return await self.__ticker_wait_for_condition__( 187 | ticker, lambda t: not util.isNan(t.midpoint()), self.api_response_wait_time 188 | ) 189 | 190 | async def __wait_for_market_price__(self, ticker: Ticker) -> bool: 191 | return await self.__ticker_wait_for_condition__( 192 | ticker, 193 | lambda t: not util.isNan(t.marketPrice()), 194 | self.api_response_wait_time, 195 | ) 196 | 197 | async def __wait_for_greeks__(self, ticker: Ticker) -> bool: 198 | return await self.__ticker_wait_for_condition__( 199 | ticker, 200 | lambda t: not ( 201 | t.modelGreeks is None 202 | or t.modelGreeks.delta is None 203 | or util.isNan(t.modelGreeks.delta) 204 | ), 205 | self.api_response_wait_time, 206 | ) 207 | 208 | async def __wait_for_open_interest__(self, ticker: Ticker) -> bool: 209 | def open_interest_is_not_ready(ticker: Ticker) -> bool: 210 | if not ticker.contract: 211 | return False 212 | if ticker.contract.right.startswith("P"): 213 | return util.isNan(ticker.putOpenInterest) 214 | else: 215 | return util.isNan(ticker.callOpenInterest) 216 | 217 | return await self.__ticker_wait_for_condition__( 218 | ticker, 219 | lambda t: not open_interest_is_not_ready(t), 220 | self.api_response_wait_time, 221 | ) 222 | 223 | def orderStatusEvent(self, trade: Trade) -> None: 224 | if "Filled" in trade.orderStatus.status: 225 | log.info(f"{trade.contract.symbol}: Order filled") 226 | if "Fill" in trade.orderStatus.status: 227 | log.info( 228 | f"{trade.contract.symbol}: {trade.orderStatus.filled} filled, {trade.orderStatus.remaining} remaining" 229 | ) 230 | if "Cancelled" in trade.orderStatus.status: 231 | log.warning(f"{trade.contract.symbol}: Order cancelled, trade={trade}") 232 | else: 233 | log.info( 234 | f"{trade.contract.symbol}: Order updated with status={trade.orderStatus.status}" 235 | ) 236 | 237 | async def __market_data_streaming_handler__( 238 | self, 239 | contract: Contract, 240 | generic_tick_list: str, 241 | handler: Callable[[Ticker], Awaitable[Any]], 242 | ) -> Ticker: 243 | """ 244 | Handles the streaming of market data for a given contract. 245 | 246 | This asynchronous method qualifies the contract, requests market data, 247 | and processes the data using the provided handler. Once the handler 248 | completes, the market data request is canceled. 249 | 250 | Args: 251 | contract (Contract): The contract for which market data is requested. 252 | handler (Callable[[Ticker], Awaitable[None]]): An asynchronous function 253 | that processes the received market data ticker. 254 | 255 | Returns: 256 | Ticker: The market data ticker for the given contract. 257 | """ 258 | await self.ib.qualifyContractsAsync(contract) 259 | ticker = self.ib.reqMktData(contract, genericTickList=generic_tick_list) 260 | await handler(ticker) 261 | return ticker 262 | 263 | async def __ticker_wait_for_condition__( 264 | self, ticker: Ticker, condition: Callable[[Ticker], bool], timeout: float 265 | ) -> bool: 266 | event = asyncio.Event() 267 | 268 | def onTicker(ticker: Ticker) -> None: 269 | if condition(ticker): 270 | event.set() 271 | 272 | ticker.updateEvent += onTicker # type: ignore 273 | try: 274 | await asyncio.wait_for(event.wait(), timeout=timeout) 275 | return True 276 | except asyncio.TimeoutError: 277 | return False 278 | finally: 279 | ticker.updateEvent -= onTicker 280 | 281 | async def wait_for_submitting_orders( 282 | self, trades: List[Trade], timetout: int = 60 283 | ) -> None: 284 | tasks = [ 285 | self.__trade_wait_for_condition__( 286 | trade, 287 | lambda trade: trade.orderStatus.status 288 | not in ["PendingSubmit", "PreSubmitted"], 289 | timetout, 290 | ) 291 | for trade in trades 292 | ] 293 | results = await log.track_async(tasks, "Waiting for orders to be submitted...") 294 | if not all(results): 295 | failed_trades = [ 296 | f"{trade.contract.symbol} (OrderId: {trade.order.orderId})" 297 | for i, trade in enumerate(trades) 298 | if not results[i] 299 | ] 300 | raise RuntimeError( 301 | f"Timeout waiting for orders to submit: {', '.join(failed_trades)}" 302 | ) 303 | 304 | async def wait_for_orders_complete( 305 | self, trades: List[Trade], timetout: int = 60 306 | ) -> None: 307 | tasks = [ 308 | self.__trade_wait_for_condition__( 309 | trade, 310 | lambda trade: trade.isDone(), 311 | timetout, 312 | ) 313 | for trade in trades 314 | ] 315 | results = await log.track_async( 316 | tasks, description="Waiting for orders to complete..." 317 | ) 318 | if not all(results): 319 | incomplete_trades = [ 320 | f"{trade.contract.symbol} (OrderId: {trade.order.orderId})" 321 | for i, trade in enumerate(trades) 322 | if not results[i] 323 | ] 324 | log.warning( 325 | f"Timeout waiting for orders to complete: {', '.join(incomplete_trades)}" 326 | ) 327 | 328 | async def __trade_wait_for_condition__( 329 | self, trade: Trade, condition: Callable[[Trade], bool], timeout: float 330 | ) -> bool: 331 | # perform an initial check first just incase Trade is in the correct condition 332 | # and onStatusEvent never gets triggered 333 | if condition(trade): 334 | return True 335 | 336 | event = asyncio.Event() 337 | 338 | def onStatusEvent(trade: Trade) -> None: 339 | if condition(trade): 340 | event.set() 341 | 342 | trade.statusEvent += onStatusEvent 343 | try: 344 | await asyncio.wait_for(event.wait(), timeout=timeout) 345 | return True 346 | except asyncio.TimeoutError: 347 | return False 348 | finally: 349 | trade.statusEvent -= onStatusEvent 350 | 351 | def __ticker_field_handler__( 352 | self, ticker_field: TickerField 353 | ) -> Callable[[Ticker], Awaitable[bool]]: 354 | if ticker_field == TickerField.MIDPOINT: 355 | return self.__wait_for_midpoint_price__ 356 | if ticker_field == TickerField.MARKET_PRICE: 357 | return self.__wait_for_market_price__ 358 | if ticker_field == TickerField.GREEKS: 359 | return self.__wait_for_greeks__ 360 | if ticker_field == TickerField.OPEN_INTEREST: 361 | return self.__wait_for_open_interest__ 362 | -------------------------------------------------------------------------------- /thetagang/log.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, Coroutine, Iterable, Iterator, List, Union 3 | 4 | from annotated_types import T 5 | from rich.console import Console 6 | from rich.panel import Panel 7 | from rich.progress import ( 8 | BarColumn, 9 | MofNCompleteColumn, 10 | Progress, 11 | TaskProgressColumn, 12 | TextColumn, 13 | ) 14 | from rich.table import Table 15 | from rich.theme import Theme 16 | 17 | custom_theme = Theme( 18 | { 19 | "notice": "green", 20 | "warning": "yellow", 21 | "error": "red", 22 | } 23 | ) 24 | 25 | console: Console = Console(theme=custom_theme) 26 | 27 | 28 | def info(text: str) -> None: 29 | console.print(text) 30 | 31 | 32 | def notice(text: str) -> None: 33 | console.print(text, style="notice") 34 | 35 | 36 | def warning(text: str) -> None: 37 | console.print(text, style="warning") 38 | 39 | 40 | def error(text: str) -> None: 41 | console.print_exception() 42 | console.print(text, style="red") 43 | 44 | 45 | def print(content: Union[Panel, Table]) -> None: 46 | console.print(content) 47 | 48 | 49 | async def track_async(tasks: List[Coroutine[Any, Any, T]], description: str) -> List[T]: 50 | results = [] 51 | total_tasks = len(tasks) 52 | 53 | progress = Progress( 54 | TextColumn("{task.description: <80}"), 55 | BarColumn(), 56 | MofNCompleteColumn(), 57 | TaskProgressColumn(), 58 | ) 59 | 60 | with progress: 61 | progress_task = progress.add_task(description, total=total_tasks) 62 | for coro in asyncio.as_completed(tasks): 63 | result = await coro 64 | results.append(result) 65 | progress.advance(progress_task) 66 | 67 | return results 68 | 69 | 70 | def track(sequence: Iterable[T], description: str, total: int) -> Iterator[T]: 71 | progress = Progress( 72 | TextColumn("{task.description: <80}"), 73 | BarColumn(), 74 | MofNCompleteColumn(), 75 | TaskProgressColumn(), 76 | ) 77 | 78 | with progress: 79 | task_id = progress.add_task(description, total=total) 80 | for item in sequence: 81 | yield item 82 | progress.advance(task_id) 83 | -------------------------------------------------------------------------------- /thetagang/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import click 4 | import click_log 5 | 6 | logger = logging.getLogger(__name__) 7 | click_log.basic_config(logger) # type: ignore 8 | 9 | 10 | CONTEXT_SETTINGS = dict( 11 | help_option_names=["-h", "--help"], auto_envvar_prefix="THETAGANG" 12 | ) 13 | 14 | 15 | @click.command(context_settings=CONTEXT_SETTINGS) 16 | @click_log.simple_verbosity_option(logger) # type: ignore 17 | @click.option( 18 | "-c", 19 | "--config", 20 | help="Path to toml config", 21 | required=True, 22 | default="thetagang.toml", 23 | type=click.Path(exists=True, readable=True), 24 | ) 25 | @click.option( 26 | "--without-ibc", 27 | is_flag=True, 28 | help="Run without IBC. Enable this if you want to run the TWS " 29 | "gateway yourself, without having ThetaGang manage it for you.", 30 | ) 31 | @click.option( 32 | "--dry-run", 33 | is_flag=True, 34 | help="Perform a dry run. This will display the the orders without sending any live trades.", 35 | ) 36 | def cli(config: str, without_ibc: bool, dry_run: bool) -> None: 37 | """ThetaGang is an IBKR bot for collecting money. 38 | 39 | You can configure this tool by supplying a toml configuration file. 40 | There's a sample config on GitHub, here: 41 | https://github.com/brndnmtthws/thetagang/blob/main/thetagang.toml 42 | """ 43 | 44 | from .thetagang import start 45 | 46 | start(config, without_ibc, dry_run) 47 | -------------------------------------------------------------------------------- /thetagang/options.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | 3 | 4 | def contract_date_to_datetime(expiration: str) -> datetime: 5 | if len(expiration) == 8: 6 | return datetime.strptime(expiration, "%Y%m%d") 7 | else: 8 | return datetime.strptime(expiration, "%Y%m") 9 | 10 | 11 | def option_dte(expiration: str) -> int: 12 | dte = contract_date_to_datetime(expiration).date() - date.today() 13 | return dte.days 14 | -------------------------------------------------------------------------------- /thetagang/orders.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from ib_async import Contract, LimitOrder 4 | from rich import box 5 | from rich.pretty import Pretty 6 | from rich.table import Table 7 | 8 | from thetagang import log 9 | from thetagang.fmt import dfmt, ifmt 10 | 11 | 12 | class Orders: 13 | def __init__(self) -> None: 14 | self.__records: List[Tuple[Contract, LimitOrder]] = [] 15 | 16 | def add_order(self, contract: Contract, order: LimitOrder) -> None: 17 | self.__records.append((contract, order)) 18 | 19 | def records(self) -> List[Tuple[Contract, LimitOrder]]: 20 | return self.__records 21 | 22 | def print_summary(self) -> None: 23 | if not self.__records: 24 | return 25 | 26 | table = Table( 27 | title="Order Summary", show_lines=True, box=box.MINIMAL_HEAVY_HEAD 28 | ) 29 | table.add_column("Symbol") 30 | table.add_column("Exchange") 31 | table.add_column("Contract") 32 | table.add_column("Action") 33 | table.add_column("Price") 34 | table.add_column("Qty") 35 | 36 | for contract, order in self.__records: 37 | table.add_row( 38 | contract.symbol, 39 | contract.exchange, 40 | Pretty(contract, indent_size=2), 41 | order.action, 42 | dfmt(order.lmtPrice), 43 | ifmt(int(order.totalQuantity)), 44 | ) 45 | 46 | log.print(table) 47 | -------------------------------------------------------------------------------- /thetagang/test_config.py: -------------------------------------------------------------------------------- 1 | from polyfactory.factories.pydantic_factory import ModelFactory 2 | 3 | from thetagang.config import ( 4 | AccountConfig, 5 | Config, 6 | OptionChainsConfig, 7 | RollWhenConfig, 8 | SymbolConfig, 9 | TargetConfig, 10 | ) 11 | 12 | 13 | class TargetConfigFactory(ModelFactory[TargetConfig]): ... 14 | 15 | 16 | class TargetConfigPutsFactory(ModelFactory[TargetConfig.Puts]): ... 17 | 18 | 19 | class TargetConfigCallsFactory(ModelFactory[TargetConfig.Calls]): ... 20 | 21 | 22 | class RollWhenConfigFactory(ModelFactory[RollWhenConfig]): ... 23 | 24 | 25 | class OptionChainsConfigFactory(ModelFactory[OptionChainsConfig]): ... 26 | 27 | 28 | class AccountConfigFactory(ModelFactory[AccountConfig]): ... 29 | 30 | 31 | class SymbolConfigFactory(ModelFactory[SymbolConfig]): ... 32 | 33 | 34 | class SymbolConfigPutsFactory(ModelFactory[SymbolConfig.Puts]): ... 35 | 36 | 37 | class SymbolConfigCallsFactory(ModelFactory[SymbolConfig.Calls]): ... 38 | 39 | 40 | class ConfigFactory(ModelFactory[Config]): ... 41 | 42 | 43 | def test_trading_is_allowed_with_symbol_no_trading() -> None: 44 | config = ConfigFactory.build( 45 | symbols={"AAPL": SymbolConfigFactory.build(no_trading=True, weight=1.0)}, 46 | ) 47 | assert not config.trading_is_allowed("AAPL") 48 | 49 | 50 | def test_trading_is_allowed_with_symbol_trading_allowed() -> None: 51 | config = ConfigFactory.build( 52 | symbols={"AAPL": SymbolConfigFactory.build(no_trading=False, weight=1.0)}, 53 | ) 54 | assert config.trading_is_allowed("AAPL") 55 | -------------------------------------------------------------------------------- /thetagang/test_exchange_hours.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from unittest.mock import patch 3 | 4 | from thetagang.config import ActionWhenClosedEnum, ExchangeHoursConfig 5 | from thetagang.exchange_hours import determine_action, waited_for_open 6 | 7 | 8 | def test_determine_action_continue_when_closed(): 9 | config = ExchangeHoursConfig( 10 | exchange="XNYS", 11 | delay_after_open=0, 12 | delay_before_close=0, 13 | action_when_closed=ActionWhenClosedEnum.continue_, 14 | ) 15 | now = datetime(2025, 1, 21, 12, 0, tzinfo=timezone.utc) 16 | 17 | result = determine_action(config, now) 18 | assert result == "continue" 19 | 20 | 21 | def test_determine_action_in_open_window(): 22 | config = ExchangeHoursConfig( 23 | exchange="XNYS", 24 | delay_after_open=60, 25 | delay_before_close=60, 26 | action_when_closed=ActionWhenClosedEnum.continue_, 27 | ) 28 | now = datetime(2025, 1, 21, 15, 0, tzinfo=timezone.utc) 29 | 30 | result = determine_action(config, now) 31 | assert result == "continue" 32 | 33 | 34 | def test_determine_action_after_close(): 35 | config = ExchangeHoursConfig( 36 | exchange="XNYS", 37 | delay_after_open=60, 38 | delay_before_close=60, 39 | action_when_closed=ActionWhenClosedEnum.exit, 40 | ) 41 | now = datetime(2025, 1, 21, 21, 0, tzinfo=timezone.utc) 42 | 43 | result = determine_action(config, now) 44 | assert result == "exit" 45 | 46 | 47 | def test_determine_action_session_closed_wait(): 48 | config = ExchangeHoursConfig( 49 | exchange="XNYS", 50 | delay_after_open=60, 51 | delay_before_close=60, 52 | action_when_closed=ActionWhenClosedEnum.wait, 53 | ) 54 | now = datetime(2025, 1, 21, 14, 29, tzinfo=timezone.utc) 55 | 56 | result = determine_action(config, now) 57 | assert result == "wait" 58 | 59 | 60 | @patch("thetagang.exchange_hours.time.sleep") 61 | def test_waited_for_open_under_max(mock_sleep): 62 | config = ExchangeHoursConfig( 63 | exchange="XNYS", 64 | delay_after_open=60, 65 | delay_before_close=60, 66 | action_when_closed=ActionWhenClosedEnum.wait, 67 | max_wait_until_open=600, 68 | ) 69 | now = datetime(2025, 1, 21, 14, 29, tzinfo=timezone.utc) 70 | 71 | assert waited_for_open(config, now) is True 72 | mock_sleep.assert_called_once() 73 | 74 | 75 | @patch("thetagang.exchange_hours.time.sleep") 76 | def test_waited_for_open_exceeds_max(mock_sleep): 77 | config = ExchangeHoursConfig( 78 | exchange="XNYS", 79 | delay_after_open=60, 80 | delay_before_close=60, 81 | action_when_closed=ActionWhenClosedEnum.wait, 82 | max_wait_until_open=30, 83 | ) 84 | now = datetime(2025, 1, 21, 14, 0, tzinfo=timezone.utc) 85 | 86 | assert waited_for_open(config, now) is False 87 | mock_sleep.assert_not_called() 88 | 89 | 90 | @patch("thetagang.exchange_hours.time.sleep") 91 | def test_waited_for_open_negative_difference(mock_sleep): 92 | config = ExchangeHoursConfig( 93 | exchange="XNYS", 94 | delay_after_open=60, 95 | delay_before_close=60, 96 | action_when_closed=ActionWhenClosedEnum.wait, 97 | max_wait_until_open=300, 98 | ) 99 | # 'now' is already after the start time 100 | now = datetime(2025, 1, 21, 15, 0, tzinfo=timezone.utc) 101 | 102 | # seconds_until_start will be negative, but code checks if it's < max_wait_until_open 103 | assert waited_for_open(config, now) is True 104 | mock_sleep.assert_called_once() 105 | -------------------------------------------------------------------------------- /thetagang/test_ibkr.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | from ib_async import IB, Contract, Order, OrderStatus, Stock, Ticker, Trade 5 | 6 | from thetagang import log 7 | from thetagang.ibkr import IBKR, RequiredFieldValidationError, TickerField 8 | 9 | # Mark all tests in this module as asyncio 10 | pytestmark = pytest.mark.asyncio 11 | 12 | 13 | @pytest.fixture 14 | def mock_ib(mocker): 15 | """Fixture to create a mock IB object.""" 16 | mock = mocker.Mock(spec=IB) 17 | # Add the missing event attribute needed by IBKR.__init__ 18 | # Make it a simple mock that accepts the += operation. 19 | mock.orderStatusEvent = mocker.Mock() 20 | mock.orderStatusEvent.__iadd__ = mocker.Mock( 21 | return_value=None 22 | ) # Allow += operation 23 | return mock 24 | 25 | 26 | @pytest.fixture 27 | def ibkr(mock_ib): 28 | """Fixture to create an IBKR instance with a mock IB.""" 29 | return IBKR(ib=mock_ib, api_response_wait_time=1, default_order_exchange="SMART") 30 | 31 | 32 | @pytest.fixture 33 | def mock_ticker(mocker): 34 | """Fixture to create a mock Ticker object.""" 35 | ticker = mocker.Mock(spec=Ticker) 36 | ticker.contract = mocker.Mock(spec=Contract) 37 | ticker.contract.localSymbol = "TEST" 38 | ticker.contract.symbol = "TEST" 39 | return ticker 40 | 41 | 42 | @pytest.fixture 43 | def mock_trade(mocker): 44 | """Fixture to create a mock Trade object.""" 45 | trade = mocker.Mock(spec=Trade) 46 | trade.contract = mocker.Mock(spec=Contract) 47 | trade.contract.symbol = "TEST" 48 | trade.order = mocker.Mock(spec=Order) 49 | trade.order.orderId = 123 50 | trade.orderStatus = mocker.Mock(spec=OrderStatus) 51 | return trade 52 | 53 | 54 | # --- Tests for get_ticker_for_contract --- 55 | 56 | 57 | async def test_get_ticker_for_contract_success(ibkr, mock_ib, mock_ticker, mocker): 58 | """Test get_ticker_for_contract when all waits succeed.""" 59 | mocker.patch.object( 60 | ibkr, "__market_data_streaming_handler__", return_value=mock_ticker 61 | ) 62 | # Mock the internal wait methods to return True (success) 63 | mocker.patch.object( 64 | ibkr, "__ticker_wait_for_condition__", return_value=asyncio.Future() 65 | ) 66 | ibkr.__ticker_wait_for_condition__.return_value.set_result(True) 67 | 68 | contract = Stock("TEST", "SMART", "USD") 69 | result = await ibkr.get_ticker_for_contract( 70 | contract, 71 | required_fields=[TickerField.MARKET_PRICE], 72 | optional_fields=[TickerField.MIDPOINT], 73 | ) 74 | 75 | assert result == mock_ticker 76 | # Check that the wait was attempted (indirectly, via the handler logic patch) 77 | ibkr.__market_data_streaming_handler__.assert_awaited_once() 78 | 79 | 80 | async def test_get_ticker_for_contract_required_timeout( 81 | ibkr, mock_ib, mock_ticker, mocker 82 | ): 83 | """Test get_ticker_for_contract when a required field wait times out.""" 84 | # Mock ib methods called by __market_data_streaming_handler__ 85 | mock_ib.qualifyContractsAsync = mocker.AsyncMock() 86 | mock_ib.reqMktData = mocker.Mock(return_value=mock_ticker) 87 | 88 | # Mock __ticker_field_handler__ to return appropriate async functions 89 | async def succeed_wait(ticker): 90 | return True 91 | 92 | async def fail_wait(ticker): 93 | return False 94 | 95 | def mock_handler_logic(field): 96 | if field == TickerField.MARKET_PRICE: # Required field 97 | return fail_wait 98 | elif field == TickerField.MIDPOINT: # Optional field 99 | return succeed_wait 100 | else: 101 | pytest.fail(f"Unexpected field: {field}") 102 | 103 | mocker.patch.object( 104 | ibkr, "__ticker_field_handler__", side_effect=mock_handler_logic 105 | ) 106 | 107 | contract = Stock("TEST", "SMART", "USD") 108 | with pytest.raises(RequiredFieldValidationError) as excinfo: 109 | await ibkr.get_ticker_for_contract( 110 | contract, 111 | required_fields=[TickerField.MARKET_PRICE], 112 | optional_fields=[TickerField.MIDPOINT], 113 | ) 114 | 115 | assert "Required fields timed out" in str(excinfo.value) 116 | assert "MARKET_PRICE" in str(excinfo.value) 117 | # Ensure the handler was called for both fields 118 | assert ibkr.__ticker_field_handler__.call_count == 2 119 | 120 | 121 | async def test_get_ticker_for_contract_optional_timeout( 122 | ibkr, mock_ib, mock_ticker, mocker 123 | ): 124 | """Test get_ticker_for_contract when an optional field wait times out.""" 125 | # Mock ib methods called by __market_data_streaming_handler__ 126 | mock_ib.qualifyContractsAsync = mocker.AsyncMock() 127 | mock_ib.reqMktData = mocker.Mock(return_value=mock_ticker) 128 | mock_log_warning = mocker.patch.object(log, "warning") 129 | 130 | # Mock __ticker_field_handler__ to return appropriate async functions 131 | async def succeed_wait(ticker): 132 | return True 133 | 134 | async def fail_wait(ticker): 135 | return False 136 | 137 | def mock_handler_logic(field): 138 | if field == TickerField.MARKET_PRICE: # Required field 139 | return succeed_wait 140 | elif field == TickerField.MIDPOINT: # Optional field 141 | return fail_wait 142 | else: 143 | pytest.fail(f"Unexpected field: {field}") 144 | 145 | mocker.patch.object( 146 | ibkr, "__ticker_field_handler__", side_effect=mock_handler_logic 147 | ) 148 | 149 | contract = Stock("TEST", "SMART", "USD") 150 | result = await ibkr.get_ticker_for_contract( 151 | contract, 152 | required_fields=[TickerField.MARKET_PRICE], 153 | optional_fields=[TickerField.MIDPOINT], 154 | ) 155 | 156 | assert result == mock_ticker 157 | # Ensure the handler was called for both fields 158 | assert ibkr.__ticker_field_handler__.call_count == 2 159 | mock_log_warning.assert_called_once() 160 | assert "Optional fields timed out" in mock_log_warning.call_args[0][0] 161 | assert "MIDPOINT" in mock_log_warning.call_args[0][0] 162 | 163 | 164 | # --- Tests for wait_for_submitting_orders --- 165 | 166 | 167 | async def test_wait_for_submitting_orders_success(ibkr, mock_trade, mocker): 168 | """Test wait_for_submitting_orders when all waits succeed.""" 169 | mocker.patch.object( 170 | ibkr, "__trade_wait_for_condition__", return_value=asyncio.Future() 171 | ) 172 | ibkr.__trade_wait_for_condition__.return_value.set_result(True) 173 | mocker.patch.object( 174 | log, "track_async", return_value=[True, True] 175 | ) # Simulate track_async returning results 176 | 177 | trades = [mock_trade, mock_trade] 178 | await ibkr.wait_for_submitting_orders(trades) 179 | 180 | assert ibkr.__trade_wait_for_condition__.call_count == 2 181 | 182 | 183 | async def test_wait_for_submitting_orders_timeout(ibkr, mock_trade, mocker): 184 | """Test wait_for_submitting_orders when a wait times out.""" 185 | 186 | # Mock the wait to return False for the second trade 187 | async def mock_wait(*args, **kwargs): 188 | # Simulate different results based on call order or trade details if needed 189 | # Simple case: first succeeds, second fails 190 | if ibkr.__trade_wait_for_condition__.call_count == 1: 191 | return True 192 | else: 193 | return False 194 | 195 | mocker.patch.object(ibkr, "__trade_wait_for_condition__", side_effect=mock_wait) 196 | # Mock track_async to return the results from our side_effect 197 | mocker.patch.object(log, "track_async", return_value=[True, False]) 198 | 199 | trades = [mocker.Mock(spec=Trade), mocker.Mock(spec=Trade)] 200 | trades[0].contract = mocker.Mock(spec=Contract) 201 | trades[0].contract.symbol = "PASS" 202 | trades[0].order = mocker.Mock(spec=Order) 203 | trades[0].order.orderId = 1 204 | trades[1].contract = mocker.Mock(spec=Contract) 205 | trades[1].contract.symbol = "FAIL" 206 | trades[1].order = mocker.Mock(spec=Order) 207 | trades[1].order.orderId = 2 208 | 209 | with pytest.raises(RuntimeError) as excinfo: 210 | await ibkr.wait_for_submitting_orders(trades) 211 | 212 | assert "Timeout waiting for orders to submit" in str(excinfo.value) 213 | assert "FAIL (OrderId: 2)" in str(excinfo.value) 214 | assert "PASS (OrderId: 1)" not in str(excinfo.value) 215 | assert ibkr.__trade_wait_for_condition__.call_count == 2 216 | 217 | 218 | # --- Tests for wait_for_orders_complete --- 219 | 220 | 221 | async def test_wait_for_orders_complete_success(ibkr, mock_trade, mocker): 222 | """Test wait_for_orders_complete when all waits succeed.""" 223 | mocker.patch.object( 224 | ibkr, "__trade_wait_for_condition__", return_value=asyncio.Future() 225 | ) 226 | ibkr.__trade_wait_for_condition__.return_value.set_result(True) 227 | mocker.patch.object(log, "track_async", return_value=[True, True]) 228 | mock_log_warning = mocker.patch.object(log, "warning") 229 | 230 | trades = [mock_trade, mock_trade] 231 | await ibkr.wait_for_orders_complete(trades) 232 | 233 | assert ibkr.__trade_wait_for_condition__.call_count == 2 234 | mock_log_warning.assert_not_called() 235 | 236 | 237 | async def test_wait_for_orders_complete_timeout(ibkr, mock_trade, mocker): 238 | """Test wait_for_orders_complete when a wait times out.""" 239 | 240 | # Mock the wait to return False for the second trade 241 | async def mock_wait(*args, **kwargs): 242 | if ibkr.__trade_wait_for_condition__.call_count == 1: 243 | return True 244 | else: 245 | return False 246 | 247 | mocker.patch.object(ibkr, "__trade_wait_for_condition__", side_effect=mock_wait) 248 | mocker.patch.object(log, "track_async", return_value=[True, False]) 249 | mock_log_warning = mocker.patch.object(log, "warning") 250 | 251 | trades = [mocker.Mock(spec=Trade), mocker.Mock(spec=Trade)] 252 | trades[0].contract = mocker.Mock(spec=Contract) 253 | trades[0].contract.symbol = "PASS" 254 | trades[0].order = mocker.Mock(spec=Order) 255 | trades[0].order.orderId = 1 256 | trades[1].contract = mocker.Mock(spec=Contract) 257 | trades[1].contract.symbol = "FAIL" 258 | trades[1].order = mocker.Mock(spec=Order) 259 | trades[1].order.orderId = 2 260 | 261 | await ibkr.wait_for_orders_complete(trades) 262 | 263 | assert ibkr.__trade_wait_for_condition__.call_count == 2 264 | mock_log_warning.assert_called_once() 265 | assert "Timeout waiting for orders to complete" in mock_log_warning.call_args[0][0] 266 | assert "FAIL (OrderId: 2)" in mock_log_warning.call_args[0][0] 267 | assert "PASS (OrderId: 1)" not in mock_log_warning.call_args[0][0] 268 | -------------------------------------------------------------------------------- /thetagang/test_trades.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | from ib_async import Contract, LimitOrder, Trade 5 | 6 | from thetagang.ibkr import IBKR 7 | from thetagang.trades import Trades 8 | 9 | 10 | @pytest.fixture 11 | def mock_ibkr() -> Mock: 12 | return Mock(spec=IBKR) 13 | 14 | 15 | @pytest.fixture 16 | def trades(mock_ibkr: Mock) -> Trades: 17 | return Trades(mock_ibkr) 18 | 19 | 20 | @pytest.fixture 21 | def mock_contract() -> Mock: 22 | return Mock(spec=Contract) 23 | 24 | 25 | @pytest.fixture 26 | def mock_order() -> Mock: 27 | return Mock(spec=LimitOrder) 28 | 29 | 30 | @pytest.fixture 31 | def mock_trade() -> Mock: 32 | return Mock(spec=Trade) 33 | 34 | 35 | def test_submit_order_successful( 36 | trades: Trades, 37 | mock_contract: Mock, 38 | mock_order: Mock, 39 | mock_trade: Mock, 40 | mock_ibkr: Mock, 41 | ) -> None: 42 | mock_ibkr.place_order.return_value = mock_trade 43 | trades.submit_order(mock_contract, mock_order) 44 | mock_ibkr.place_order.assert_called_once_with(mock_contract, mock_order) 45 | assert len(trades.records()) == 1 46 | assert trades.records()[0] == mock_trade 47 | 48 | 49 | def test_submit_order_with_replacement( 50 | trades: Trades, 51 | mock_contract: Mock, 52 | mock_order: Mock, 53 | mock_trade: Mock, 54 | mock_ibkr: Mock, 55 | ) -> None: 56 | mock_ibkr.place_order.return_value = mock_trade 57 | trades.submit_order(mock_contract, mock_order) 58 | new_trade = Mock(spec=Trade) 59 | mock_ibkr.place_order.return_value = new_trade 60 | trades.submit_order(mock_contract, mock_order, idx=0) 61 | assert len(trades.records()) == 1 62 | assert trades.records()[0] == new_trade 63 | 64 | 65 | def test_submit_order_failure( 66 | trades: Trades, mock_contract: Mock, mock_order: Mock, mock_ibkr: Mock 67 | ) -> None: 68 | mock_ibkr.place_order.side_effect = RuntimeError("Failed to place order") 69 | trades.submit_order(mock_contract, mock_order) 70 | mock_ibkr.place_order.assert_called_once_with(mock_contract, mock_order) 71 | assert len(trades.records()) == 0 72 | 73 | 74 | def test_submit_order_multiple_trades( 75 | trades: Trades, 76 | mock_contract: Mock, 77 | mock_order: Mock, 78 | mock_trade: Mock, 79 | mock_ibkr: Mock, 80 | ) -> None: 81 | mock_ibkr.place_order.return_value = mock_trade 82 | trades.submit_order(mock_contract, mock_order) 83 | trades.submit_order(mock_contract, mock_order) 84 | assert mock_ibkr.place_order.call_count == 2 85 | assert len(trades.records()) == 2 86 | -------------------------------------------------------------------------------- /thetagang/test_util.py: -------------------------------------------------------------------------------- 1 | import math 2 | from datetime import date, timedelta 3 | 4 | from ib_async import Option, Order, PortfolioItem 5 | from ib_async.contract import Stock 6 | 7 | from thetagang.test_config import ( 8 | ConfigFactory, 9 | SymbolConfigFactory, 10 | SymbolConfigPutsFactory, 11 | TargetConfigCallsFactory, 12 | TargetConfigFactory, 13 | TargetConfigPutsFactory, 14 | ) 15 | from thetagang.util import ( 16 | calculate_net_short_positions, 17 | position_pnl, 18 | weighted_avg_long_strike, 19 | weighted_avg_short_strike, 20 | would_increase_spread, 21 | ) 22 | 23 | 24 | def test_position_pnl() -> None: 25 | qqq_put = PortfolioItem( 26 | contract=Option( 27 | conId=397556522, 28 | symbol="QQQ", 29 | lastTradeDateOrContractMonth="20201218", 30 | strike=300.0, 31 | right="P", 32 | multiplier="100", 33 | primaryExchange="AMEX", 34 | currency="USD", 35 | localSymbol="QQQ 201218P00300000", 36 | tradingClass="QQQ", 37 | ), 38 | position=-1.0, 39 | marketPrice=4.1194396, 40 | marketValue=-411.94, 41 | averageCost=222.4293, 42 | unrealizedPNL=-189.51, 43 | realizedPNL=0.0, 44 | account="DU2962946", 45 | ) 46 | assert round(position_pnl(qqq_put), 2) == -0.85 47 | 48 | spy = PortfolioItem( 49 | contract=Stock( 50 | conId=756733, 51 | symbol="SPY", 52 | right="0", 53 | primaryExchange="ARCA", 54 | currency="USD", 55 | localSymbol="SPY", 56 | tradingClass="SPY", 57 | ), 58 | position=100.0, 59 | marketPrice=365.4960022, 60 | marketValue=36549.6, 61 | averageCost=368.42, 62 | unrealizedPNL=-292.4, 63 | realizedPNL=0.0, 64 | account="DU2962946", 65 | ) 66 | assert round(position_pnl(spy), 4) == -0.0079 67 | 68 | spy_call = PortfolioItem( 69 | contract=Option( 70 | conId=454208258, 71 | symbol="SPY", 72 | lastTradeDateOrContractMonth="20201214", 73 | strike=373.0, 74 | right="C", 75 | multiplier="100", 76 | primaryExchange="AMEX", 77 | currency="USD", 78 | localSymbol="SPY 201214C00373000", 79 | tradingClass="SPY", 80 | ), 81 | position=-1.0, 82 | marketPrice=0.08, 83 | marketValue=-8.0, 84 | averageCost=96.422, 85 | unrealizedPNL=88.42, 86 | realizedPNL=0.0, 87 | account="DU2962946", 88 | ) 89 | assert round(position_pnl(spy_call), 2) == 0.92 90 | 91 | spy_put = PortfolioItem( 92 | contract=Option( 93 | conId=458705534, 94 | symbol="SPY", 95 | lastTradeDateOrContractMonth="20210122", 96 | strike=352.5, 97 | right="P", 98 | multiplier="100", 99 | primaryExchange="AMEX", 100 | currency="USD", 101 | localSymbol="SPY 210122P00352500", 102 | tradingClass="SPY", 103 | ), 104 | position=-1.0, 105 | marketPrice=5.96710015, 106 | marketValue=-596.71, 107 | averageCost=528.9025, 108 | unrealizedPNL=-67.81, 109 | realizedPNL=0.0, 110 | account="DU2962946", 111 | ) 112 | assert round(position_pnl(spy_put), 2) == -0.13 113 | 114 | 115 | def test_get_delta() -> None: 116 | target_config = TargetConfigFactory.build(delta=0.5, puts=None, calls=None) 117 | symbol_config = SymbolConfigFactory.build( 118 | weight=1.0, delta=None, puts=None, calls=None 119 | ) 120 | config = ConfigFactory.build(target=target_config, symbols={"SPY": symbol_config}) 121 | assert 0.5 == config.get_target_delta("SPY", "P") 122 | assert 0.5 == config.get_target_delta("SPY", "C") 123 | 124 | target_config = TargetConfigFactory.build( 125 | delta=0.5, puts=TargetConfigPutsFactory.build(delta=0.4), calls=None 126 | ) 127 | symbol_config = SymbolConfigFactory.build( 128 | weight=1.0, delta=None, puts=None, calls=None 129 | ) 130 | config = ConfigFactory.build(target=target_config, symbols={"SPY": symbol_config}) 131 | assert 0.4 == config.get_target_delta("SPY", "P") 132 | 133 | target_config = TargetConfigFactory.build( 134 | delta=0.5, calls=TargetConfigCallsFactory.build(delta=0.4), puts=None 135 | ) 136 | symbol_config = SymbolConfigFactory.build( 137 | weight=1.0, delta=None, puts=None, calls=None 138 | ) 139 | config = ConfigFactory.build(target=target_config, symbols={"SPY": symbol_config}) 140 | assert 0.5 == config.get_target_delta("SPY", "P") 141 | 142 | target_config = TargetConfigFactory.build( 143 | delta=0.5, calls=TargetConfigCallsFactory.build(delta=0.4), puts=None 144 | ) 145 | symbol_config = SymbolConfigFactory.build( 146 | weight=1.0, delta=None, puts=None, calls=None 147 | ) 148 | config = ConfigFactory.build(target=target_config, symbols={"SPY": symbol_config}) 149 | assert 0.4 == config.get_target_delta("SPY", "C") 150 | 151 | target_config = TargetConfigFactory.build( 152 | delta=0.5, calls=TargetConfigCallsFactory.build(delta=0.4), puts=None 153 | ) 154 | symbol_config = SymbolConfigFactory.build( 155 | weight=1.0, delta=0.3, puts=None, calls=None 156 | ) 157 | config = ConfigFactory.build(target=target_config, symbols={"SPY": symbol_config}) 158 | assert 0.3 == config.get_target_delta("SPY", "C") 159 | 160 | target_config = TargetConfigFactory.build( 161 | delta=0.5, calls=TargetConfigCallsFactory.build(delta=0.4), puts=None 162 | ) 163 | symbol_config = SymbolConfigFactory.build( 164 | weight=1.0, delta=0.3, puts=SymbolConfigPutsFactory.build(delta=0.2), calls=None 165 | ) 166 | config = ConfigFactory.build(target=target_config, symbols={"SPY": symbol_config}) 167 | assert 0.3 == config.get_target_delta("SPY", "C") 168 | 169 | target_config = TargetConfigFactory.build( 170 | delta=0.5, calls=TargetConfigCallsFactory.build(delta=0.4), puts=None 171 | ) 172 | symbol_config = SymbolConfigFactory.build( 173 | weight=1.0, delta=0.3, puts=SymbolConfigPutsFactory.build(delta=0.2), calls=None 174 | ) 175 | config = ConfigFactory.build(target=target_config, symbols={"SPY": symbol_config}) 176 | assert 0.2 == config.get_target_delta("SPY", "P") 177 | 178 | 179 | def con(dte: str, strike: float, right: str, position: float) -> PortfolioItem: 180 | return PortfolioItem( 181 | contract=Option( 182 | conId=458705534, 183 | symbol="SPY", 184 | lastTradeDateOrContractMonth=dte, 185 | strike=strike, 186 | right=right, 187 | multiplier="100", 188 | primaryExchange="AMEX", 189 | currency="USD", 190 | localSymbol="SPY 210122P00352500", 191 | tradingClass="SPY", 192 | ), 193 | position=position, 194 | marketPrice=5.96710015, 195 | marketValue=-596.71, 196 | averageCost=528.9025, 197 | unrealizedPNL=-67.81, 198 | realizedPNL=0.0, 199 | account="DU2962946", 200 | ) 201 | 202 | 203 | def test_calculate_net_short_positions() -> None: 204 | today = date.today() 205 | exp3dte = (today + timedelta(days=3)).strftime("%Y%m%d") 206 | exp30dte = (today + timedelta(days=30)).strftime("%Y%m%d") 207 | exp90dte = (today + timedelta(days=90)).strftime("%Y%m%d") 208 | 209 | assert 1 == calculate_net_short_positions([con(exp3dte, 69, "P", -1)], "P") 210 | 211 | assert 1 == calculate_net_short_positions( 212 | [con(exp3dte, 69, "P", -1), con(exp3dte, 69, "C", 1)], "P" 213 | ) 214 | 215 | assert 0 == calculate_net_short_positions( 216 | [con(exp3dte, 69, "P", -1), con(exp3dte, 69, "C", 1)], "C" 217 | ) 218 | 219 | assert 0 == calculate_net_short_positions( 220 | [con(exp3dte, 69, "C", -1), con(exp3dte, 69, "C", 1)], "C" 221 | ) 222 | 223 | assert 0 == calculate_net_short_positions( 224 | [ 225 | con(exp3dte, 69, "C", -1), 226 | con(exp3dte, 69, "C", 1), 227 | con(exp30dte, 69, "C", 1), 228 | ], 229 | "C", 230 | ) 231 | 232 | assert 0 == calculate_net_short_positions( 233 | [ 234 | con(exp3dte, 69, "C", -1), 235 | con(exp3dte, 69, "P", -1), 236 | con(exp3dte, 69, "C", 1), 237 | con(exp30dte, 69, "C", 1), 238 | ], 239 | "C", 240 | ) 241 | 242 | assert 0 == calculate_net_short_positions( 243 | [ 244 | con(exp3dte, 69, "C", -1), 245 | con(exp3dte, 69, "P", -1), 246 | con(exp3dte, 69, "C", 1), 247 | con(exp30dte, 70, "C", 1), 248 | ], 249 | "C", 250 | ) 251 | 252 | assert 1 == calculate_net_short_positions( 253 | [ 254 | con(exp3dte, 69, "C", -1), 255 | con(exp3dte, 69, "C", -1), 256 | con(exp3dte, 69, "C", 1), 257 | con(exp30dte, 70, "C", 1), 258 | ], 259 | "C", 260 | ) 261 | 262 | assert 2 == calculate_net_short_positions( 263 | [ 264 | con(exp3dte, 69, "C", -1), 265 | con(exp3dte, 69, "C", -1), 266 | con(exp3dte, 69, "P", 1), 267 | con(exp30dte, 69, "P", 1), 268 | ], 269 | "C", 270 | ) 271 | 272 | assert 0 == calculate_net_short_positions( 273 | [ 274 | con(exp3dte, 69, "C", -1), 275 | con(exp3dte, 69, "C", -1), 276 | con(exp3dte, 69, "C", 1), 277 | con(exp30dte, 69, "C", 5), 278 | ], 279 | "C", 280 | ) 281 | 282 | assert 0 == calculate_net_short_positions( 283 | [ 284 | con(exp3dte, 69, "C", -1), 285 | con(exp30dte, 69, "C", -1), 286 | con(exp3dte, 69, "C", 1), 287 | con(exp30dte, 69, "C", 5), 288 | ], 289 | "C", 290 | ) 291 | 292 | assert 0 == calculate_net_short_positions( 293 | [ 294 | con(exp3dte, 69, "P", -1), 295 | con(exp30dte, 69, "P", -1), 296 | con(exp3dte, 69, "P", 1), 297 | con(exp30dte, 69, "P", 5), 298 | ], 299 | "P", 300 | ) 301 | 302 | assert 0 == calculate_net_short_positions( 303 | [ 304 | con(exp3dte, 70, "P", -1), 305 | con(exp30dte, 69, "P", -1), 306 | con(exp3dte, 69, "P", 1), 307 | con(exp30dte, 70, "P", 5), 308 | ], 309 | "P", 310 | ) 311 | 312 | assert 2 == calculate_net_short_positions( 313 | [ 314 | con(exp3dte, 70, "P", -1), 315 | con(exp30dte, 69, "P", -1), 316 | con(exp3dte, 69, "P", 1), 317 | con(exp30dte, 68, "P", 5), 318 | ], 319 | "P", 320 | ) 321 | 322 | assert 0 == calculate_net_short_positions( 323 | [ 324 | con(exp3dte, 70, "C", -1), 325 | con(exp30dte, 69, "C", -1), 326 | con(exp3dte, 69, "C", 1), 327 | con(exp30dte, 68, "C", 5), 328 | ], 329 | "C", 330 | ) 331 | 332 | assert 1 == calculate_net_short_positions( 333 | [ 334 | con(exp3dte, 70, "C", -1), 335 | con(exp30dte, 69, "C", -1), 336 | con(exp3dte, 71, "C", 1), 337 | con(exp30dte, 70, "C", 5), 338 | ], 339 | "C", 340 | ) 341 | 342 | assert 2 == calculate_net_short_positions( 343 | [ 344 | con(exp3dte, 70, "C", -1), 345 | con(exp30dte, 71, "C", -1), 346 | con(exp3dte, 71, "C", 1), 347 | con(exp30dte, 72, "C", 5), 348 | ], 349 | "C", 350 | ) 351 | 352 | assert 3 == calculate_net_short_positions( 353 | [ 354 | con(exp3dte, 70, "C", -1), 355 | con(exp30dte, 71, "C", -1), 356 | con(exp90dte, 72, "C", -1), 357 | con(exp3dte, 71, "C", 1), 358 | con(exp30dte, 72, "C", 5), 359 | ], 360 | "C", 361 | ) 362 | 363 | assert 5 == calculate_net_short_positions( 364 | [ 365 | con(exp3dte, 60, "P", -10), 366 | con(exp30dte, 69, "P", -1), 367 | con(exp90dte, 69, "P", 1), 368 | con(exp90dte, 68, "P", 5), 369 | ], 370 | "P", 371 | ) 372 | 373 | assert 10 == calculate_net_short_positions( 374 | [ 375 | con(exp3dte, 70, "P", -10), 376 | con(exp30dte, 69, "P", -1), 377 | con(exp90dte, 69, "P", 1), 378 | con(exp90dte, 68, "P", 5), 379 | ], 380 | "P", 381 | ) 382 | 383 | assert 0 == calculate_net_short_positions( 384 | [ 385 | con(exp3dte, 60, "P", -10), 386 | con(exp30dte, 69, "P", -1), 387 | con(exp90dte, 69, "P", 1), 388 | con(exp90dte, 68, "P", 50), 389 | ], 390 | "P", 391 | ) 392 | 393 | # A couple real-world examples 394 | exp9dte = (today + timedelta(days=9)).strftime("%Y%m%d") 395 | exp16dte = (today + timedelta(days=16)).strftime("%Y%m%d") 396 | exp23dte = (today + timedelta(days=23)).strftime("%Y%m%d") 397 | exp30dte = (today + timedelta(days=30)).strftime("%Y%m%d") 398 | exp37dte = (today + timedelta(days=37)).strftime("%Y%m%d") 399 | exp268dte = (today + timedelta(days=268)).strftime("%Y%m%d") 400 | 401 | assert 2 == calculate_net_short_positions( 402 | [ 403 | con(exp9dte, 77.0, "P", -2), 404 | con(exp16dte, 76.0, "P", -1), 405 | con(exp16dte, 77.0, "P", -1), 406 | con(exp23dte, 77.0, "P", -6), 407 | con(exp30dte, 77.0, "P", -2), 408 | con(exp37dte, 77.0, "P", -5), 409 | con(exp268dte, 77.0, "P", 15), 410 | ], 411 | "P", 412 | ) 413 | 414 | assert 0 == calculate_net_short_positions( 415 | [ 416 | con(exp9dte, 77.0, "P", -2), 417 | con(exp16dte, 76.0, "P", -1), 418 | con(exp16dte, 77.0, "P", -1), 419 | con(exp23dte, 77.0, "P", -6), 420 | con(exp30dte, 77.0, "P", -2), 421 | con(exp37dte, 77.0, "P", -5), 422 | con(exp268dte, 77.0, "P", 15), 423 | ], 424 | "C", 425 | ) 426 | 427 | assert 20 == calculate_net_short_positions( 428 | [ 429 | con(exp23dte, 72.0, "C", -8), 430 | con(exp30dte, 66.0, "C", -8), 431 | con(exp30dte, 68.0, "C", -9), 432 | con(exp30dte, 69.0, "C", -7), 433 | con(exp30dte, 72.0, "C", -1), 434 | con(exp37dte, 59.5, "C", -8), 435 | con(exp37dte, 68.0, "C", -7), 436 | con(exp268dte, 55.0, "C", 5), 437 | con(exp268dte, 60.0, "C", 23), 438 | ], 439 | "C", 440 | ) 441 | 442 | assert 0 == calculate_net_short_positions( 443 | [ 444 | con(exp23dte, 72.0, "C", -8), 445 | con(exp30dte, 66.0, "C", -8), 446 | con(exp30dte, 68.0, "C", -9), 447 | con(exp30dte, 69.0, "C", -7), 448 | con(exp30dte, 72.0, "C", -1), 449 | con(exp37dte, 59.5, "C", -8), 450 | con(exp37dte, 68.0, "C", -7), 451 | con(exp268dte, 55.0, "C", 5), 452 | con(exp268dte, 60.0, "C", 23), 453 | ], 454 | "P", 455 | ) 456 | 457 | 458 | def test_weighted_avg_strike() -> None: 459 | today = date.today() 460 | exp3dte = (today + timedelta(days=3)).strftime("%Y%m%d") 461 | exp30dte = (today + timedelta(days=30)).strftime("%Y%m%d") 462 | exp90dte = (today + timedelta(days=90)).strftime("%Y%m%d") 463 | 464 | assert math.isclose( 465 | 70, 466 | weighted_avg_short_strike( 467 | [ 468 | con(exp3dte, 70, "C", -1), 469 | con(exp30dte, 70, "C", -1), 470 | con(exp90dte, 70, "C", -1), 471 | con(exp3dte, 100, "C", 1), 472 | con(exp30dte, 100, "C", 5), 473 | ], 474 | "C", 475 | ) 476 | or -1, 477 | ) 478 | assert math.isclose( 479 | 100, 480 | weighted_avg_long_strike( 481 | [ 482 | con(exp3dte, 70, "C", -1), 483 | con(exp30dte, 70, "C", -1), 484 | con(exp90dte, 70, "C", -1), 485 | con(exp3dte, 100, "C", 1), 486 | con(exp30dte, 100, "C", 5), 487 | ], 488 | "C", 489 | ) 490 | or -1, 491 | ) 492 | assert math.isclose( 493 | 70, 494 | weighted_avg_short_strike( 495 | [ 496 | con(exp3dte, 70, "P", -1), 497 | con(exp30dte, 70, "P", -1), 498 | con(exp90dte, 70, "P", -1), 499 | con(exp3dte, 100, "P", 1), 500 | con(exp30dte, 100, "P", 5), 501 | ], 502 | "P", 503 | ) 504 | or -1, 505 | ) 506 | assert math.isclose( 507 | 100, 508 | weighted_avg_long_strike( 509 | [ 510 | con(exp3dte, 70, "P", -1), 511 | con(exp30dte, 70, "P", -1), 512 | con(exp90dte, 70, "P", -1), 513 | con(exp3dte, 100, "P", 1), 514 | con(exp30dte, 100, "P", 5), 515 | ], 516 | "P", 517 | ) 518 | or -1, 519 | ) 520 | 521 | assert math.isclose( 522 | 28, 523 | weighted_avg_short_strike( 524 | [ 525 | con(exp3dte, 10, "P", -4), 526 | con(exp3dte, 100, "P", -1), 527 | con(exp3dte, 100, "P", 4), 528 | con(exp3dte, 10, "P", 1), 529 | ], 530 | "P", 531 | ) 532 | or -1, 533 | ) 534 | 535 | assert math.isclose( 536 | 82, 537 | weighted_avg_long_strike( 538 | [ 539 | con(exp3dte, 10, "P", -4), 540 | con(exp3dte, 100, "P", -1), 541 | con(exp3dte, 100, "P", 4), 542 | con(exp3dte, 10, "P", 1), 543 | ], 544 | "P", 545 | ) 546 | or -1, 547 | ) 548 | 549 | 550 | def test_would_increase_spread() -> None: 551 | # Test BUY order with lmtPrice < 0 and updated_price > lmtPrice 552 | order1 = Order(action="BUY", lmtPrice=-10) 553 | updated_price1 = -5.0 554 | assert would_increase_spread(order1, updated_price1) is False 555 | 556 | # Test BUY order with lmtPrice < 0 and updated_price < lmtPrice 557 | order2 = Order(action="BUY", lmtPrice=-10) 558 | updated_price2 = -15.0 559 | assert would_increase_spread(order2, updated_price2) is True 560 | 561 | # Test BUY order with lmtPrice > 0 and updated_price < lmtPrice 562 | order3 = Order(action="BUY", lmtPrice=10) 563 | updated_price3 = 5.0 564 | assert would_increase_spread(order3, updated_price3) is True 565 | 566 | # Test BUY order with lmtPrice > 0 and updated_price > lmtPrice 567 | order4 = Order(action="BUY", lmtPrice=10) 568 | updated_price4 = 15.0 569 | assert would_increase_spread(order4, updated_price4) is False 570 | 571 | # Test SELL order with lmtPrice < 0 and updated_price < lmtPrice 572 | order5 = Order(action="SELL", lmtPrice=-10) 573 | updated_price5 = -15.0 574 | assert would_increase_spread(order5, updated_price5) is False 575 | 576 | # Test SELL order with lmtPrice < 0 and updated_price > lmtPrice 577 | order6 = Order(action="SELL", lmtPrice=-10) 578 | updated_price6 = -5.0 579 | assert would_increase_spread(order6, updated_price6) is True 580 | 581 | # Test SELL order with lmtPrice > 0 and updated_price > lmtPrice 582 | order7 = Order(action="SELL", lmtPrice=10) 583 | updated_price7 = 15.0 584 | assert would_increase_spread(order7, updated_price7) is True 585 | 586 | # Test SELL order with lmtPrice > 0 and updated_price < lmtPrice 587 | order8 = Order(action="SELL", lmtPrice=10) 588 | updated_price8 = 5.0 589 | assert would_increase_spread(order8, updated_price8) is False 590 | -------------------------------------------------------------------------------- /thetagang/thetagang.py: -------------------------------------------------------------------------------- 1 | from asyncio import Future 2 | 3 | import toml 4 | from ib_async import IB, IBC, Contract, Watchdog, util 5 | from rich.console import Console 6 | 7 | from thetagang import log 8 | from thetagang.config import Config, normalize_config 9 | from thetagang.exchange_hours import need_to_exit 10 | from thetagang.portfolio_manager import PortfolioManager 11 | 12 | util.patchAsyncio() 13 | 14 | console = Console() 15 | 16 | 17 | def start(config_path: str, without_ibc: bool = False, dry_run: bool = False) -> None: 18 | with open(config_path, "r", encoding="utf8") as file: 19 | config = toml.load(file) 20 | 21 | config = Config(**normalize_config(config)) # type: ignore 22 | 23 | config.display(config_path) 24 | 25 | if config.ib_async.logfile: 26 | util.logToFile(config.ib_async.logfile) 27 | 28 | # Check if exchange is open before continuing 29 | if need_to_exit(config.exchange_hours): 30 | return 31 | 32 | async def onConnected() -> None: 33 | log.info(f"Connected to IB Gateway, serverVersion={ib.client.serverVersion()}") 34 | await portfolio_manager.manage() 35 | 36 | ib = IB() 37 | ib.connectedEvent += onConnected 38 | 39 | completion_future: Future[bool] = Future() 40 | portfolio_manager = PortfolioManager(config, ib, completion_future, dry_run) 41 | 42 | probe_contract_config = config.watchdog.probeContract 43 | watchdog_config = config.watchdog 44 | probeContract = Contract( 45 | secType=probe_contract_config.secType, 46 | symbol=probe_contract_config.symbol, 47 | currency=probe_contract_config.currency, 48 | exchange=probe_contract_config.exchange, 49 | ) 50 | 51 | if not without_ibc: 52 | # TWS version is pinned to current stable 53 | ibc_config = config.ibc 54 | ibc = IBC(1030, **ibc_config.to_dict()) 55 | log.info(f"Starting TWS with twsVersion={ibc.twsVersion}") 56 | 57 | ib.RaiseRequestErrors = ibc_config.RaiseRequestErrors 58 | 59 | watchdog = Watchdog( 60 | ibc, ib, probeContract=probeContract, **watchdog_config.to_dict() 61 | ) 62 | watchdog.start() 63 | 64 | ib.run(completion_future) # type: ignore 65 | watchdog.stop() 66 | ibc.terminate() 67 | else: 68 | ib.connect( 69 | watchdog_config.host, 70 | watchdog_config.port, 71 | clientId=watchdog_config.clientId, 72 | timeout=watchdog_config.probeTimeout, 73 | account=config.account.number, 74 | ) 75 | ib.run(completion_future) # type: ignore 76 | ib.disconnect() 77 | -------------------------------------------------------------------------------- /thetagang/trades.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from ib_async import Contract, LimitOrder, Trade 4 | from rich import box 5 | from rich.pretty import Pretty 6 | from rich.table import Table 7 | 8 | from thetagang import log 9 | from thetagang.fmt import dfmt, ffmt, ifmt 10 | from thetagang.ibkr import IBKR 11 | 12 | 13 | class Trades: 14 | def __init__(self, ibkr: IBKR) -> None: 15 | self.ibkr = ibkr 16 | self.__records: List[Trade] = [] 17 | 18 | def submit_order( 19 | self, contract: Contract, order: LimitOrder, idx: Optional[int] = None 20 | ) -> None: 21 | try: 22 | trade = self.ibkr.place_order(contract, order) 23 | if idx is not None: 24 | self.__replace_trade(trade, idx) 25 | else: 26 | self.__add_trade(trade) 27 | except RuntimeError: 28 | log.error(f"{contract.symbol}: Failed to submit contract, order={order}") 29 | 30 | def records(self) -> List[Trade]: 31 | return self.__records 32 | 33 | def is_empty(self) -> bool: 34 | return len(self.__records) == 0 35 | 36 | def print_summary(self) -> None: 37 | if not self.__records: 38 | return 39 | 40 | table = Table( 41 | title="Trade Summary", show_lines=True, box=box.MINIMAL_HEAVY_HEAD 42 | ) 43 | table.add_column("Symbol") 44 | table.add_column("Exchange") 45 | table.add_column("Contract") 46 | table.add_column("Action") 47 | table.add_column("Price") 48 | table.add_column("Qty") 49 | table.add_column("Status") 50 | table.add_column("Filled") 51 | 52 | for trade in self.__records: 53 | table.add_row( 54 | trade.contract.symbol, 55 | trade.contract.exchange, 56 | Pretty(trade.contract, indent_size=2), 57 | trade.order.action, 58 | dfmt(trade.order.lmtPrice), 59 | ifmt(int(trade.order.totalQuantity)), 60 | trade.orderStatus.status, 61 | ffmt(trade.orderStatus.filled, 0), 62 | ) 63 | 64 | log.print(table) 65 | 66 | def __add_trade(self, trade: Trade) -> None: 67 | self.__records.append(trade) 68 | 69 | def __replace_trade(self, trade: Trade, idx: int) -> None: 70 | self.__records[idx] = trade 71 | -------------------------------------------------------------------------------- /thetagang/util.py: -------------------------------------------------------------------------------- 1 | import math 2 | from operator import itemgetter 3 | from typing import Dict, List, Optional 4 | 5 | import ib_async.objects 6 | import ib_async.ticker 7 | from ib_async import AccountValue, Order, PortfolioItem, Ticker, util 8 | from ib_async.contract import Option 9 | 10 | from thetagang.config import Config 11 | from thetagang.options import option_dte 12 | 13 | 14 | def account_summary_to_dict( 15 | account_summary: List[AccountValue], 16 | ) -> Dict[str, AccountValue]: 17 | d: Dict[str, AccountValue] = dict() 18 | for s in account_summary: 19 | d[s.tag] = s 20 | return d 21 | 22 | 23 | def portfolio_positions_to_dict( 24 | portfolio_positions: List[PortfolioItem], 25 | ) -> Dict[str, List[PortfolioItem]]: 26 | d: Dict[str, List[PortfolioItem]] = dict() 27 | for p in portfolio_positions: 28 | symbol = p.contract.symbol 29 | if symbol not in d: 30 | d[symbol] = [] 31 | d[symbol].append(p) 32 | return d 33 | 34 | 35 | def position_pnl(position: ib_async.objects.PortfolioItem) -> float: 36 | return position.unrealizedPNL / abs(position.averageCost * position.position) 37 | 38 | 39 | def get_short_positions( 40 | positions: List[PortfolioItem], right: str 41 | ) -> List[PortfolioItem]: 42 | return [ 43 | p 44 | for p in positions 45 | if isinstance(p.contract, Option) 46 | and p.contract.right.upper().startswith(right.upper()) 47 | and p.position < 0 48 | ] 49 | 50 | 51 | def get_long_positions( 52 | positions: List[PortfolioItem], right: str 53 | ) -> List[PortfolioItem]: 54 | return [ 55 | p 56 | for p in positions 57 | if isinstance(p.contract, Option) 58 | and p.contract.right.upper().startswith(right.upper()) 59 | and p.position > 0 60 | ] 61 | 62 | 63 | def count_short_option_positions(positions: List[PortfolioItem], right: str) -> int: 64 | return math.floor(-sum([p.position for p in get_short_positions(positions, right)])) 65 | 66 | 67 | def weighted_avg_short_strike( 68 | positions: List[PortfolioItem], right: str 69 | ) -> Optional[float]: 70 | shorts = [ 71 | (abs(p.position), p.contract.strike) 72 | for p in get_short_positions(positions, right) 73 | ] 74 | num = sum([p[0] * p[1] for p in shorts]) 75 | den = sum([p[0] for p in shorts]) 76 | if den > 0: 77 | return num / den 78 | 79 | 80 | def weighted_avg_long_strike( 81 | positions: List[PortfolioItem], right: str 82 | ) -> Optional[float]: 83 | shorts = [ 84 | (abs(p.position), p.contract.strike) 85 | for p in get_long_positions(positions, right) 86 | ] 87 | num = sum([p[0] * p[1] for p in shorts]) 88 | den = sum([p[0] for p in shorts]) 89 | if den > 0: 90 | return num / den 91 | 92 | 93 | def count_long_option_positions(positions: List[PortfolioItem], right: str) -> int: 94 | return math.floor(sum([p.position for p in get_long_positions(positions, right)])) 95 | 96 | 97 | def calculate_net_short_positions(positions: List[PortfolioItem], right: str) -> int: 98 | shorts = [ 99 | ( 100 | option_dte(p.contract.lastTradeDateOrContractMonth), 101 | p.contract.strike, 102 | p.position, 103 | ) 104 | for p in get_short_positions(positions, right) 105 | ] 106 | longs = [ 107 | ( 108 | option_dte(p.contract.lastTradeDateOrContractMonth), 109 | p.contract.strike, 110 | p.position, 111 | ) 112 | for p in get_long_positions(positions, right) 113 | ] 114 | shorts = sorted(shorts, key=itemgetter(0, 1), reverse=right.upper().startswith("P")) 115 | longs = sorted(longs, key=itemgetter(0, 1), reverse=right.upper().startswith("P")) 116 | 117 | def calc_net(short_dte: int, short_strike: float, short_position: float) -> float: 118 | for i in range(len(longs)): 119 | if short_position > -1: 120 | break 121 | (long_dte, long_strike, long_position) = longs[i] 122 | if long_position < 1: 123 | # ignore empty long positions 124 | continue 125 | if long_dte >= short_dte: 126 | if ( 127 | math.isclose(short_strike, long_strike) 128 | or (right.upper().startswith("P") and long_strike >= short_strike) 129 | or (right.upper().startswith("C") and long_strike <= short_strike) 130 | ): 131 | if short_position + long_position > 0: 132 | long_position = short_position + long_position 133 | short_position = 0 134 | else: 135 | short_position += long_position 136 | long_position = 0 137 | longs[i] = (long_dte, long_strike, long_position) 138 | return min([0.0, short_position]) 139 | 140 | nets = [calc_net(*short) for short in shorts] 141 | 142 | return math.floor(-sum(nets)) 143 | 144 | 145 | def net_option_positions( 146 | symbol: str, 147 | portfolio_positions: Dict[str, List[PortfolioItem]], 148 | right: str, 149 | ignore_dte: Optional[int] = None, 150 | ) -> int: 151 | if symbol in portfolio_positions: 152 | return math.floor( 153 | sum( 154 | [ 155 | p.position 156 | for p in portfolio_positions[symbol] 157 | if isinstance(p.contract, Option) 158 | and p.contract.right.upper().startswith(right.upper()) 159 | and option_dte(p.contract.lastTradeDateOrContractMonth) >= 0 160 | and ( 161 | not ignore_dte 162 | or option_dte(p.contract.lastTradeDateOrContractMonth) 163 | > ignore_dte 164 | ) 165 | ] 166 | ) 167 | ) 168 | 169 | return 0 170 | 171 | 172 | def get_higher_price(ticker: Ticker) -> float: 173 | # Returns the highest of either the option model price, the midpoint, or the 174 | # market price. The midpoint is usually a bit higher than the IB model's 175 | # pricing, but we want to avoid leaving money on the table in cases where 176 | # the spread might be messed up. This may in some cases make it harder for 177 | # orders to fill in a given day, but I think that's a reasonable tradeoff to 178 | # avoid leaving money on the table. 179 | if ticker.modelGreeks and ticker.modelGreeks.optPrice: 180 | return max([midpoint_or_market_price(ticker), ticker.modelGreeks.optPrice]) 181 | return midpoint_or_market_price(ticker) 182 | 183 | 184 | def get_lower_price(ticker: Ticker) -> float: 185 | # Same as get_highest_price(), except get the lower price instead. 186 | if ticker.modelGreeks and ticker.modelGreeks.optPrice: 187 | return min([midpoint_or_market_price(ticker), ticker.modelGreeks.optPrice]) 188 | return midpoint_or_market_price(ticker) 189 | 190 | 191 | def midpoint_or_market_price(ticker: Ticker) -> float: 192 | # As per the ib_async docs, marketPrice returns the last price first, but 193 | # we often prefer the midpoint over the last price. This function pulls the 194 | # midpoint first, then falls back to marketPrice() if midpoint is nan. 195 | if util.isNan(ticker.midpoint()): 196 | if ( 197 | util.isNan(ticker.marketPrice()) 198 | and ticker.modelGreeks 199 | and ticker.modelGreeks.optPrice 200 | ): 201 | # Fallback to the model price if the greeks are available 202 | return ticker.modelGreeks.optPrice 203 | else: 204 | return ticker.marketPrice() if not util.isNan(ticker.marketPrice()) else 0.0 205 | 206 | return ticker.midpoint() 207 | 208 | 209 | def get_target_calls( 210 | config: Config, symbol: str, current_shares: int, target_shares: int 211 | ) -> int: 212 | if config.write_excess_calls_only(symbol): 213 | return max([0, (current_shares - target_shares) // 100]) 214 | else: 215 | cap_factor = config.get_cap_factor(symbol) 216 | cap_target_floor = config.get_cap_target_floor(symbol) 217 | min_uncovered = (target_shares * cap_target_floor) // 100 218 | max_covered = (current_shares * cap_factor) // 100 219 | total_coverable = current_shares // 100 220 | return max([0, math.floor(min([max_covered, total_coverable - min_uncovered]))]) 221 | 222 | 223 | def would_increase_spread(order: Order, updated_price: float) -> bool: 224 | return ( 225 | order.action == "BUY" 226 | and updated_price < order.lmtPrice 227 | or order.action == "SELL" 228 | and updated_price > order.lmtPrice 229 | ) 230 | --------------------------------------------------------------------------------