Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v6
Expand Down Expand Up @@ -68,7 +68,7 @@ jobs:
- name: Install uv and set the python version with caching
uses: astral-sh/setup-uv@v7
with:
python-version: 3.11
python-version: 3.14
enable-cache: true

- name: Install the project
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/python-publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6

Expand All @@ -35,7 +35,7 @@ jobs:
- name: Install uv and set the python version with caching
uses: astral-sh/setup-uv@v7
with:
python-version: 3.11
python-version: 3.14

- name: Install the project
run: uv sync --all-extras
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/python-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6

Expand All @@ -37,5 +37,5 @@ jobs:
- name: Check formatting with ruff
run: uv run ruff format --check --diff

- name: Check types with pyright
run: uv run pyright
- name: Check types with ty
run: uvx ty check
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ repos:
- id: uv-lock
- repo: local
hooks:
- id: pyright
name: pyright
entry: uv run pyright
- id: ty-check
name: ty check
entry: uvx ty check
language: system
types: [python]
pass_filenames: true
pass_filenames: false
require_serial: true
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
## Project Structure & Module Organization
- Core trading code lives in `thetagang/`; the CLI entry point is `thetagang/entry.py`, with the main orchestration in `portfolio_manager.py` and configuration models
in `config.py`.
- The Click CLI command is defined in `thetagang/main.py` and re-exported by
`thetagang/entry.py` for the `thetagang` console script.
- Runtime startup wiring (config loading, IBKR/IBC setup, event loop, dry run
control flow) lives in `thetagang/thetagang.py`.
- Broker integrations and execution helpers sit in `thetagang/ibkr.py`, `orders.py`, and related utilities under the same package.
- Supporting scripts and assets reside in `tws/`, `lib/`, and the packaging files `pyproject.toml` and `uv.lock`; sample configs and data are under `data/` and
`thetagang.toml`.
Expand All @@ -13,7 +17,7 @@ in `config.py`.
- `uv run pytest` — run the full test suite; append a path (e.g., `tests/test_portfolio_manager.py`) to scope runs.
- `uv run pytest --cov=thetagang` — gather coverage for trading logic changes.
- `uv run ruff check .` / `uv run ruff format .` — lint and auto-format the codebase.
- `uv run pyright` — perform static type checking.
- `uv run ty check` — perform static type checking.
- `uv run pre-commit run --all-files` — replicate the CI hook set before pushing.

## Coding Style & Naming Conventions
Expand Down
12 changes: 8 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,10 @@ RUN apt-get update \
libxrender1 \
libxtst6 \
openjfx \
python3-pip \
python3-setuptools \
unzip \
wget \
xdg-utils \
xvfb \
&& if test "$(dpkg --print-architecture)" = "armhf" ; then python3 -m pip config set global.extra-index-url https://www.piwheels.org/simple ; fi \
&& echo 'a3f9b93ea1ff6740d2880760fb73e1a6e63b454f86fe6366779ebd9cd41c1542 ibc.zip' | tee ibc.zip.sha256 \
&& wget -q https://github.com/IbcAlpha/IBC/releases/download/3.20.0/IBCLinux-3.20.0.zip -O ibc.zip \
&& sha256sum -c ibc.zip.sha256 \
Expand All @@ -51,14 +48,21 @@ RUN apt-get update \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

ENV VIRTUAL_ENV="/opt/venv"
ENV PATH="/opt/venv/bin:/root/.local/bin:${PATH}"

WORKDIR /src

ADD ./tws/Jts /root/Jts
ADD ./dist /src/dist
ADD entrypoint.bash /src/entrypoint.bash
ADD ./data/jxbrowser-linux64-arm-7.29.jar /root/Jts/1037/jars/

RUN python3 -m pip install dist/thetagang-*.whl \
RUN wget -qO- https://astral.sh/uv/install.sh | sh \
&& uv python install 3.14 \
&& uv venv /opt/venv --python 3.14 \
&& if test "$(dpkg --print-architecture)" = "armhf" ; then export PIP_EXTRA_INDEX_URL=https://www.piwheels.org/simple ; fi \
&& uv pip install --python /opt/venv/bin/python dist/thetagang-*.whl \
&& rm -rf /root/.cache \
&& rm -rf dist \
&& echo '--module-path /usr/share/openjfx/lib' | tee -a /root/Jts/*/tws.vmoptions \
Expand Down
75 changes: 68 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ _Beat the capitalists at their own game with ThetaGang 📈_

![Decay my sweet babies](thetagang.jpg)

ThetaGang is an [IBKR](https://www.interactivebrokers.com/) trading bot for
collecting premium by selling options using "The Wheel" strategy. The Wheel
is a strategy that [surfaced on
ThetaGang is an [IBKR](https://www.interactivebrokers.com/) trading bot that
started as a basic implementation of "The Wheel" strategy and has grown into a
broader, configurable portfolio automation tool. The Wheel is a strategy that
[surfaced on
Reddit](https://www.reddit.com/r/options/comments/a36k4j/the_wheel_aka_triple_income_strategy_explained/),
but has been used by many in the past. This bot implements a slightly
modified version of The Wheel, with my own personal tweaks.
but has been used by many in the past. ThetaGang still supports a modified
version of The Wheel, and now also includes features like direct share
rebalancing, cash management, VIX call hedging, regime-aware rebalancing, and
exchange-hours gating.

## Risk Disclaimer

Expand Down Expand Up @@ -66,6 +69,16 @@ strategies as long as you have the buying power available and set the
appropriate configuration (in particular, by setting
`write_when.calculate_net_contracts = true`).

Over time, additional features were added to support different portfolio
workflows and risk controls. You can enable or disable them independently via
config:

- Direct share rebalancing (buy-only and sell-only modes)
- Cash management via a cash-equivalent fund
- VIX call hedging
- Regime-aware rebalancing gates
- Exchange-hours enforcement

You could use this tool on individual stocks, but I don't
recommend it because I am not smart enough to understand which stocks to buy.
That's why I buy index funds.
Expand Down Expand Up @@ -114,7 +127,8 @@ implications, but that is outside the scope of this README.

In normal usage, you would run the script as a cronjob on a daily, weekly, or
monthly basis according to your preferences. Running more frequently than
daily is not recommended, but the choice is yours.
daily is not recommended, but the choice is yours. Some features (like
regime-aware rebalancing) assume a daily cadence.

![Paper account sample output](sample.png)

Expand Down Expand Up @@ -202,6 +216,35 @@ buy_only_min_threshold_amount = 1000 # Minimum dollar amount to buy

This feature is useful for maintaining target allocations in stocks with limited options liquidity or when you want to dollar-cost average into positions.

### Regime-Aware Rebalancing

Regime-aware rebalancing lets you gate share rebalances on a simple regime
filter before acting. It builds a proxy series from the configured symbols’
daily closes, then checks for “choppy/mean-reverting” conditions using
choppiness and efficiency thresholds. If the regime passes, and allocations
drift beyond the soft relative band around target weights (or cash flow moves
all positions in the same direction), it queues share trades to move back
toward targets. A hard relative band acts as a safety rail and triggers even
when the regime filter fails, optionally rebalancing only partway back to
target. A cooldown prevents frequent soft-band rebalances and is based on
recent executions tagged with `tg:regime-rebalance`. When using this feature,
run the script once per day.

```toml
[regime_rebalance]
enabled = true
symbols = ["QQQ", "BTAL"]
lookback_days = 40
soft_band = 0.25 # +/-25% relative drift from target weight
hard_band = 0.50 # +/-50% relative drift from target weight
hard_band_rebalance_fraction = 1.0 # 1.0 = full to target, 0.5 = halfway
cooldown_days = 5
choppiness_min = 3.0
efficiency_max = 0.30
order_history_lookback_days = 30
shares_only = true # disable option writes/rolls while rebalancing
```

### Exchange Hours Management

Control when ThetaGang operates relative to market hours:
Expand Down Expand Up @@ -405,6 +448,23 @@ much, consider [running ThetaGang with Docker](#running-with-docker).
thetagang -h
```

## State Database

ThetaGang can persist a SQLite database with order activity, executions,
historical bars, account snapshots, and decision gates. By default, the database
is created relative to your config file, and it is reused across runs to build a
long-lived history.

```toml
[database]
enabled = true
path = "data/thetagang.db"
# url = "sqlite:////abs/path/thetagang.db"
```

For Docker runs, make sure the `data/` directory is inside the mounted config
volume so the database persists between runs.

## Up and running with Docker

My preferred way for running ThetaGang is to use a cronjob to execute Docker
Expand Down Expand Up @@ -432,7 +492,8 @@ curl -Lq https://raw.githubusercontent.com/brndnmtthws/thetagang/main/ibc-config
Edit `~/thetagang/thetagang.toml` to suit your needs. Pay particular
attention to the symbols and weights. At a minimum, you must change the
username, password, and account number. You may also want to change the
trading move from paper to live when needed.
trading move from paper to live when needed. If you enable the database,
create `~/thetagang/data/` so the SQLite file is persisted.

Now, to run ThetaGang with Docker:

Expand Down
35 changes: 35 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[alembic]
script_location = alembic
sqlalchemy.url = sqlite:///thetagang.db

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
66 changes: 66 additions & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from __future__ import annotations

from logging.config import fileConfig

from sqlalchemy import engine_from_config, pool

from alembic import context
from thetagang.db import Base

config = context.config

if config.config_file_name is not None:
fileConfig(config.config_file_name)

target_metadata = Base.metadata


def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
transactional_ddl=True,
transaction_per_migration=True,
)

with context.begin_transaction():
context.run_migrations()


def run_migrations_online() -> None:
connectable = config.attributes.get("connection")
if connectable is None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)

if hasattr(connectable, "connect"):
with connectable.connect() as connection:
_run_migrations_with_connection(connection)
else:
_run_migrations_with_connection(connectable)


def _run_migrations_with_connection(connection) -> None:
is_sqlite = connection.dialect.name == "sqlite"
context.configure(
connection=connection,
target_metadata=target_metadata,
transactional_ddl=True,
transaction_per_migration=True,
render_as_batch=is_sqlite,
)

with context.begin_transaction():
context.run_migrations()


if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
Loading