├── app ├── __init__.py ├── messaging │ ├── __init__.py │ ├── ntfy.py │ └── telegram.py ├── services │ ├── __init__.py │ ├── contract.py │ ├── utilities.py │ ├── positions_parser.py │ └── option_spread.py ├── storage │ ├── __init__.py │ └── db.py ├── ib_mock │ ├── __init__.py │ ├── gen_trades.py │ ├── gen_option_chain.py │ ├── common.py │ ├── gen_positions.py │ ├── gen_tickers.py │ └── ib_mock.py ├── models │ ├── __init__.py │ ├── filled_trade.py │ ├── option_spread.py │ └── option_sized.py ├── Dockerfile ├── settings.py ├── main.py ├── requirements.txt └── trader │ ├── trading_bot.py │ └── trading_logic.py ├── .gitattributes ├── infra ├── docker-compose-aws │ ├── .gitignore │ ├── requirements.txt │ ├── Pulumi.yaml │ ├── config.py │ ├── __main__.py │ ├── dc_helper.py │ ├── docker-compose-prod.yaml │ └── ec2_helper.py ├── docker-compose-vps │ ├── .gitignore │ ├── requirements.txt │ ├── __main__.py │ ├── Pulumi.yaml │ ├── config.py │ ├── docker-compose.yaml │ ├── vps_setup.py │ └── dc_setup.py └── k8s-deployment │ ├── kustomization.yaml │ ├── service.yaml │ ├── volumes.yaml │ ├── configmap.yaml │ ├── deployment.yaml │ └── jobs.yaml ├── charts └── ibkr-bot │ ├── Chart.yaml │ ├── templates │ ├── serviceaccount.yaml │ ├── service.yaml │ ├── _helpers.tpl │ ├── application.yaml │ └── gateway.yaml │ ├── .helmignore │ └── values.yaml ├── devenv.yaml ├── .github ├── lint │ └── .yamllint.yaml └── workflows │ ├── publish-helm.yaml │ └── publish-containers.yaml ├── .gitignore ├── Makefile ├── pulumi.nix ├── devenv.nix ├── pyproject.toml ├── Taskfile.yaml ├── .envrc ├── .pre-commit-config.yaml ├── LICENSE.txt ├── docker-compose-p4d.yaml ├── docker-compose-evd.yaml ├── terraform └── google │ ├── network.tf │ ├── main.tf │ ├── application.tf │ ├── outputs.tf │ ├── gateway.tf │ └── variables.tf ├── devenv.lock └── README.md /app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/messaging/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # trade_logic.py filter=git-crypt diff=git-crypt 2 | -------------------------------------------------------------------------------- /app/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from .db import DB 2 | 3 | __all__ = ["DB"] 4 | -------------------------------------------------------------------------------- /infra/docker-compose-aws/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.pyc 3 | venv/ 4 | __pycache__/ 5 | -------------------------------------------------------------------------------- /infra/docker-compose-vps/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.pyc 3 | venv/ 4 | __pycache__/ 5 | -------------------------------------------------------------------------------- /app/ib_mock/__init__.py: -------------------------------------------------------------------------------- 1 | from .ib_mock import MockIB 2 | 3 | __all__ = ["MockIB"] 4 | -------------------------------------------------------------------------------- /infra/docker-compose-vps/requirements.txt: -------------------------------------------------------------------------------- 1 | pulumi>=3 2 | pulumi_command>=1.0.1 3 | loguru>=0.7.3 4 | -------------------------------------------------------------------------------- /infra/docker-compose-aws/requirements.txt: -------------------------------------------------------------------------------- 1 | pulumi>=3 2 | pulumi_aws>=6.13.3 3 | pulumi_docker>=4.4.3 4 | pulumi_random>=4.14.0 5 | -------------------------------------------------------------------------------- /charts/ibkr-bot/Chart.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v2 3 | name: ibkr-bot 4 | description: A Helm chart for Interactive Brokers bot 5 | type: application 6 | version: 0.4.3 7 | appVersion: "1.0.0" 8 | -------------------------------------------------------------------------------- /devenv.yaml: -------------------------------------------------------------------------------- 1 | inputs: 2 | nixpkgs: 3 | url: github:cachix/devenv-nixpkgs/rolling 4 | nixpkgs-python: 5 | url: github:cachix/nixpkgs-python 6 | inputs: 7 | nixpkgs: 8 | follows: nixpkgs 9 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .option_sized import OptionWithSize 2 | from .option_spread import OptionSpread 3 | from .filled_trade import FilledTrade 4 | 5 | __all__ = ["OptionWithSize", "OptionSpread", "FilledTrade"] 6 | -------------------------------------------------------------------------------- /infra/docker-compose-vps/__main__.py: -------------------------------------------------------------------------------- 1 | import config as cfg 2 | import vps_setup 3 | import dc_setup 4 | 5 | config = cfg.read_config() 6 | setup_vps = vps_setup.setup_vps(config) 7 | setup_docker_compose = dc_setup.setup_docker_compose(config, setup_vps) 8 | -------------------------------------------------------------------------------- /infra/k8s-deployment/kustomization.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: kustomize.config.k8s.io/v1beta1 3 | kind: Kustomization 4 | resources: 5 | - configmap.yaml 6 | - deployment.yaml 7 | - jobs.yaml 8 | - service.yaml 9 | - secrets.yaml 10 | - volumes.yaml 11 | -------------------------------------------------------------------------------- /infra/k8s-deployment/service.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: ib-gateway 6 | spec: 7 | selector: 8 | app: ib-gateway 9 | ports: 10 | - name: novnc 11 | port: 6080 12 | targetPort: 6080 13 | - name: api 14 | port: 8888 15 | targetPort: 8888 16 | -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.9-slim 2 | 3 | LABEL maintainer="Oleg Medvedev " 4 | 5 | WORKDIR /code 6 | 7 | COPY ./requirements.txt /code/requirements.txt 8 | RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt 9 | 10 | COPY . /code/app 11 | 12 | CMD ["python", "-u", "/code/app/main.py"] 13 | -------------------------------------------------------------------------------- /infra/docker-compose-vps/Pulumi.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: docker-compose-vps 3 | runtime: 4 | name: python 5 | options: 6 | toolchain: pip 7 | virtualenv: venv 8 | description: Docker-compose app on VPS 9 | config: 10 | pulumi:tags: 11 | value: 12 | pulumi:template: https://www.pulumi.com/ai/api/project/4f29cef9-2f38-4195-9a19-6343d30881d2.zip 13 | -------------------------------------------------------------------------------- /.github/lint/.yamllint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | rules: 4 | truthy: 5 | allowed-values: ["true", "false", "on"] 6 | comments: 7 | min-spaces-from-content: 1 8 | line-length: disable 9 | braces: 10 | min-spaces-inside: 0 11 | max-spaces-inside: 1 12 | brackets: 13 | min-spaces-inside: 0 14 | max-spaces-inside: 0 15 | indentation: enable 16 | -------------------------------------------------------------------------------- /.github/workflows/publish-helm.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Publish Helm 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Publish Helm charts 14 | uses: stefanprodan/helm-gh-pages@master 15 | with: 16 | token: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .venv 3 | .terraform/ 4 | .terraform* 5 | *.log 6 | downloads/ 7 | terraform.tfstate* 8 | node_modules/ 9 | __pycache__/ 10 | poetry.lock 11 | secrets.yaml 12 | data/ 13 | mock_data/ 14 | 15 | # Jupyter 16 | .ipynb_checkpoints/ 17 | 18 | # Devenv 19 | .devenv* 20 | devenv.local.nix 21 | 22 | # direnv 23 | .direnv 24 | 25 | # pre-commit 26 | .pre-commit-config.yaml 27 | .ruff_cache/ 28 | -------------------------------------------------------------------------------- /charts/ibkr-bot/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | {{- if .Values.serviceAccount.create }} 3 | apiVersion: v1 4 | kind: ServiceAccount 5 | metadata: 6 | name: {{ include "ibkr.serviceAccountName" . }} 7 | labels: 8 | {{- include "ibkr.labels" . | nindent 4 }} 9 | {{- with .Values.serviceAccount.annotations }} 10 | annotations: 11 | {{- toYaml . | nindent 4 }} 12 | {{- end }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /infra/docker-compose-aws/Pulumi.yaml: -------------------------------------------------------------------------------- 1 | name: ibkr 2 | runtime: 3 | name: python 4 | options: 5 | virtualenv: venv 6 | description: Trading app on AWS. One virtual machine running two docker images. One image is the trading gateway. Other image is the trading application. VM not accessible from outside. Trading application has access to S3 7 | config: 8 | pulumi:tags: 9 | value: 10 | pulumi:template: "" 11 | -------------------------------------------------------------------------------- /app/settings.py: -------------------------------------------------------------------------------- 1 | from pydantic_settings import BaseSettings 2 | 3 | 4 | class Settings(BaseSettings): 5 | """ 6 | Read server settings 7 | """ 8 | 9 | ib_gateway_host: str 10 | ib_gateway_port: str 11 | timezone: str = "US/Eastern" 12 | timeformat: str = "%Y-%m-%dT%H%M" 13 | storage: str = "file" 14 | storage_path: str 15 | close_spread_on_expiry: bool = False 16 | ntfy_topic: str 17 | ntfy_enabled: bool 18 | -------------------------------------------------------------------------------- /charts/ibkr-bot/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /app/ib_mock/gen_trades.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generate mock trades 3 | """ 4 | 5 | from ib_async.contract import Contract 6 | from ib_async.order import Order, Trade, OrderStatus 7 | 8 | 9 | def gen_trades(contract: Contract, order: Order): 10 | """Generate mock trades""" 11 | trade = Trade( 12 | contract=contract, 13 | order=order, 14 | orderStatus=OrderStatus( 15 | status="Filled", 16 | avgFillPrice=round(order.lmtPrice, 2), 17 | ), 18 | ) 19 | return trade 20 | -------------------------------------------------------------------------------- /app/ib_mock/gen_option_chain.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generate a mocked option chain 3 | """ 4 | 5 | from ib_async import OptionChain 6 | from trader.trading_logic import next_trading_day 7 | 8 | 9 | def gen_option_chain(): 10 | """Generate a mocked option chain""" 11 | 12 | chain = OptionChain( 13 | underlyingConId=1000000000, 14 | exchange="SMART", 15 | tradingClass="SPXW", 16 | multiplier=100, 17 | expirations=[next_trading_day()], 18 | strikes=[5900, 5890, 5880, 5870, 5860], 19 | ) 20 | return [chain] 21 | -------------------------------------------------------------------------------- /infra/docker-compose-aws/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Read config 3 | """ 4 | 5 | import os 6 | 7 | 8 | def read_config(): 9 | """ 10 | Read config from config.json 11 | """ 12 | config = {} 13 | config["aws_region"] = os.environ.get("AWS_REGION") 14 | config["aws_availability_zone"] = os.environ.get("AWS_AVAILABILITY_ZONE") 15 | config["public_key"] = os.environ.get("MY_PUBLIC_KEY") 16 | config["gateway_image"] = os.environ.get("GATEWAY_IMAGE") 17 | config["app_image"] = os.environ.get("APP_IMAGE") 18 | 19 | return config 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # === SETUP === 2 | PROJECT := ibkr-trading 3 | REPOSITORY := omdv/ibkr-trading 4 | 5 | APP_FOLDER := ./app 6 | APP_IMAGE := docker.io/omdv/ib-app:${IB_APP_VERSION} 7 | 8 | # === DEVELOPMENT === 9 | .PHONY: test 10 | test: 11 | docker-compose up -d --build 12 | 13 | .PHONY: dev 14 | dev: build-app 15 | docker-compose up -d 16 | 17 | # === DOCKER === 18 | .PHONY: build-app 19 | build-app: 20 | docker build -t $(APP_IMAGE) $(APP_FOLDER) 21 | 22 | .PHONY: publish-app 23 | publish-app: build-app 24 | docker push $(APP_IMAGE) 25 | 26 | .PHONY: publish 27 | publish: publish-app 28 | -------------------------------------------------------------------------------- /pulumi.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | 3 | pkgs.stdenv.mkDerivation { 4 | pname = "pulumi"; 5 | version = "3.141.0"; 6 | src = pkgs.fetchurl { 7 | url = "https://get.pulumi.com/releases/sdk/pulumi-v3.141.0-linux-x64.tar.gz"; 8 | sha256 = "sha256-gbUeSuxw58jyKHou2It4AfTK3gdMrMnzzDyoUsrkdwA="; 9 | }; 10 | installPhase = '' 11 | mkdir -p $out/bin 12 | tar -xzf $src -C $out/bin --strip-components=1 13 | ''; 14 | meta = with pkgs.lib; { 15 | description = "Pulumi CLI"; 16 | license = licenses.mit; 17 | platforms = platforms.linux; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /app/ib_mock/common.py: -------------------------------------------------------------------------------- 1 | from ib_async.objects import Contract 2 | 3 | 4 | def local_symbol(contract: Contract) -> str: 5 | if contract.secType == "OPT": 6 | return f"{contract.tradingClass} {contract.lastTradeDateOrContractMonth}{contract.right}{contract.strike:07.0f}" 7 | else: 8 | return contract.symbol 9 | 10 | 11 | def contract_id(contract: Contract) -> int: 12 | if contract.secType == "IND": 13 | return 100 14 | elif contract.secType == "OPT": 15 | return int(float(contract.lastTradeDateOrContractMonth) + contract.strike) 16 | else: 17 | return contract.conId 18 | -------------------------------------------------------------------------------- /infra/docker-compose-aws/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main pulumi module 3 | """ 4 | 5 | import pulumi 6 | import pulumi_aws as aws 7 | 8 | import ec2_helper as ec2h 9 | import config as cfg 10 | import dc_helper as dch 11 | 12 | config = cfg.read_config() 13 | 14 | aws.config.region = config["aws_region"] 15 | 16 | dc = dch.get_docker_compose(config) 17 | instance = ec2h.create_ec2(config, dc) 18 | 19 | pulumi.export("config", config) 20 | pulumi.export("instance_id", instance.id) 21 | pulumi.export("instance_ip", instance.public_ip) 22 | pulumi.export("instance_public_dns", instance.public_dns) 23 | -------------------------------------------------------------------------------- /charts/ibkr-bot/templates/service.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: {{ include "ibkr.fullname" . }}-gateway 6 | labels: 7 | {{- include "ibkr.labels" . | nindent 4 }} 8 | spec: 9 | type: ClusterIP 10 | ports: 11 | - port: {{ .Values.gateway.ports.api }} 12 | targetPort: api 13 | protocol: TCP 14 | name: api 15 | - port: {{ .Values.gateway.ports.vnc }} 16 | targetPort: vnc 17 | protocol: TCP 18 | name: vnc 19 | selector: 20 | {{- include "ibkr.selectorLabels" . | nindent 4 }} 21 | app.kubernetes.io/component: gateway 22 | -------------------------------------------------------------------------------- /infra/k8s-deployment/volumes.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: PersistentVolume 4 | metadata: 5 | name: pv-ibkr-data 6 | spec: 7 | capacity: 8 | storage: 5Gi 9 | accessModes: 10 | - ReadWriteOnce 11 | persistentVolumeReclaimPolicy: Retain 12 | storageClassName: local-path 13 | hostPath: 14 | path: "/pool/ibkr" 15 | 16 | --- 17 | apiVersion: v1 18 | kind: PersistentVolumeClaim 19 | metadata: 20 | name: pvc-ibkr-data 21 | spec: 22 | accessModes: 23 | - ReadWriteOnce 24 | resources: 25 | requests: 26 | storage: 5Gi 27 | storageClassName: local-path 28 | volumeName: pv-ibkr-data 29 | -------------------------------------------------------------------------------- /devenv.nix: -------------------------------------------------------------------------------- 1 | { pkgs, ... }: { 2 | # https://devenv.sh/packages/ 3 | packages = [ 4 | (import ./pulumi.nix { inherit pkgs; }) 5 | pkgs.awscli2 6 | pkgs.go-task 7 | pkgs.pre-commit 8 | pkgs.litecli 9 | pkgs.kubernetes-helm 10 | ]; 11 | 12 | dotenv = { 13 | enable = false; 14 | filename = ".env"; 15 | }; 16 | 17 | # https://devenv.sh/languages/ 18 | languages.python = { 19 | enable = true; 20 | poetry = { 21 | enable = true; 22 | install = { 23 | enable = true; 24 | groups = [ "main" "dev" ]; 25 | allExtras = true; 26 | }; 27 | }; 28 | libraries = with pkgs; [zlib]; 29 | version = "3.11.9"; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /infra/k8s-deployment/configmap.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ConfigMap 4 | metadata: 5 | name: ibkr-config 6 | data: 7 | TWOFA_TIMEOUT_ACTION: "restart" 8 | GATEWAY_OR_TWS: "gateway" 9 | IBC_TradingMode: "live" 10 | IBC_ReadOnlyApi: "no" 11 | IBC_ReloginAfterSecondFactorAuthenticationTimeout: "yes" 12 | IBC_AutoRestartTime: "08:35 AM" 13 | 14 | --- 15 | apiVersion: v1 16 | kind: ConfigMap 17 | metadata: 18 | name: ibkr-app-config 19 | data: 20 | IB_GATEWAY_HOST: "ib-gateway.ibkr.svc.cluster.local" 21 | IB_GATEWAY_PORT: "8888" 22 | STORAGE_PATH: "/data" 23 | TRADING_MOCKED: "false" 24 | TRADING_KELLY_RATIO: "0.22" 25 | TRADING_CLOSE_FLAG: "true" 26 | TRADING_CLOSE_TARGET_PROFIT_RATIO: "0.75" 27 | TRADING_CLOSE_CHECK_INTERVAL: "60" 28 | -------------------------------------------------------------------------------- /app/storage/db.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from sqlmodel import create_engine, Session, SQLModel 3 | 4 | from settings import Settings 5 | 6 | 7 | class DB: 8 | """Database connection manager""" 9 | 10 | def __init__(self, config: Settings, echo: bool = False): 11 | self.db_file = Path(config.storage_path) 12 | self.echo = echo 13 | self._engine = None 14 | 15 | @property 16 | def engine(self): 17 | """Lazy initialization of database engine""" 18 | if self._engine is None: 19 | self._engine = create_engine(f"sqlite:///{self.db_file}", echo=self.echo) 20 | SQLModel.metadata.create_all(self._engine) 21 | 22 | return self._engine 23 | 24 | def get_session(self) -> Session: 25 | """Get a new database session""" 26 | return Session(self.engine) 27 | -------------------------------------------------------------------------------- /infra/docker-compose-aws/dc_helper.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configure docker-compose 3 | """ 4 | 5 | import io 6 | from ruamel.yaml import YAML 7 | 8 | 9 | def get_docker_compose(config): 10 | """ 11 | Read and parameterize docker compose file 12 | """ 13 | 14 | yaml = YAML() 15 | with open("docker-compose-prod.yaml", "r", encoding="utf-8") as file: 16 | data = yaml.load(file) 17 | 18 | # Replace the value 19 | data["services"]["ib-gateway"]["image"] = config["gateway_image"] 20 | data["services"]["ib-app"]["image"] = config["app_image"] 21 | 22 | # Write the updated data back to the file 23 | with open("docker-compose-prod.yaml", "w", encoding="utf-8") as file: 24 | yaml.dump(data, file) 25 | 26 | string_stream = io.StringIO() 27 | yaml.dump(data, string_stream) 28 | return string_stream.getvalue() 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "ibkr-trading" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Oleg Medvedyev "] 6 | license = "MIT" 7 | 8 | [tool.poetry.group.main.dependencies] 9 | python = "3.11.9" 10 | schedule = ">=1.1.0" 11 | holidays = ">=0.11.1" 12 | pytz = ">=2022.1" 13 | pandas = "^2.0.0" 14 | pydantic = "^2.4.2" 15 | pydantic-settings = "^2.0.3" 16 | ruamel-yaml = "^0.18.6" 17 | sqlalchemy = "^2.0.32" 18 | sqlmodel = "^0.0.21" 19 | exchange-calendars = "^4.5.5" 20 | ib-async = "^1.0.3" 21 | msgpack = "^1.1.0" 22 | 23 | [tool.poetry.group.dev.dependencies] 24 | pylint = ">=2.14.5" 25 | jupyterlab = "^4.0.8" 26 | pulumi = "3.112.0" 27 | pulumi-aws = "6.28.2" 28 | ruff = "^0.6.2" 29 | 30 | [build-system] 31 | requires = ["poetry-core>=1.0.0"] 32 | build-backend = "poetry.core.masonry.api" 33 | 34 | [tool.ruff] 35 | indent-width = 2 36 | lint.ignore = ["E722"] 37 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Trading bot 3 | """ 4 | 5 | import schedule 6 | import time 7 | import sys 8 | 9 | from loguru import logger 10 | 11 | from settings import Settings 12 | from trader.trading_bot import TradingBot 13 | 14 | # Set logging level to INFO 15 | logger.remove() 16 | logger.add(sys.stderr, level="INFO") 17 | 18 | if __name__ == "__main__": 19 | # Create trading bot 20 | settings = Settings() 21 | bot = TradingBot(settings, mocked=False) 22 | 23 | # Clear any existing jobs (in case of previous abrupt exits) 24 | schedule.clear() 25 | 26 | # Schedule the trading loop 27 | schedule.every(1).minute.do(bot.trade_loop) 28 | logger.info("Trading schedule initialized") 29 | 30 | try: 31 | while True: 32 | schedule.run_pending() 33 | time.sleep(1) 34 | except KeyboardInterrupt: 35 | logger.info("Shutting down trading bot gracefully...") 36 | schedule.clear() 37 | sys.exit(0) 38 | -------------------------------------------------------------------------------- /Taskfile.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | env: 4 | AWS_REGION: us-east-1 5 | AWS_AVAILABILITY_ZONE: us-east-1a 6 | GATEWAY_IMAGE: ghcr.io/extrange/ibkr:10.30 7 | APP_IMAGE: ghcr.io/omdv/ib-bots:latest 8 | 9 | tasks: 10 | evd-up: 11 | cmds: 12 | - docker-compose -f docker-compose-evd.yaml up -d 13 | env: 14 | IB_GATEWAY_HOST: localhost 15 | IB_GATEWAY_PORT: 8888 16 | silent: true 17 | 18 | evd-down: 19 | cmds: 20 | - docker-compose -f docker-compose-evd.yaml down 21 | silent: true 22 | 23 | test: 24 | cmds: 25 | - docker-compose -f docker-compose-p4d.yaml up -d 26 | silent: true 27 | 28 | build-app: 29 | cmds: 30 | - cd app && poetry export -f requirements.txt --without-hashes --only main > requirements.txt 31 | - docker build -t omdv/ib-trading-app:latest ./app 32 | 33 | deploy: 34 | cmds: 35 | - pulumi up --yes 36 | 37 | default: 38 | - task: publish-images 39 | - task: deploy 40 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0=" 2 | 3 | use devenv 4 | 5 | # gateway settings 6 | export PAPER_USER_ID="$(gopass show ibkr/paper_user_id)" 7 | export PAPER_PASSWORD="$(gopass show ibkr/paper_user_pass)" 8 | export LIVE_USER_ID="$(gopass show ibkr/live_user_id)" 9 | export LIVE_PASSWORD="$(gopass show ibkr/live_user_pass)" 10 | export IB_TRADING_MODE=live 11 | 12 | # ib-app settings 13 | export IB_GATEWAY_HOST=localhost 14 | export IB_GATEWAY_PORT=8888 15 | export NTFY_TOPIC="$(gopass show api/ntfy.sh/topic)" 16 | export NTFY_ENABLED=false 17 | export STORAGE_PATH="./data/ibkr_data.db" 18 | 19 | # AWS deployment settings 20 | export MY_PUBLIC_KEY="$(cat ~/.ssh/id_rsa.pub)" 21 | export AWS_REGION=us-east-1 22 | export AWS_AVAILABILITY_ZONE=us-east-1a 23 | export GATEWAY_IMAGE=ghcr.io/extrange/ibkr:latest 24 | export APP_IMAGE=ghcr.io/omdv/ib-trading-app:latest 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | fail_fast: false 3 | repos: 4 | - repo: https://github.com/adrienverge/yamllint 5 | rev: v1.27.1 6 | hooks: 7 | - args: 8 | - --config-file 9 | - .github/lint/.yamllint.yaml 10 | id: yamllint 11 | - repo: https://github.com/astral-sh/ruff-pre-commit 12 | rev: v0.6.2 13 | hooks: 14 | - id: ruff 15 | args: [--fix] 16 | # formatter hook 17 | - id: ruff-format 18 | args: [--config, pyproject.toml] 19 | - repo: https://github.com/pre-commit/pre-commit-hooks 20 | rev: v4.3.0 21 | hooks: 22 | - id: trailing-whitespace 23 | - id: end-of-file-fixer 24 | - id: mixed-line-ending 25 | - repo: https://github.com/Lucas-C/pre-commit-hooks 26 | rev: v1.3.0 27 | hooks: 28 | - id: remove-crlf 29 | - id: remove-tabs 30 | - repo: https://github.com/sirosen/fix-smartquotes 31 | rev: 0.2.0 32 | hooks: 33 | - id: fix-smartquotes 34 | - repo: https://github.com/sirwart/ripsecrets.git 35 | rev: v0.1.7 36 | hooks: 37 | - id: ripsecrets 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker-compose-p4d.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | 4 | ib-gateway: 5 | image: ghcr.io/extrange/ibkr:10.30 6 | ports: 7 | - "127.0.0.1:6080:6080" # noVNC browser access 8 | - "127.0.0.1:8888:8888" # API access 9 | ulimits: 10 | nofile: 10000 11 | environment: 12 | USERNAME: ${PAPER_USER_ID} 13 | PASSWORD: ${PAPER_PASSWORD} 14 | TWOFA_TIMEOUT_ACTION: restart 15 | GATEWAY_OR_TWS: gateway 16 | IBC_TradingMode: paper 17 | restart: always 18 | healthcheck: 19 | test: pidof java || exit 1 20 | interval: 30s 21 | timeout: 10s 22 | retries: 3 23 | start_period: 20s 24 | 25 | ib-app-databot: 26 | build: ./app-databot 27 | environment: 28 | - IB_GATEWAY_HOST=ib-gateway 29 | - IB_GATEWAY_PORT=8888 30 | volumes: 31 | # - ./bots:/code/app 32 | - ./downloads:/data 33 | restart: on-failure 34 | healthcheck: 35 | test: pidof python || exit 1 36 | interval: 30s 37 | timeout: 10s 38 | retries: 3 39 | start_period: 20s 40 | depends_on: 41 | ib-gateway: 42 | condition: service_healthy 43 | -------------------------------------------------------------------------------- /infra/docker-compose-vps/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Read config 3 | """ 4 | 5 | import os 6 | 7 | 8 | def read_config(): 9 | """ 10 | Read config from config.json 11 | """ 12 | config = {} 13 | 14 | # vps connection config 15 | config["vps_ip_address"] = os.environ.get("VPS_IP_ADDRESS") 16 | config["vps_ssh_port"] = int(os.environ.get("VPS_SSH_PORT")) 17 | config["vps_ssh_private_key"] = os.environ.get("VPS_SSH_PRIVATE_KEY") 18 | config["vps_ssh_user"] = os.environ.get("VPS_SSH_USER") 19 | 20 | # docker compose config 21 | config["docker_compose_file"] = "docker-compose.yaml" 22 | config["remote_path"] = "/home/om/ibkr" 23 | 24 | # .env config 25 | config["ibkr_user"] = os.environ.get("LIVE_USER_ID") 26 | config["ibkr_password"] = os.environ.get("LIVE_PASSWORD") 27 | config["ib_trading_mode"] = os.environ.get("IB_TRADING_MODE") 28 | config["gh_user"] = os.environ.get("GH_USER") 29 | config["gh_token"] = os.environ.get("GH_TOKEN") 30 | config["ntfy_topic"] = os.environ.get("NTFY_TOPIC") 31 | config["ntfy_enabled"] = os.environ.get("NTFY_ENABLED") 32 | config["storage_path"] = "./data/ibkr_data.db" 33 | 34 | return config 35 | -------------------------------------------------------------------------------- /.github/workflows/publish-containers.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Create and publish docker images 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | publish-app: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - 14 | name: checkout 15 | uses: actions/checkout@v3 16 | - 17 | name: Set up Docker Buildx 18 | uses: docker/setup-buildx-action@v2 19 | - 20 | name: Login to Docker Hub 21 | uses: docker/login-action@v2 22 | with: 23 | username: ${{ secrets.DOCKER_USERNAME }} 24 | password: ${{ secrets.DOCKER_TOKEN }} 25 | - 26 | name: Login to GitHub Container Registry 27 | uses: docker/login-action@v2 28 | with: 29 | registry: ghcr.io 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | - 33 | name: Build and push 34 | uses: docker/build-push-action@v4 35 | with: 36 | push: true 37 | context: ./app 38 | tags: | 39 | omdv/ib-trading-app-demo:latest 40 | ghcr.io/omdv/ib-trading-app-demo:latest 41 | -------------------------------------------------------------------------------- /infra/k8s-deployment/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: ib-gateway 6 | spec: 7 | replicas: 1 8 | selector: 9 | matchLabels: 10 | app: ib-gateway 11 | template: 12 | metadata: 13 | labels: 14 | app: ib-gateway 15 | spec: 16 | containers: 17 | - name: ib-gateway 18 | image: ghcr.io/extrange/ibkr:10.30 19 | ports: 20 | - containerPort: 6080 21 | name: novnc 22 | - containerPort: 8888 23 | name: api 24 | env: 25 | - name: USERNAME 26 | valueFrom: 27 | secretKeyRef: 28 | name: ibkr-gateway-secret 29 | key: username 30 | - name: PASSWORD 31 | valueFrom: 32 | secretKeyRef: 33 | name: ibkr-gateway-secret 34 | key: password 35 | envFrom: 36 | - configMapRef: 37 | name: ibkr-config 38 | resources: 39 | requests: 40 | cpu: 500m 41 | memory: 1024Mi 42 | limits: 43 | cpu: 1000m 44 | memory: 2048Mi 45 | -------------------------------------------------------------------------------- /infra/docker-compose-aws/docker-compose-prod.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | ib-gateway: 4 | image: ghcr.io/extrange/ibkr:latest 5 | ports: 6 | - 127.0.0.1:6080:6080 # noVNC browser access 7 | - 127.0.0.1:8888:8888 # API access 8 | ulimits: 9 | nofile: 10000 10 | environment: 11 | USERNAME: ${PAPER_USER_ID} 12 | PASSWORD: ${PAPER_PASSWORD} 13 | TWOFA_TIMEOUT_ACTION: restart 14 | GATEWAY_OR_TWS: gateway 15 | IBC_TradingMode: paper 16 | IBC_ReadOnlyApi: no 17 | IBC_ReloginAfterSecondFactorAuthenticationTimeout: yes 18 | restart: always 19 | healthcheck: 20 | test: pidof java || exit 1 21 | interval: 30s 22 | timeout: 10s 23 | retries: 3 24 | start_period: 20s 25 | 26 | ib-app: 27 | image: ghcr.io/omdv/ib-trading-app:latest 28 | environment: 29 | - IB_GATEWAY_HOST=ib-gateway 30 | - IB_GATEWAY_PORT=8888 31 | volumes: 32 | - ./downloads:/data 33 | restart: on-failure 34 | healthcheck: 35 | test: pidof python || exit 1 36 | interval: 30s 37 | timeout: 10s 38 | retries: 3 39 | start_period: 20s 40 | depends_on: 41 | ib-gateway: 42 | condition: service_healthy 43 | -------------------------------------------------------------------------------- /docker-compose-evd.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | 4 | ib-gateway: 5 | image: ghcr.io/extrange/ibkr:10.30 6 | ports: 7 | - "127.0.0.1:6080:6080" # noVNC browser access 8 | - "127.0.0.1:8888:8888" # API access 9 | ulimits: 10 | nofile: 10000 11 | environment: 12 | USERNAME: ${LIVE_USER_ID} 13 | PASSWORD: ${LIVE_PASSWORD} 14 | TWOFA_TIMEOUT_ACTION: restart 15 | GATEWAY_OR_TWS: gateway 16 | IBC_TradingMode: live 17 | IBC_ReadOnlyApi: "no" 18 | IBC_ReloginAfterSecondFactorAuthenticationTimeout: "yes" 19 | restart: always 20 | healthcheck: 21 | test: pidof java || exit 1 22 | interval: 30s 23 | timeout: 10s 24 | retries: 3 25 | start_period: 20s 26 | 27 | # ib-trading-app: 28 | # build: 29 | # context: ./app 30 | # dockerfile: Dockerfile 31 | # restart: always 32 | # depends_on: 33 | # - ib-gateway 34 | # environment: 35 | # IBKR_LIVE_USER_ID: ${LIVE_USER_ID} 36 | # IBKR_LIVE_PASSWORD: ${LIVE_PASSWORD} 37 | # TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID} 38 | # TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN} 39 | # TWILIO_WHATSAPP_NUMBER: ${TWILIO_WHATSAPP_NUMBER} 40 | # ADMIN_WHATSAPP_NUMBER: ${ADMIN_WHATSAPP_NUMBER} 41 | -------------------------------------------------------------------------------- /app/services/contract.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | import datetime as dt 3 | import pandas as pd 4 | from ib_async import IB, Contract 5 | from services.utilities import is_market_open 6 | 7 | 8 | class ContractService: 9 | def __init__(self, ibkr: IB, contract: Contract): 10 | self.ibkr = ibkr 11 | self.contract = contract 12 | 13 | def get_current_price(self, price_type: str = "last") -> float: 14 | """ 15 | Get the current price of the contract 16 | """ 17 | logger.debug("Getting price for contract: {}", self.contract) 18 | logger.debug("Contract conId: {}", self.contract.conId) 19 | 20 | # Set market data type based on market status 21 | self.ibkr.reqMarketDataType(1 if is_market_open(self.ibkr) else 2) 22 | 23 | # Qualify the contract 24 | self.ibkr.qualifyContracts(self.contract) 25 | 26 | # Request market data 27 | ticker = self.ibkr.reqMktData(self.contract) 28 | 29 | # Wait for market data to arrive (timeout after 20 seconds) 30 | timeout = 20 31 | start_time = dt.datetime.now() 32 | while (not ticker.last or pd.isna(ticker.last)) and ( 33 | dt.datetime.now() - start_time 34 | ).seconds < timeout: 35 | self.ibkr.sleep(0.1) 36 | 37 | price = getattr(ticker, price_type) 38 | if not price or pd.isna(price): 39 | logger.error("Could not get price data within timeout period") 40 | 41 | # Cancel the market data subscription when done 42 | self.ibkr.cancelMktData(self.contract) 43 | 44 | return price 45 | -------------------------------------------------------------------------------- /charts/ibkr-bot/values.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | nameOverride: "" 3 | fullnameOverride: "ibkr-bot" 4 | 5 | serviceAccount: 6 | create: true 7 | annotations: {} 8 | name: "" 9 | 10 | gateway: 11 | replicaCount: 1 12 | image: 13 | repository: ghcr.io/extrange/ibkr 14 | pullPolicy: IfNotPresent 15 | tag: "10.32.1l" 16 | 17 | # The name of the secret containing the credentials for the gateway 18 | # expecting USERNAME and PASSWORD variables 19 | existingSecret: "ibkr-gateway-secret" 20 | 21 | parameters: 22 | TWOFA_TIMEOUT_ACTION: "restart" 23 | GATEWAY_OR_TWS: "gateway" 24 | IBC_TradingMode: "live" 25 | IBC_ReadOnlyApi: "no" 26 | IBC_ReloginAfterSecondFactorAuthenticationTimeout: "yes" 27 | ports: 28 | api: "8888" 29 | vnc: "6080" 30 | imagePullSecrets: [] 31 | podAnnotations: {} 32 | podSecurityContext: {} 33 | securityContext: {} 34 | resources: {} 35 | nodeSelector: {} 36 | tolerations: [] 37 | affinity: {} 38 | 39 | application: 40 | replicaCount: 1 41 | image: 42 | repository: ghcr.io/omdv/ib-trading-app 43 | pullPolicy: Always 44 | tag: "latest" 45 | existingConfigMap: "ibkr-app-config" 46 | existingSecret: "ibkr-app-secret" 47 | 48 | # needs existing claim if enabled 49 | persistence: 50 | enabled: false 51 | existingClaim: "" 52 | mountPath: "/data" 53 | 54 | imagePullSecrets: [] 55 | podAnnotations: {} 56 | podSecurityContext: {} 57 | securityContext: {} 58 | resources: {} 59 | nodeSelector: {} 60 | tolerations: [] 61 | affinity: {} 62 | -------------------------------------------------------------------------------- /infra/docker-compose-vps/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | ib-gateway: 4 | image: ghcr.io/extrange/ibkr:10.32 5 | ports: 6 | - 127.0.0.1:6080:6080 # noVNC browser access 7 | - 127.0.0.1:8888:8888 # API access 8 | ulimits: 9 | nofile: 10000 10 | environment: 11 | USERNAME: ${IBKR_USER} 12 | PASSWORD: ${IBKR_PASSWORD} 13 | TWOFA_TIMEOUT_ACTION: restart 14 | GATEWAY_OR_TWS: gateway 15 | IBC_TradingMode: live 16 | IBC_ReadOnlyApi: "no" 17 | IBC_ReloginAfterSecondFactorAuthenticationTimeout: "yes" 18 | restart: always 19 | healthcheck: 20 | test: pidof java || exit 1 21 | interval: 30s 22 | timeout: 10s 23 | retries: 3 24 | start_period: 20s 25 | 26 | ib-app: 27 | image: ghcr.io/omdv/ib-trading-app:latest 28 | environment: 29 | - IB_GATEWAY_HOST=ib-gateway 30 | - IB_GATEWAY_PORT=8888 31 | - NTFY_ENABLED=${NTFY_ENABLED} 32 | - NTFY_TOPIC=${NTFY_TOPIC} 33 | volumes: 34 | - ./downloads:/data 35 | restart: on-failure 36 | healthcheck: 37 | test: pidof python || exit 1 38 | interval: 30s 39 | timeout: 10s 40 | retries: 3 41 | start_period: 20s 42 | depends_on: 43 | ib-gateway: 44 | condition: service_healthy 45 | 46 | watchtower: 47 | image: containrrr/watchtower:latest 48 | volumes: 49 | - /var/run/docker.sock:/var/run/docker.sock 50 | - $HOME/.docker/config.json:/config.json 51 | command: --interval 300 --cleanup 52 | -------------------------------------------------------------------------------- /app/models/filled_trade.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from sqlmodel import Field, SQLModel 3 | from ib_async.order import Trade 4 | from storage.db import DB 5 | from .option_spread import OptionSpread 6 | 7 | 8 | class FilledTrade(SQLModel, table=True): 9 | """ 10 | Custom FilledTrade model with SQL support 11 | """ 12 | 13 | __tablename__ = "trades" 14 | 15 | id: int = Field(primary_key=True) 16 | timestamp: datetime = Field(default_factory=datetime.now) 17 | underlying: str 18 | net_value: float 19 | expiry: str 20 | symbol: str 21 | strike: float 22 | width: float 23 | delta: float 24 | size: int 25 | right: str 26 | price: float 27 | commission: float 28 | 29 | def __init__(self, contract: OptionSpread, trade: Trade, account_value: float): 30 | self.timestamp = datetime.now() 31 | self.expiry = contract.expiry 32 | self.underlying = contract.legs[0].symbol 33 | self.delta = contract.delta 34 | self.net_value = account_value 35 | self.symbol = str(contract) 36 | self.strike = contract.strike 37 | self.width = contract.width 38 | self.size = contract.size 39 | self.right = contract.right 40 | self.price = trade.orderStatus.avgFillPrice 41 | self.commission = sum(fill.commissionReport.commission for fill in trade.fills) 42 | 43 | def save(self, db: DB) -> "FilledTrade": 44 | """Save the FilledTrade to the database""" 45 | with db.get_session() as session: 46 | session.add(self) 47 | session.commit() 48 | session.refresh(self) 49 | return self 50 | -------------------------------------------------------------------------------- /terraform/google/network.tf: -------------------------------------------------------------------------------- 1 | resource "google_compute_network" "default" { 2 | project = google_project.project.project_id 3 | name = var.network 4 | routing_mode = "REGIONAL" 5 | auto_create_subnetworks = false 6 | mtu = 1500 7 | 8 | depends_on = [ 9 | google_project_service.gcp_services, 10 | ] 11 | } 12 | 13 | resource "google_compute_subnetwork" "default" { 14 | project = google_project.project.project_id 15 | name = var.subnetwork 16 | ip_cidr_range = var.subnet_cidr 17 | private_ip_google_access = true 18 | region = var.region 19 | network = var.network 20 | 21 | depends_on = [ 22 | google_compute_network.default, 23 | ] 24 | } 25 | 26 | resource "google_compute_firewall" "allow-ssh" { 27 | project = google_project.project.project_id 28 | name = "${var.network}-ssh" 29 | network = var.network 30 | direction = "INGRESS" 31 | 32 | allow { 33 | protocol = "tcp" 34 | ports = ["22"] 35 | } 36 | 37 | target_tags = [var.project_name] 38 | 39 | depends_on = [ 40 | google_compute_network.default, 41 | ] 42 | } 43 | 44 | resource "google_compute_firewall" "allow-api" { 45 | project = google_project.project.project_id 46 | name = "${var.network}-api" 47 | network = var.network 48 | 49 | allow { 50 | protocol = "tcp" 51 | ports = ["4041", "4042"] 52 | } 53 | 54 | source_tags = [var.app_vm_name] 55 | target_tags = [var.gateway_vm_name] 56 | 57 | depends_on = [ 58 | google_compute_network.default, 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /app/services/utilities.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions 3 | """ 4 | 5 | from loguru import logger 6 | import datetime as dt 7 | from exchange_calendars import get_calendar 8 | 9 | from ib_async import IB 10 | from ib_async.contract import Option 11 | 12 | 13 | def is_market_open(ibkr: IB) -> bool: 14 | """ 15 | Check if the market is open 16 | """ 17 | nyse = get_calendar("XNYS") 18 | current_time = dt.datetime.now(dt.UTC) 19 | is_market_open = nyse.is_open_on_minute(current_time) 20 | logger.debug("Market is open: {}", is_market_open) 21 | return is_market_open 22 | 23 | 24 | def next_trading_day() -> str: 25 | """ 26 | Get the next trading day 27 | """ 28 | nyse = get_calendar("XNYS") 29 | today = dt.datetime.now() 30 | 31 | # Get the next trading day after today (regardless of market status) 32 | next_trading_day = nyse.next_open(today.date() + dt.timedelta(days=1)).strftime( 33 | "%Y%m%d" 34 | ) 35 | logger.debug("Next trading day: {}", next_trading_day) 36 | return next_trading_day 37 | 38 | 39 | def get_delta(ibkr: IB, contracts: list[Option]) -> float: 40 | """ 41 | Get the delta for an option contract 42 | TODO: modelGreeks vs lastGreeks 43 | """ 44 | ibkr.reqMarketDataType(1 if is_market_open(ibkr) else 4) 45 | 46 | logger.debug("Getting delta for contracts: {}", contracts) 47 | ibkr.qualifyContracts(*contracts) 48 | tickers = ibkr.reqTickers(*contracts) 49 | 50 | deltas = [] 51 | for ticker in tickers: 52 | try: 53 | deltas.append(ticker.modelGreeks.delta) 54 | except Exception as e: 55 | logger.error("Error getting delta: {}", e) 56 | deltas.append(0) 57 | logger.debug("Deltas: {}", deltas) 58 | return deltas 59 | -------------------------------------------------------------------------------- /app/ib_mock/gen_positions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generate random mock positions 3 | """ 4 | 5 | import random 6 | import datetime as dt 7 | from ib_async.objects import Position, Contract 8 | 9 | from .common import local_symbol, contract_id 10 | 11 | random.seed(42) 12 | 13 | 14 | def gen_positions(num_positions: int = 10) -> list[Position]: 15 | """Generate random positions""" 16 | account = "U1234567" 17 | positions = [ 18 | Position( 19 | account=account, 20 | contract=Contract( 21 | symbol="SPX", 22 | secType="STK", 23 | exchange="BATS", 24 | currency="USD", 25 | localSymbol="SPX", 26 | tradingClass="SPX", 27 | ), 28 | position=500.0, 29 | avgCost=100.0, 30 | ), 31 | Position( 32 | account=account, 33 | contract=Contract( 34 | symbol="SPX", 35 | secType="OPT", 36 | lastTradeDateOrContractMonth=dt.datetime.now().strftime("%Y%m%d"), 37 | strike=6000.0, 38 | right="P", 39 | multiplier="100", 40 | currency="USD", 41 | tradingClass="SPXW", 42 | ), 43 | position=10.0, 44 | avgCost=128.70595, 45 | ), 46 | Position( 47 | account=account, 48 | contract=Contract( 49 | symbol="SPX", 50 | secType="OPT", 51 | lastTradeDateOrContractMonth=dt.datetime.now().strftime("%Y%m%d"), 52 | strike=6100.0, 53 | right="P", 54 | multiplier="100", 55 | currency="USD", 56 | tradingClass="SPXW", 57 | ), 58 | position=-10.0, 59 | avgCost=88.79595, 60 | ), 61 | ] 62 | for position in positions: 63 | position.contract.localSymbol = local_symbol(position.contract) 64 | position.contract.conId = contract_id(position.contract) 65 | return positions 66 | -------------------------------------------------------------------------------- /terraform/google/main.tf: -------------------------------------------------------------------------------- 1 | provider "google" { 2 | zone = var.zone 3 | } 4 | 5 | data "google_billing_account" "account" { 6 | display_name = "My Billing Account" 7 | open = true 8 | } 9 | 10 | # Generate a random string to append to resource names. 11 | resource "random_string" "random" { 12 | length = 6 13 | upper = false 14 | special = false 15 | } 16 | 17 | resource "google_project" "project" { 18 | name = var.project_name 19 | project_id = "${var.project_name}-${random_string.random.result}" 20 | billing_account = data.google_billing_account.account.id 21 | auto_create_network = false 22 | labels = var.labels 23 | } 24 | 25 | resource "google_project_service" "gcp_services" { 26 | count = length(var.gcp_service_list) 27 | project = google_project.project.project_id 28 | service = var.gcp_service_list[count.index] 29 | disable_dependent_services = true 30 | } 31 | 32 | # Creates a GCS bucket to store tfstate. 33 | resource "google_storage_bucket" "tfstate" { 34 | name = "${google_project.project.project_id}-tfstate" 35 | location = var.region 36 | project = google_project.project.project_id 37 | } 38 | 39 | # Creates a GCS bucket to store data. 40 | resource "google_storage_bucket" "data" { 41 | name = "${google_project.project.project_id}-data" 42 | location = var.region 43 | project = google_project.project.project_id 44 | } 45 | 46 | # resource "google_app_engine_application" "app" { 47 | # project = var.project_id 48 | # location_id = var.app-engine-location 49 | # } 50 | 51 | # resource "google_project_iam_binding" "project" { 52 | # role = "roles/viewer" 53 | 54 | # members = [ 55 | # "serviceAccount:${google_project.project.number}@cloudbuild.gserviceaccount.com" 56 | # ] 57 | 58 | # depends_on = [ 59 | # google_project.project 60 | # ] 61 | # } 62 | -------------------------------------------------------------------------------- /app/services/positions_parser.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from loguru import logger 3 | from ib_async import IB, Position 4 | from models import OptionSpread, OptionWithSize 5 | from services.utilities import get_delta 6 | 7 | 8 | class PositionsService: 9 | def __init__(self, ibkr: IB, positions: list[Position]): 10 | self.ibkr = ibkr 11 | self.positions = positions 12 | 13 | def get_option_spreads(self) -> list[OptionSpread]: 14 | """ 15 | Get the option spreads from the positions by grouping options by expiry and symbol 16 | """ 17 | 18 | # Filter and create list of option contracts 19 | option_positions = [] 20 | for pos in self.positions: 21 | if pos.contract.secType == "OPT": 22 | logger.debug("Found option contract: {}", pos) 23 | option_positions.append( 24 | { 25 | "contract": pos.contract, 26 | "position_size": int(pos.position), 27 | "symbol": pos.contract.symbol, 28 | "expiry": pos.contract.lastTradeDateOrContractMonth, 29 | } 30 | ) 31 | df = pd.DataFrame(option_positions) 32 | grouped = df.groupby(["expiry", "symbol"]) 33 | 34 | contracts = df["contract"].tolist() 35 | deltas = get_delta(self.ibkr, contracts) 36 | 37 | # Assign deltas back to contracts 38 | for contract, delta in zip(contracts, deltas): 39 | contract.delta = delta 40 | 41 | # Create OptionSpread objects 42 | spreads = [] 43 | for (expiry, symbol), group in grouped: 44 | if len(group) > 1: # Only create spreads with multiple legs 45 | legs = [] 46 | for leg_data in group.to_dict("records"): 47 | option_with_size = OptionWithSize( 48 | option=leg_data["contract"], 49 | position_size=leg_data["position_size"], 50 | delta=leg_data["contract"].delta, 51 | ) 52 | legs.append(option_with_size) 53 | spreads.append(OptionSpread(legs=legs)) 54 | return spreads 55 | -------------------------------------------------------------------------------- /charts/ibkr-bot/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "ibkr.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "ibkr.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "ibkr.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "ibkr.labels" -}} 37 | helm.sh/chart: {{ include "ibkr.chart" . }} 38 | {{ include "ibkr.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "ibkr.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "ibkr.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "ibkr.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "ibkr.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /terraform/google/application.tf: -------------------------------------------------------------------------------- 1 | module "ib-app" { 2 | source = "terraform-google-modules/container-vm/google" 3 | version = "~> 2.0" 4 | 5 | container = { 6 | image = var.app_image 7 | tty = true 8 | env = [ 9 | { 10 | name = "IB_GATEWAY_HOST" 11 | value = var.ib_gateway_internal_ip 12 | }, 13 | { 14 | name = "IB_GATEWAY_PORT" 15 | value = var.ib_gateway_port 16 | }, 17 | { 18 | name = "GCS_BUCKET_NAME" 19 | value = google_storage_bucket.data.name 20 | }, 21 | { 22 | name = "STORAGE_BACKEND" 23 | value = "gcs" 24 | } 25 | ] 26 | } 27 | restart_policy = "Always" 28 | } 29 | 30 | resource "google_compute_instance" "ib-app" { 31 | project = google_project.project.project_id 32 | machine_type = var.app_machine_type 33 | zone = var.zone 34 | name = var.app_vm_name 35 | allow_stopping_for_update = true 36 | 37 | boot_disk { 38 | initialize_params { 39 | image = module.ib-app.source_image 40 | } 41 | } 42 | 43 | network_interface { 44 | network = google_compute_network.default.id 45 | subnetwork = google_compute_subnetwork.default.id 46 | access_config {} 47 | } 48 | 49 | metadata = { 50 | gce-container-declaration = module.ib-app.metadata_value 51 | google-logging-enabled = var.app_logging_enabled 52 | google-monitoring-enabled = var.app_monitoring_enabled 53 | } 54 | 55 | labels = { 56 | container-vm = module.ib-app.vm_container_label 57 | } 58 | 59 | tags = [var.project_name, var.app_vm_name] 60 | 61 | service_account { 62 | scopes = [ 63 | "https://www.googleapis.com/auth/compute", 64 | "https://www.googleapis.com/auth/devstorage.read_write", 65 | "https://www.googleapis.com/auth/datastore", 66 | "logging-write", 67 | "monitoring-write" 68 | ] 69 | } 70 | 71 | depends_on = [ 72 | google_compute_subnetwork.default, 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /infra/docker-compose-aws/ec2_helper.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper for EC2 instance creation 3 | """ 4 | 5 | import pulumi_aws as aws 6 | 7 | 8 | def create_ec2(config, docker_compose_content): 9 | """ 10 | Main function 11 | """ 12 | 13 | # Create an EC2 Key Pair with the public key 14 | key_pair = aws.ec2.KeyPair( 15 | "my-key-pair", public_key=config["public_key"], key_name="my-key-pair" 16 | ) 17 | 18 | # Create a VPC 19 | vpc = aws.ec2.Vpc( 20 | "my-vpc", 21 | cidr_block="10.0.0.0/16", 22 | tags={ 23 | "Name": "my-vpc", 24 | }, 25 | ) 26 | 27 | # Create a subnet within the VPC 28 | subnet = aws.ec2.Subnet( 29 | "my-subnet", 30 | vpc_id=vpc.id, 31 | cidr_block="10.0.1.0/24", 32 | availability_zone=config["aws_availability_zone"], 33 | tags={ 34 | "Name": "my-subnet", 35 | }, 36 | ) 37 | 38 | # Create a security group allowing SSH access only 39 | security_group = aws.ec2.SecurityGroup( 40 | "security-group", 41 | vpc_id=vpc.id, 42 | description="Allow SSH traffic", 43 | ingress=[ 44 | aws.ec2.SecurityGroupIngressArgs( 45 | protocol="tcp", 46 | from_port=22, 47 | to_port=22, 48 | cidr_blocks=["0.0.0.0/0"], 49 | ), 50 | ], 51 | egress=[ 52 | aws.ec2.SecurityGroupEgressArgs( 53 | protocol="-1", # All traffic 54 | from_port=0, 55 | to_port=0, 56 | cidr_blocks=["0.0.0.0/0"], 57 | ), 58 | ], 59 | ) 60 | 61 | # Fetch the most recent Amazon Linux 2 AMI. 62 | ami = aws.ec2.get_ami( 63 | most_recent=True, 64 | owners=["amazon"], 65 | filters=[ 66 | { 67 | "name": "name", 68 | "values": ["amzn2-ami-hvm-*-x86_64-gp2"], 69 | } 70 | ], 71 | ) 72 | 73 | # Create an EC2 instance using the created security group and SSH key pair 74 | ec2_instance = aws.ec2.Instance( 75 | "ec2-instance", 76 | ami=ami.id, 77 | instance_type="t2.micro", 78 | security_groups=[security_group.id], 79 | subnet_id=subnet.id, 80 | key_name=key_pair.key_name, 81 | ) 82 | 83 | return ec2_instance 84 | -------------------------------------------------------------------------------- /terraform/google/outputs.tf: -------------------------------------------------------------------------------- 1 | output "project_name" { 2 | description = "Project name" 3 | value = var.project_name 4 | } 5 | 6 | output "project_id" { 7 | description = "Project ID" 8 | value = google_project.project.project_id 9 | } 10 | 11 | output "gateway_name" { 12 | description = "Gateway instance name" 13 | value = var.gateway_vm_name 14 | } 15 | 16 | output "app_name" { 17 | description = "App instance name" 18 | value = var.app_vm_name 19 | } 20 | 21 | output "gateway-internal-ip" { 22 | description = "Internal IP of gateway" 23 | value = google_compute_address.ib-gateway-internal-ip.address 24 | } 25 | 26 | output "gateway-external-ip" { 27 | description = "External IP of gateway" 28 | value = google_compute_instance.ib-gateway.network_interface.0.access_config.0.nat_ip 29 | } 30 | 31 | output "app-internal-ip" { 32 | description = "Internal IP of gateway" 33 | value = google_compute_instance.ib-app.network_interface.0.network_ip 34 | } 35 | 36 | output "app-external-ip" { 37 | description = "External IP of gateway" 38 | value = google_compute_instance.ib-app.network_interface.0.access_config.0.nat_ip 39 | } 40 | 41 | output "app-container" { 42 | description = "The container metadata provided to the module" 43 | value = module.ib-app.container 44 | } 45 | 46 | output "gateway-container" { 47 | description = "The container metadata provided to the module" 48 | value = module.ib-gateway.container 49 | } 50 | 51 | output "app-machine-type" { 52 | value = var.app_machine_type 53 | } 54 | 55 | output "gateway-machine-type" { 56 | value = var.gateway_machine_type 57 | } 58 | 59 | output "gateway-label" { 60 | description = "The instance label containing container configuration" 61 | value = module.ib-gateway.vm_container_label 62 | } 63 | 64 | output "app-label" { 65 | description = "The instance label containing container configuration" 66 | value = module.ib-app.vm_container_label 67 | } 68 | 69 | output "network" { 70 | description = "GCP network" 71 | value = var.network 72 | } 73 | 74 | output "subnetwork" { 75 | description = "GCP subnetwork" 76 | value = var.subnetwork 77 | } 78 | -------------------------------------------------------------------------------- /app/ib_mock/gen_tickers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generate mocked tickers 3 | """ 4 | 5 | from datetime import datetime as dt 6 | from ib_async import Ticker, OptionComputation, Contract 7 | from trader.trading_logic import next_trading_day 8 | from .common import local_symbol, contract_id 9 | 10 | 11 | def gen_tickers(): 12 | """Generate mocked tickers""" 13 | tickers = [ 14 | Ticker( 15 | contract=Contract( 16 | secType="IND", 17 | symbol="SPX", 18 | ), 19 | last=6200.0, 20 | ), 21 | Ticker( 22 | contract=Contract( 23 | secType="OPT", 24 | symbol="SPX", 25 | tradingClass="SPXW", 26 | lastTradeDateOrContractMonth=dt.now().strftime("%Y%m%d"), 27 | strike=6100.0, 28 | right="P", 29 | ), 30 | modelGreeks=OptionComputation( 31 | tickAttrib=1, 32 | impliedVol=0.5, 33 | delta=-0.001, 34 | optPrice=0.5, 35 | pvDividend=0.0, 36 | gamma=0.5, 37 | vega=0.5, 38 | theta=0.5, 39 | undPrice=100.0, 40 | ), 41 | ), 42 | ] 43 | 44 | expiry = next_trading_day() 45 | for strike in [ 46 | 5910, 47 | 5900, 48 | 5890, 49 | 5880, 50 | 5870, 51 | 5860, 52 | 5850, 53 | 5840, 54 | 5830, 55 | 5820, 56 | 5810, 57 | 5800, 58 | ]: 59 | tickers.append( 60 | Ticker( 61 | contract=Contract( 62 | secType="OPT", 63 | symbol="SPX", 64 | lastTradeDateOrContractMonth=expiry, 65 | right="P", 66 | strike=strike, 67 | tradingClass="SPXW", 68 | ), 69 | modelGreeks=OptionComputation( 70 | tickAttrib=1, 71 | impliedVol=0.5, 72 | delta=-0.07 + 0.001 * strike, 73 | optPrice=0.5, 74 | pvDividend=0.0, 75 | gamma=0.5, 76 | vega=0.5, 77 | theta=0.5, 78 | undPrice=100.0, 79 | ), 80 | ask=round(strike * 0.01005, 2), 81 | bid=round(strike * 0.01001, 2), 82 | last=round(strike * 0.01003, 2), 83 | ) 84 | ) 85 | 86 | for ticker in tickers: 87 | ticker.contract.conId = contract_id(ticker.contract) 88 | ticker.contract.localSymbol = local_symbol(ticker.contract) 89 | 90 | return {ticker.contract.conId: ticker for ticker in tickers} 91 | -------------------------------------------------------------------------------- /infra/docker-compose-vps/vps_setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper for VPS setup 3 | """ 4 | 5 | from loguru import logger 6 | import pulumi 7 | import pulumi_command as command 8 | 9 | 10 | def setup_vps(config): 11 | """ 12 | Setup VPS by installing Docker Compose and other dependencies 13 | """ 14 | # Create remote connection to execute commands 15 | logger.info(f"Connecting to {config['vps_ip_address']}") 16 | connection = command.remote.ConnectionArgs( 17 | host=config["vps_ip_address"], 18 | port=config["vps_ssh_port"], 19 | private_key=config["vps_ssh_private_key"], 20 | user=config["vps_ssh_user"], 21 | ) 22 | 23 | logger.info("Updating apt") 24 | update_apt = command.remote.Command( 25 | "update-apt", connection=connection, create="sudo apt-get update -y" 26 | ) 27 | 28 | logger.info("Installing Docker Compose") 29 | install_docker_compose = command.remote.Command( 30 | "install-docker-compose", 31 | connection=connection, 32 | create="\n".join( 33 | [ 34 | # Add Docker's official GPG key 35 | "sudo apt-get update", 36 | "sudo apt-get install -y ca-certificates curl", 37 | "sudo install -m 0755 -d /etc/apt/keyrings", 38 | "sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", 39 | "sudo chmod a+r /etc/apt/keyrings/docker.asc", 40 | # Add the repository to Apt sources 41 | 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null', 42 | "sudo apt-get update", 43 | # Install Docker and Docker Compose 44 | "sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin", 45 | # Add user to Docker group 46 | "sudo usermod -aG docker om", 47 | ] 48 | ), 49 | opts=pulumi.ResourceOptions(depends_on=[update_apt]), 50 | ) 51 | 52 | logger.info("Restarting Docker") 53 | restart_docker = command.remote.Command( 54 | "restart-docker", 55 | connection=connection, 56 | create="sudo systemctl restart docker", 57 | opts=pulumi.ResourceOptions( 58 | depends_on=[ 59 | install_docker_compose, 60 | ], 61 | ), 62 | ) 63 | 64 | return restart_docker 65 | -------------------------------------------------------------------------------- /terraform/google/gateway.tf: -------------------------------------------------------------------------------- 1 | module "ib-gateway" { 2 | source = "terraform-google-modules/container-vm/google" 3 | version = "~> 2.0" 4 | 5 | container = { 6 | image = var.gateway_image 7 | tty = true 8 | env = [ 9 | { 10 | name = "TRADING_MODE" 11 | value = var.TRADING_MODE 12 | }, 13 | { 14 | name = "TWSPASSWORD" 15 | value = var.TWS_PASSWORD 16 | }, 17 | { 18 | name = "TWSUSERID" 19 | value = var.TWS_USER_ID 20 | } 21 | ] 22 | } 23 | restart_policy = "Always" 24 | } 25 | 26 | resource "google_compute_address" "ib-gateway-internal-ip" { 27 | project = google_project.project.project_id 28 | name = "ib-gateway-internal-address" 29 | subnetwork = var.subnetwork 30 | address_type = "INTERNAL" 31 | address = var.ib_gateway_internal_ip 32 | region = var.region 33 | purpose = "GCE_ENDPOINT" 34 | depends_on = [ 35 | google_compute_subnetwork.default, 36 | ] 37 | } 38 | 39 | resource "google_compute_instance" "ib-gateway" { 40 | project = google_project.project.project_id 41 | machine_type = var.gateway_machine_type 42 | zone = var.zone 43 | name = var.gateway_vm_name 44 | allow_stopping_for_update = true 45 | 46 | boot_disk { 47 | initialize_params { 48 | image = module.ib-gateway.source_image 49 | } 50 | } 51 | 52 | network_interface { 53 | network = google_compute_network.default.id 54 | subnetwork = google_compute_subnetwork.default.id 55 | network_ip = var.ib_gateway_internal_ip 56 | access_config {} 57 | } 58 | 59 | metadata = { 60 | gce-container-declaration = module.ib-gateway.metadata_value 61 | google-logging-enabled = var.gateway_logging_enabled 62 | google-monitoring-enabled = var.gateway_monitoring_enabled 63 | } 64 | 65 | labels = { 66 | container-vm = module.ib-gateway.vm_container_label 67 | } 68 | 69 | tags = [var.project_name, var.gateway_vm_name] 70 | 71 | service_account { 72 | scopes = [ 73 | "https://www.googleapis.com/auth/compute", 74 | "https://www.googleapis.com/auth/devstorage.read_write", 75 | "logging-write", 76 | "monitoring-write" 77 | ] 78 | } 79 | 80 | depends_on = [ 81 | google_compute_address.ib-gateway-internal-ip, 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /app/models/option_spread.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from sqlmodel import Field, SQLModel 3 | 4 | from .option_sized import OptionWithSize, OptionWithSizeListEncoder 5 | from storage.db import DB 6 | 7 | 8 | class OptionSpread(SQLModel, table=True): 9 | """ 10 | Custom OptionSpread model with SQL support 11 | """ 12 | 13 | __tablename__ = "option_spreads" 14 | 15 | timestamp: dt.datetime = Field(default_factory=dt.datetime.now) 16 | 17 | id: int = Field(primary_key=True) 18 | expiry: str 19 | symbol: str 20 | tradingClass: str 21 | size: int 22 | strike: float 23 | width: float 24 | delta: float 25 | right: str 26 | legs: list[OptionWithSize] = Field(sa_type=OptionWithSizeListEncoder) 27 | 28 | @staticmethod 29 | def validate_legs(legs: list[OptionWithSize]) -> bool: 30 | """Validate the legs of the spread""" 31 | if len(legs) != 2: 32 | raise ValueError("Spread must have exactly 2 legs") 33 | 34 | if legs[0].position_size + legs[1].position_size != 0: 35 | raise ValueError("Legs must have opposite sizes") 36 | 37 | if legs[0].right != legs[1].right: 38 | raise ValueError("Both legs must be the same type (calls or puts)") 39 | 40 | return True 41 | 42 | def __init__(self, legs: list[OptionWithSize]): 43 | """Initialize an option spread""" 44 | self.validate_legs(legs) 45 | 46 | self.legs = sorted(legs, key=lambda x: x.position_size) 47 | 48 | self.expiry = self.legs[0].lastTradeDateOrContractMonth 49 | self.symbol = self.legs[0].symbol 50 | self.size = abs(self.legs[0].position_size) 51 | self.right = self.legs[0].right 52 | self.width = abs(self.legs[0].strike - self.legs[1].strike) 53 | self.strike = ( 54 | self.legs[0].strike if self.legs[0].right == "P" else self.legs[1].strike 55 | ) 56 | self.tradingClass = self.legs[0].tradingClass 57 | self.delta = self.legs[0].delta 58 | 59 | def __str__(self) -> str: 60 | """Return a string description of the spread""" 61 | return f"SHORT {self.legs[0]} | LONG {str(self.legs[1])}" 62 | 63 | def __repr__(self) -> str: 64 | """Return a string representation of the OptionSpread""" 65 | return f"OptionSpread(legs={self.legs})" 66 | 67 | def save(self, db: DB) -> "OptionSpread": 68 | """Save the OptionSpread to the database""" 69 | with db.get_session() as session: 70 | session.add(self) 71 | session.commit() 72 | session.refresh(self) 73 | return self 74 | -------------------------------------------------------------------------------- /app/messaging/ntfy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Send messages via ntfy.sh 3 | """ 4 | 5 | import requests 6 | from typing import List 7 | from ib_async.contract import Contract 8 | 9 | from models import OptionSpread 10 | from settings import Settings 11 | 12 | 13 | class MessageHandler: 14 | def __init__(self, settings: Settings): 15 | self.ntfy_topic = settings.ntfy_topic 16 | self.ntfy_enabled = settings.ntfy_enabled 17 | self.ntfy_url = f"https://ntfy.sh/{self.ntfy_topic}" 18 | 19 | def send_positions(self, positions: List[OptionSpread]) -> None: 20 | """Send option spreads update via ntfy.sh""" 21 | message_body = self._format_option_spreads(positions) 22 | self._send_message(message_body) 23 | 24 | def send_target_spread(self, contract: Contract) -> None: 25 | """Send target spread update via ntfy.sh""" 26 | message_body = self._format_option_spread(contract) 27 | self._send_message(message_body) 28 | 29 | def _send_message(self, message: str, priority: str = "default") -> None: 30 | """Helper method to send message via ntfy.sh""" 31 | if not self.ntfy_enabled: 32 | return 33 | 34 | try: 35 | requests.post( 36 | self.ntfy_url, 37 | data=message.encode("utf-8"), 38 | headers={ 39 | "Title": "Trading Bot Update", 40 | "Priority": priority, 41 | "Tags": "trading-bot", 42 | }, 43 | ) 44 | except Exception as e: 45 | print(f"Failed to send notification: {str(e)}") 46 | 47 | def _format_option_spreads(self, positions: List[OptionSpread]) -> str: 48 | """Format multiple option spreads into readable message""" 49 | message = "Current positions:\n" 50 | for position in positions: 51 | message += self._format_option_spread(position) 52 | return message 53 | 54 | def _format_option_spread(self, spread: OptionSpread) -> str: 55 | """Format one option spread into readable message""" 56 | message = f"{str(spread)}\n" 57 | return message 58 | 59 | def send_alert(self, alert_message: str) -> None: 60 | """Send immediate alert message""" 61 | self._send_message(f"🚨 ALERT: {alert_message}", priority="urgent") 62 | 63 | def send_notification(self, notification_message: str) -> None: 64 | """Send notification message""" 65 | self._send_message(f"📢 {notification_message}", priority="default") 66 | 67 | 68 | if __name__ == "__main__": 69 | message_handler = MessageHandler() 70 | 71 | # Send an alert 72 | message_handler.send_alert("High volatility detected!") 73 | -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | aiohappyeyeballs==2.4.3 ; python_full_version == "3.11.9" 2 | aiohttp-retry==2.9.1 ; python_full_version == "3.11.9" 3 | aiohttp==3.10.10 ; python_full_version == "3.11.9" 4 | aiosignal==1.3.1 ; python_full_version == "3.11.9" 5 | annotated-types==0.7.0 ; python_full_version == "3.11.9" 6 | attrs==24.2.0 ; python_full_version == "3.11.9" 7 | certifi==2024.8.30 ; python_full_version == "3.11.9" 8 | charset-normalizer==3.4.0 ; python_full_version == "3.11.9" 9 | eventkit==1.0.3 ; python_full_version == "3.11.9" 10 | exchange-calendars==4.5.7 ; python_full_version == "3.11.9" 11 | frozenlist==1.5.0 ; python_full_version == "3.11.9" 12 | greenlet==3.1.1 ; python_full_version == "3.11.9" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") 13 | holidays==0.60 ; python_full_version == "3.11.9" 14 | ib-async==1.0.3 ; python_full_version == "3.11.9" 15 | idna==3.10 ; python_full_version == "3.11.9" 16 | korean-lunar-calendar==0.3.1 ; python_full_version == "3.11.9" 17 | multidict==6.1.0 ; python_full_version == "3.11.9" 18 | nest-asyncio==1.6.0 ; python_full_version == "3.11.9" 19 | numpy==2.1.3 ; python_full_version == "3.11.9" 20 | pandas==2.2.3 ; python_full_version == "3.11.9" 21 | propcache==0.2.0 ; python_full_version == "3.11.9" 22 | pydantic-core==2.23.4 ; python_full_version == "3.11.9" 23 | pydantic-settings==2.6.1 ; python_full_version == "3.11.9" 24 | pydantic==2.9.2 ; python_full_version == "3.11.9" 25 | pyjwt==2.9.0 ; python_full_version == "3.11.9" 26 | pyluach==2.2.0 ; python_full_version == "3.11.9" 27 | python-dateutil==2.9.0.post0 ; python_full_version == "3.11.9" 28 | python-dotenv==1.0.1 ; python_full_version == "3.11.9" 29 | pytz==2024.2 ; python_full_version == "3.11.9" 30 | requests==2.32.3 ; python_full_version == "3.11.9" 31 | ruamel-yaml-clib==0.2.12 ; platform_python_implementation == "CPython" and python_full_version == "3.11.9" 32 | ruamel-yaml==0.18.6 ; python_full_version == "3.11.9" 33 | schedule==1.2.2 ; python_full_version == "3.11.9" 34 | six==1.16.0 ; python_full_version == "3.11.9" 35 | sqlalchemy==2.0.36 ; python_full_version == "3.11.9" 36 | sqlmodel==0.0.21 ; python_full_version == "3.11.9" 37 | toolz==1.0.0 ; python_full_version == "3.11.9" 38 | twilio==9.3.6 ; python_full_version == "3.11.9" 39 | typing-extensions==4.12.2 ; python_full_version == "3.11.9" 40 | tzdata==2024.2 ; python_full_version == "3.11.9" 41 | urllib3==2.2.3 ; python_full_version == "3.11.9" 42 | yarl==1.17.1 ; python_full_version == "3.11.9" 43 | -------------------------------------------------------------------------------- /infra/docker-compose-vps/dc_setup.py: -------------------------------------------------------------------------------- 1 | import pulumi 2 | import pulumi_command as command 3 | from loguru import logger 4 | 5 | 6 | def setup_docker_compose(config, setup_vps): 7 | # Create remote connection to execute commands 8 | logger.info(f"Connecting to {config['vps_ip_address']}") 9 | connection = command.remote.ConnectionArgs( 10 | host=config["vps_ip_address"], 11 | port=config["vps_ssh_port"], 12 | private_key=config["vps_ssh_private_key"], 13 | user=config["vps_ssh_user"], 14 | ) 15 | 16 | # Create ibkr directory 17 | logger.info("Creating ibkr directory") 18 | create_ibkr_dir = command.remote.Command( 19 | "create-ibkr-dir", 20 | connection=connection, 21 | create=f"mkdir -p {config['remote_path']}", 22 | opts=pulumi.ResourceOptions(depends_on=[setup_vps]), 23 | ) 24 | 25 | # Create .env file 26 | logger.info("Creating .env file") 27 | docker_env_vars = { 28 | "GH_USER": config["gh_user"], 29 | "GH_TOKEN": config["gh_token"], 30 | "IBKR_USER": config["ibkr_user"], 31 | "IBKR_PASSWORD": config["ibkr_password"], 32 | "IB_TRADING_MODE": config["ib_trading_mode"], 33 | "NTFY_TOPIC": config["ntfy_topic"], 34 | "NTFY_ENABLED": config["ntfy_enabled"], 35 | "STORAGE_PATH": config["storage_path"], 36 | } 37 | env_file_content = "\n".join( 38 | [f"{k}={v}" for k, v in docker_env_vars.items() if v is not None] 39 | ) 40 | copy_env_file = command.remote.Command( 41 | "copy-env-file", 42 | connection=connection, 43 | create=f'echo "{env_file_content}" > {config["remote_path"]}/.env', 44 | opts=pulumi.ResourceOptions(depends_on=[create_ibkr_dir]), 45 | ) 46 | 47 | # Send docker compose file 48 | logger.info("Sending docker compose file") 49 | copy_docker_compose = command.remote.CopyToRemote( 50 | "copy-docker-compose", 51 | connection=connection, 52 | source=pulumi.FileAsset(f"{config['docker_compose_file']}"), 53 | remote_path=f"{config['remote_path']}/{config['docker_compose_file']}", 54 | opts=pulumi.ResourceOptions(depends_on=[copy_env_file]), 55 | ) 56 | 57 | # Login to GitHub Container Registry 58 | logger.info("Logging into GitHub Container Registry") 59 | ghcr_login = command.remote.Command( 60 | "ghcr-login", 61 | connection=connection, 62 | create=f'echo "{config["gh_token"]}" | docker login ghcr.io -u {config["gh_user"]} --password-stdin', 63 | opts=pulumi.ResourceOptions(depends_on=[setup_vps]), 64 | ) 65 | 66 | # Start Docker Compose 67 | logger.info("Starting Docker Compose") 68 | start_docker_compose = command.remote.Command( 69 | "start-docker-compose", 70 | connection=connection, 71 | create=f"cd {config['remote_path']} && docker compose up -d", 72 | opts=pulumi.ResourceOptions( 73 | depends_on=[setup_vps, copy_docker_compose, copy_env_file, ghcr_login] 74 | ), 75 | ) 76 | 77 | return start_docker_compose 78 | -------------------------------------------------------------------------------- /charts/ibkr-bot/templates/application.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: {{ include "ibkr.fullname" . }}-application 6 | labels: 7 | {{- include "ibkr.labels" . | nindent 4 }} 8 | app.kubernetes.io/component: "application" 9 | spec: 10 | replicas: {{ .Values.application.replicaCount }} 11 | selector: 12 | matchLabels: 13 | {{- include "ibkr.selectorLabels" . | nindent 6 }} 14 | app.kubernetes.io/component: "application" 15 | template: 16 | metadata: 17 | {{- with .Values.application.podAnnotations }} 18 | annotations: 19 | {{- toYaml . | nindent 8 }} 20 | {{- end }} 21 | labels: 22 | {{- include "ibkr.selectorLabels" . | nindent 8 }} 23 | app.kubernetes.io/component: "application" 24 | spec: 25 | {{- with .Values.application.imagePullSecrets }} 26 | imagePullSecrets: 27 | {{- toYaml . | nindent 8 }} 28 | {{- end }} 29 | serviceAccountName: {{ include "ibkr.serviceAccountName" . }} 30 | securityContext: 31 | {{- toYaml .Values.application.podSecurityContext | nindent 8 }} 32 | containers: 33 | - name: {{ .Chart.Name }} 34 | securityContext: 35 | {{- toYaml .Values.application.securityContext | nindent 12 }} 36 | image: "{{ .Values.application.image.repository }}:{{ .Values.application.image.tag | default .Chart.AppVersion }}" 37 | imagePullPolicy: {{ .Values.application.image.pullPolicy }} 38 | env: 39 | - name: IB_GATEWAY_HOST 40 | value: {{ include "ibkr.fullname" . }}-gateway.{{ .Release.Namespace }}.svc.cluster.local 41 | - name: IB_GATEWAY_PORT 42 | value: "8888" 43 | {{- if .Values.application.configMapName }} 44 | envFrom: 45 | - configMapRef: 46 | name: {{ .Values.application.configMapName }} 47 | - secretRef: 48 | name: {{ .Values.application.existingSecret }} 49 | {{- end}} 50 | {{- if .Values.application.persistence.enabled }} 51 | volumeMounts: 52 | - mountPath: {{ .Values.application.persistence.mountPath }} 53 | name: volume 54 | {{- end }} 55 | resources: 56 | {{- toYaml .Values.application.resources | nindent 12 }} 57 | {{- with .Values.application.nodeSelector }} 58 | nodeSelector: 59 | {{- toYaml . | nindent 8 }} 60 | {{- end }} 61 | {{- with .Values.application.affinity }} 62 | affinity: 63 | {{- toYaml . | nindent 8 }} 64 | {{- end }} 65 | {{- with .Values.application.tolerations }} 66 | tolerations: 67 | {{- toYaml . | nindent 8 }} 68 | {{- end }} 69 | {{- if .Values.application.persistence.enabled }} 70 | volumes: 71 | - name: volume 72 | persistentVolumeClaim: 73 | claimName: {{ .Values.application.persistence.existingClaim }} 74 | {{- end }} 75 | -------------------------------------------------------------------------------- /app/models/option_sized.py: -------------------------------------------------------------------------------- 1 | import json 2 | import msgpack 3 | import base64 4 | from sqlalchemy.dialects.sqlite import JSON 5 | from sqlalchemy.types import TypeDecorator 6 | 7 | from ib_async.contract import Option 8 | 9 | 10 | class OptionWithSizeListEncoder(TypeDecorator): 11 | """Enables binary storage by encoding and decoding on the fly.""" 12 | 13 | impl = JSON 14 | 15 | def process_bind_param(self, value, dialect): 16 | if value is not None: 17 | packed = msgpack.packb([leg.to_dict() for leg in value]) 18 | return base64.b64encode(packed).decode() 19 | return None 20 | 21 | def process_result_value(self, value, dialect): 22 | if value is not None: 23 | from .option_sized import ( 24 | OptionWithSize, 25 | ) # Import here to avoid circular imports 26 | 27 | packed = base64.b64decode(value.encode()) 28 | return [OptionWithSize.from_dict(leg) for leg in msgpack.unpackb(packed)] 29 | return None 30 | 31 | 32 | class OptionWithSize(Option): 33 | """ 34 | Custom Option class that includes position size 35 | """ 36 | 37 | def __init__(self, option: Option, position_size: int, delta: float): 38 | super().__init__( 39 | conId=option.conId, 40 | symbol=option.symbol, 41 | lastTradeDateOrContractMonth=option.lastTradeDateOrContractMonth, 42 | strike=option.strike, 43 | tradingClass=option.tradingClass, 44 | right=option.right, 45 | multiplier=option.multiplier, 46 | exchange=option.exchange, 47 | currency=option.currency, 48 | localSymbol=option.localSymbol, 49 | ) 50 | self.position_size = position_size 51 | self.delta = delta 52 | 53 | @classmethod 54 | def from_dict(cls, data: dict) -> "OptionWithSize": 55 | """Create OptionWithSize from a dictionary""" 56 | option = Option( 57 | conId=data.get("conId"), 58 | symbol=data["symbol"], 59 | lastTradeDateOrContractMonth=data["lastTradeDateOrContractMonth"], 60 | strike=data["strike"], 61 | tradingClass=data.get("tradingClass"), 62 | right=data["right"], 63 | multiplier=data.get("multiplier"), 64 | exchange=data.get("exchange", "SMART"), 65 | currency=data.get("currency", "USD"), 66 | localSymbol=data.get("localSymbol", ""), 67 | ) 68 | return cls( 69 | option=option, 70 | position_size=data["position_size"], 71 | delta=data["delta"], 72 | ) 73 | 74 | def to_dict(self): 75 | base_dict = super().to_dict() if hasattr(super(), "to_dict") else vars(super()) 76 | return {**base_dict, "position_size": self.position_size} 77 | 78 | def to_json(self): 79 | return json.dumps(self.to_dict()) 80 | 81 | def __str__(self) -> str: 82 | """Return a string representation of the OptionWithSize""" 83 | return self.localSymbol.replace(" ", "") 84 | 85 | def __repr__(self) -> str: 86 | """Return a string representation of the OptionWithSize""" 87 | return f"OptionWithSize(localSymbol={self.localSymbol}, position_size={self.position_size})" 88 | -------------------------------------------------------------------------------- /infra/k8s-deployment/jobs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: batch/v1 3 | kind: CronJob 4 | metadata: 5 | name: ib-trading-job 6 | spec: 7 | schedule: "10 9 * * *" # 9:10am server time 8 | jobTemplate: 9 | spec: 10 | backoffLimit: 3 11 | template: 12 | metadata: 13 | labels: 14 | app: ib-trading-app 15 | spec: 16 | restartPolicy: Never 17 | initContainers: 18 | - name: volume-owner 19 | image: busybox 20 | command: ["sh", "-c", "chown -R 1000:1000 /data"] 21 | volumeMounts: 22 | - name: data-volume 23 | mountPath: /data 24 | containers: 25 | - name: ib-trading-app 26 | image: ghcr.io/omdv/ib-trading-app:latest 27 | imagePullPolicy: Always 28 | envFrom: 29 | - configMapRef: 30 | name: ibkr-app-config 31 | - secretRef: 32 | name: ibkr-app-secret 33 | resources: 34 | requests: 35 | cpu: 500m 36 | memory: 1024Mi 37 | limits: 38 | cpu: 1000m 39 | memory: 2048Mi 40 | volumeMounts: 41 | - name: data-volume 42 | mountPath: /data 43 | volumes: 44 | - name: data-volume 45 | persistentVolumeClaim: 46 | claimName: pvc-ibkr-data 47 | imagePullSecrets: 48 | - name: ghcr-secret 49 | 50 | --- 51 | apiVersion: batch/v1 52 | kind: CronJob 53 | metadata: 54 | name: ib-prediction-job 55 | spec: 56 | schedule: "0 9 * * *" # 9:00am server time 57 | jobTemplate: 58 | spec: 59 | backoffLimit: 3 60 | template: 61 | metadata: 62 | labels: 63 | app: ib-prediction-app 64 | spec: 65 | restartPolicy: Never 66 | initContainers: 67 | - name: volume-owner 68 | image: busybox 69 | command: ["sh", "-c", "chown -R 1000:1000 /data"] 70 | volumeMounts: 71 | - name: data-volume 72 | mountPath: /data 73 | containers: 74 | - name: ib-prediction-app 75 | image: ghcr.io/omdv/ib-trading-predictor:latest 76 | imagePullPolicy: Always 77 | envFrom: 78 | - configMapRef: 79 | name: ibkr-app-config 80 | - secretRef: 81 | name: ibkr-app-secret 82 | resources: 83 | requests: 84 | cpu: 500m 85 | memory: 1024Mi 86 | limits: 87 | cpu: 1000m 88 | memory: 2048Mi 89 | volumeMounts: 90 | - name: data-volume 91 | mountPath: /data 92 | volumes: 93 | - name: data-volume 94 | persistentVolumeClaim: 95 | claimName: pvc-ibkr-data 96 | imagePullSecrets: 97 | - name: ghcr-secret 98 | -------------------------------------------------------------------------------- /charts/ibkr-bot/templates/gateway.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: {{ include "ibkr.fullname" . }}-gateway 6 | labels: 7 | {{- include "ibkr.labels" . | nindent 4 }} 8 | app.kubernetes.io/component: "gateway" 9 | spec: 10 | replicas: {{ .Values.gateway.replicaCount }} 11 | selector: 12 | matchLabels: 13 | {{- include "ibkr.selectorLabels" . | nindent 6 }} 14 | app.kubernetes.io/component: "gateway" 15 | template: 16 | metadata: 17 | {{- with .Values.gateway.podAnnotations }} 18 | annotations: 19 | {{- toYaml . | nindent 8 }} 20 | {{- end }} 21 | labels: 22 | {{- include "ibkr.selectorLabels" . | nindent 8 }} 23 | app.kubernetes.io/component: "gateway" 24 | spec: 25 | {{- with .Values.gateway.imagePullSecrets }} 26 | imagePullSecrets: 27 | {{- toYaml . | nindent 8 }} 28 | {{- end }} 29 | serviceAccountName: {{ include "ibkr.serviceAccountName" . }} 30 | securityContext: 31 | {{- toYaml .Values.gateway.podSecurityContext | nindent 8 }} 32 | containers: 33 | - name: {{ .Chart.Name }} 34 | securityContext: 35 | {{- toYaml .Values.gateway.securityContext | nindent 12 }} 36 | image: "{{ .Values.gateway.image.repository }}:{{ .Values.gateway.image.tag | default .Chart.AppVersion }}" 37 | imagePullPolicy: {{ .Values.gateway.image.pullPolicy }} 38 | ports: 39 | - name: api 40 | containerPort: {{ .Values.gateway.ports.api }} 41 | protocol: TCP 42 | - name: vnc 43 | containerPort: {{ .Values.gateway.ports.vnc }} 44 | protocol: TCP 45 | # livenessProbe: 46 | # tcpSocket: 47 | # port: api 48 | # initialDelaySeconds: 120 49 | # periodSeconds: 60 50 | # readinessProbe: 51 | # tcpSocket: 52 | # port: api 53 | # initialDelaySeconds: 30 54 | # periodSeconds: 10 55 | envFrom: 56 | - secretRef: 57 | name: {{ .Values.gateway.existingSecret }} 58 | env: 59 | - name: GATEWAY_OR_TWS 60 | value: {{ .Values.gateway.parameters.GATEWAY_OR_TWS | quote }} 61 | - name: TWOFA_TIMEOUT_ACTION 62 | value: {{ .Values.gateway.parameters.TWOFA_TIMEOUT_ACTION | quote }} 63 | - name: IBC_TradingMode 64 | value: {{ .Values.gateway.parameters.IBC_TradingMode | quote }} 65 | - name: IBC_ReadOnlyApi 66 | value: {{ .Values.gateway.parameters.IBC_ReadOnlyApi | quote }} 67 | - name: IBC_ReloginAfterSecondFactorAuthenticationTimeout 68 | value: {{ .Values.gateway.parameters.IBC_ReloginAfterSecondFactorAuthenticationTimeout | quote }} 69 | resources: 70 | {{- toYaml .Values.gateway.resources | nindent 12 }} 71 | {{- with .Values.gateway.nodeSelector }} 72 | nodeSelector: 73 | {{- toYaml . | nindent 8 }} 74 | {{- end }} 75 | {{- with .Values.gateway.affinity }} 76 | affinity: 77 | {{- toYaml . | nindent 8 }} 78 | {{- end }} 79 | {{- with .Values.gateway.tolerations }} 80 | tolerations: 81 | {{- toYaml . | nindent 8 }} 82 | {{- end }} 83 | -------------------------------------------------------------------------------- /app/ib_mock/ib_mock.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | 3 | from ib_async import IB 4 | from ib_async.objects import AccountValue 5 | from ib_async.contract import ContractDetails, Contract 6 | from ib_async.order import Order 7 | 8 | from .gen_positions import gen_positions 9 | from .gen_tickers import gen_tickers 10 | from .gen_option_chain import gen_option_chain 11 | from .gen_trades import gen_trades 12 | from .common import contract_id, local_symbol 13 | 14 | 15 | class MockIB(IB): 16 | def __init__(self): 17 | super().__init__() 18 | 19 | def connect(self, *args, **kwargs): 20 | """Mock connect - always succeeds""" 21 | logger.debug("Mocking connect") 22 | return True 23 | 24 | def positions(self): 25 | """Mock positions - return mock positions""" 26 | logger.debug("Mocking positions") 27 | return gen_positions() 28 | 29 | def qualifyContracts(self, *args, **kwargs): 30 | """Mock qualifyContracts""" 31 | logger.debug("Mocking qualifyContracts for contracts: {}", args) 32 | for arg in args: 33 | arg.conId = contract_id(arg) 34 | arg.localSymbol = local_symbol(arg) 35 | return args 36 | 37 | def reqContractDetails(self, *args, **kwargs): 38 | """Mock reqContractDetails - return mocked contract details""" 39 | logger.debug("Mocking reqContractDetails for contracts: {}", args) 40 | contract_details = [] 41 | for arg in args: 42 | cd = ContractDetails(contract=arg) 43 | cd.contract.conId = contract_id(arg) 44 | cd.contract.localSymbol = local_symbol(arg) 45 | contract_details.append(cd) 46 | return contract_details 47 | 48 | def reqMarketDataType(self, *args, **kwargs): 49 | """Mock reqMarketDataType - always succeeds""" 50 | logger.debug("Mocking reqMarketDataType") 51 | return True 52 | 53 | def reqTickers(self, *args, **kwargs): 54 | """Mock reqTickers - return mocked ticker""" 55 | logger.debug("Mocking tickers for contracts: {}", args) 56 | mock_tickers = gen_tickers() 57 | result = [] 58 | for arg in args: 59 | if arg.conId in mock_tickers: 60 | result.append(mock_tickers[arg.conId]) 61 | return result 62 | 63 | def reqMktData(self, contract): 64 | """Mock reqMktData - always succeeds""" 65 | logger.debug("Mocking reqMktData for contract: {}", contract.conId) 66 | mock_tickers = gen_tickers() 67 | if contract.conId in mock_tickers: 68 | logger.debug("Mocking reqMktData for contract: {}", mock_tickers[contract.conId]) 69 | return mock_tickers[contract.conId] 70 | else: 71 | return None 72 | 73 | def reqSecDefOptParams(self, *args, **kwargs): 74 | """Mock reqSecDefOptParams - return mocked option chain""" 75 | logger.debug("Mocking reqSecDefOptParams for contract: {}", args[0]) 76 | return gen_option_chain() 77 | 78 | def cancelMktData(self, *args, **kwargs): 79 | """Mock cancelMktData - always succeeds""" 80 | logger.debug("Mocking cancelMktData for contract: {}", args[0].conId) 81 | return True 82 | 83 | def placeOrder(self, contract: Contract, order: Order): 84 | """Mock placeOrder - return mocked trade""" 85 | logger.debug("Mocking placeOrder for contract: {}, order: {}", contract, order) 86 | trade = gen_trades(contract, order) 87 | return trade 88 | 89 | def accountValues(self, *args, **kwargs): 90 | """Mock accountValues - return mocked account values""" 91 | logger.debug("Mocking accountValues") 92 | account_values = [ 93 | AccountValue( 94 | account="U123456", 95 | tag="NetLiquidationByCurrency", 96 | currency="BASE", 97 | value=100000.0, 98 | modelCode="", 99 | ), 100 | ] 101 | return account_values 102 | -------------------------------------------------------------------------------- /app/messaging/telegram.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from telegram import Update 5 | from telegram.ext import ( 6 | Application, 7 | CommandHandler, 8 | MessageHandler, 9 | filters, 10 | ContextTypes, 11 | ) 12 | 13 | # Configure logging 14 | logging.basicConfig( 15 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO 16 | ) 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class TelegramHandler: 21 | def __init__(self): 22 | self.token = os.getenv("TELEGRAM_BOT_TOKEN") 23 | allowed_users = os.getenv("TELEGRAM_ALLOWED_USER_IDS", "") 24 | self.allowed_user_ids = [int(uid.strip()) for uid in allowed_users.split(",")] 25 | self.application = Application.builder().token(self.token).build() 26 | self._setup_handlers() 27 | 28 | def _setup_handlers(self): 29 | """ 30 | Set up command and message handlers 31 | """ 32 | # Command handlers 33 | self.application.add_handler(CommandHandler("start", self.start_command)) 34 | self.application.add_handler(CommandHandler("help", self.help_command)) 35 | self.application.add_handler(CommandHandler("pos", self.positions_command)) 36 | 37 | # Message handler 38 | self.application.add_handler( 39 | MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_message) 40 | ) 41 | 42 | async def _check_user_permission(self, update: Update) -> bool: 43 | """ 44 | Check if user is allowed to use the bot 45 | """ 46 | user_id = update.effective_user.id 47 | is_allowed = user_id in self.allowed_user_ids 48 | 49 | if not is_allowed: 50 | logger.warning(f"Unauthorized access attempt from user ID: {user_id}") 51 | await update.message.reply_text("Sorry, you are not authorized to use this bot.") 52 | else: 53 | logger.info(f"User ID {user_id} is authorized to use the bot") 54 | return is_allowed 55 | 56 | async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): 57 | """ 58 | Handle the /start command 59 | """ 60 | if not await self._check_user_permission(update): 61 | return 62 | await update.message.reply_text("Use /help to see available commands.") 63 | 64 | async def help_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): 65 | """ 66 | Handle the /help command 67 | """ 68 | if not await self._check_user_permission(update): 69 | return 70 | await update.message.reply_text("/start, /help") 71 | 72 | async def positions_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE): 73 | """ 74 | Handle the /pos command 75 | """ 76 | if not await self._check_user_permission(update): 77 | return 78 | await update.message.reply_text("Positions command") 79 | 80 | async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE): 81 | """ 82 | Handle incoming messages 83 | """ 84 | if not await self._check_user_permission(update): 85 | return 86 | 87 | message_text = update.message.text 88 | logger.info(f"Received message: {message_text}") 89 | await update.message.reply_text(f"Received message: {message_text}") 90 | 91 | def run(self): 92 | """ 93 | Start the bot 94 | """ 95 | logger.info("Starting bot...") 96 | # Changed from start() to run_polling() 97 | self.application.run_polling(allowed_updates=Update.ALL_TYPES) 98 | 99 | def error_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE): 100 | """ 101 | Handle errors 102 | """ 103 | logger.error(f"Update {update} caused error {context.error}") 104 | 105 | 106 | if __name__ == "__main__": 107 | telegram_handler = TelegramHandler() 108 | telegram_handler.run() 109 | -------------------------------------------------------------------------------- /app/trader/trading_bot.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from loguru import logger 3 | 4 | from ib_async import IB 5 | from ib_mock import MockIB 6 | 7 | from storage.db import DB 8 | from services.positions_parser import PositionsService 9 | from services.option_spread import OptionSpreadService 10 | from messaging.ntfy import MessageHandler 11 | from models.filled_trade import FilledTrade 12 | from services.utilities import is_market_open 13 | from .trading_logic import need_to_open_spread, get_spread_to_open 14 | 15 | 16 | class ConnectionIssue(Exception): 17 | """My custom exception class.""" 18 | 19 | 20 | class TradingBot: 21 | """ 22 | Trading bot class, which orchestrates interactions with the IB API 23 | and trade logic 24 | """ 25 | 26 | def __init__(self, settings, mocked=False): 27 | self.config = settings 28 | self.ibkr = None 29 | self.spreads = None 30 | self.positions = None 31 | self.net_value = None 32 | self.db = DB(self.config) 33 | self._connect(mocked) 34 | 35 | def __del__(self): 36 | try: 37 | self.ibkr.disconnect() 38 | except AttributeError: 39 | pass 40 | 41 | def _connect(self, mocked=False): 42 | """ 43 | Create and connect IB client 44 | """ 45 | host = self.config.ib_gateway_host 46 | port = self.config.ib_gateway_port 47 | 48 | if mocked: 49 | self.ibkr = MockIB() 50 | else: 51 | self.ibkr = IB() 52 | 53 | try: 54 | self.ibkr.connect( 55 | host=host, 56 | port=port, 57 | clientId=dt.datetime.now(dt.UTC).strftime("%H%M"), 58 | timeout=120, 59 | readonly=True, 60 | ) 61 | self.ibkr.RequestTimeout = 30 62 | except ConnectionIssue as e: 63 | logger.error("Error connecting to IB: {}", e) 64 | logger.debug("Connected to IB on {}:{}", host, port) 65 | 66 | try: 67 | self.positions = self.ibkr.positions() 68 | except Exception as e: 69 | logger.error("Error getting positions: {}", e) 70 | raise e 71 | 72 | def trade_loop(self): 73 | """ 74 | Main trading loop 75 | TODO: skip if no actions for today 76 | """ 77 | 78 | # Create messaging bot 79 | status_bot = MessageHandler(self.config) 80 | 81 | if not is_market_open(self.ibkr): 82 | logger.info("Market is closed, skipping") 83 | status_bot.send_notification("Market is closed, skipping") 84 | return 85 | 86 | # Get existing option spreads 87 | positions_service = PositionsService(self.ibkr, self.positions) 88 | existing_spreads = positions_service.get_option_spreads() 89 | self.spreads = existing_spreads 90 | for spread in self.spreads: 91 | logger.info("Found spread: {}", spread) 92 | spread.save(self.db) 93 | status_bot.send_positions(existing_spreads) 94 | 95 | # Identify if spreads need to be opened 96 | if not need_to_open_spread(self.ibkr, existing_spreads): 97 | logger.info("No spreads need to be opened") 98 | status_bot.send_notification("No spreads need to be opened") 99 | return 100 | 101 | # Close the spread with execution logic 102 | if self.config.close_spread_on_expiry: 103 | # TODO: implement the closing logic 104 | pass 105 | 106 | # Identify which spreads needs to be opened 107 | contract = get_spread_to_open(self.ibkr, existing_spreads) 108 | logger.info("Target spread: {}", contract) 109 | status_bot.send_target_spread(contract) 110 | 111 | # Open the spread with execution logic 112 | spread_service = OptionSpreadService(self.ibkr, contract) 113 | current_price = spread_service.get_current_price() 114 | logger.info("Current price of the target spread: {}", current_price) 115 | trade = spread_service.trade_spread() 116 | 117 | # Save the filled trade to the database 118 | if trade: 119 | account_value = [ 120 | v 121 | for v in self.ibkr.accountValues() 122 | if v.tag == "NetLiquidationByCurrency" and v.currency == "BASE" 123 | ] 124 | net_value = float(account_value[0].value) 125 | filled_trade = FilledTrade(contract, trade, net_value) 126 | filled_trade.save(self.db) 127 | -------------------------------------------------------------------------------- /terraform/google/variables.tf: -------------------------------------------------------------------------------- 1 | variable "TWS_USER_ID" { 2 | description = "TWS user login" 3 | type = string 4 | } 5 | 6 | variable "TWS_PASSWORD" { 7 | description = "TWS user password" 8 | type = string 9 | } 10 | 11 | variable "VNC_PASSWORD" { 12 | description = "VNC password" 13 | type = string 14 | default = "" 15 | } 16 | 17 | variable "TRADING_MODE" { 18 | description = "Trading mode - live or paper" 19 | type = string 20 | } 21 | 22 | variable "project_name" { 23 | description = "GCP project name" 24 | type = string 25 | } 26 | 27 | variable "region" { 28 | description = "The GCP region to deploy to" 29 | type = string 30 | default = "us-central1" 31 | } 32 | 33 | variable "zone" { 34 | description = "The GCP zone to deploy to" 35 | type = string 36 | default = "us-central1-c" 37 | } 38 | 39 | variable "app_engine_location" { 40 | description = "Location for App Engine to serve from" 41 | type = string 42 | default = "us-central" 43 | } 44 | 45 | variable "gateway_vm_name" { 46 | description = "Instance name for gateway" 47 | type = string 48 | default = "ib-gateway" 49 | } 50 | 51 | variable "app_vm_name" { 52 | description = "Instance name for app" 53 | type = string 54 | default = "ib-app" 55 | } 56 | 57 | variable "gateway_machine_type" { 58 | description = "GCE machine type for gateway" 59 | type = string 60 | default = "e2-small" 61 | } 62 | 63 | variable "app_machine_type" { 64 | description = "GCE machine type for application" 65 | type = string 66 | default = "e2-small" 67 | } 68 | 69 | variable "gateway_image" { 70 | description = "Link to gateway docker image" 71 | type = string 72 | } 73 | 74 | variable "app_image" { 75 | description = "Link to app docker image" 76 | type = string 77 | } 78 | 79 | variable "network" { 80 | description = "GCP network name" 81 | type = string 82 | default = "ib-net" 83 | } 84 | 85 | variable "subnetwork" { 86 | description = "GCP subnetwork name" 87 | type = string 88 | default = "ib-subnet" 89 | } 90 | 91 | variable "subnet_cidr" { 92 | description = "Subnetwork CIDR" 93 | type = string 94 | default = "10.0.0.0/25" 95 | } 96 | 97 | variable "ib_gateway_internal_ip" { 98 | description = "Fixed internal IP of ib-gateway" 99 | type = string 100 | default = "10.0.0.10" 101 | } 102 | 103 | variable "ib_gateway_port" { 104 | description = "Gateway API port: 4041 (live) or 4042 (paper)" 105 | type = number 106 | } 107 | 108 | variable "gateway_logging_enabled" { 109 | description = "If true need at least e2-small instance" 110 | type = string 111 | default = "false" 112 | } 113 | 114 | variable "gateway_monitoring_enabled" { 115 | description = "If true need at least e2-small instance" 116 | type = string 117 | default = "false" 118 | } 119 | 120 | variable "app_logging_enabled" { 121 | description = "If true need at least e2-small instance" 122 | type = string 123 | default = "false" 124 | } 125 | 126 | variable "app_monitoring_enabled" { 127 | description = "If true need at least e2-small instance" 128 | type = string 129 | default = "false" 130 | } 131 | 132 | variable "gcp_service_list" { 133 | type = list 134 | default = [ 135 | # "run.googleapis.com", 136 | "containerregistry.googleapis.com", # Container registry 137 | "cloudapis.googleapis.com", # Google Cloud APIs 138 | "compute.googleapis.com", # Compute Engine API 139 | "iam.googleapis.com", # Identity and Access Management (IAM) API 140 | "iamcredentials.googleapis.com", # IAM Service Account Credentials API 141 | "servicemanagement.googleapis.com", # Service Management API 142 | "serviceusage.googleapis.com", # Service Usage API 143 | "sourcerepo.googleapis.com", # Cloud Source Repositories API 144 | "storage-api.googleapis.com", # Google Cloud Storage JSON API 145 | "storage-component.googleapis.com", # Cloud Storage 146 | ] 147 | } 148 | 149 | variable "labels" { 150 | type = map 151 | default = { 152 | "environment" = "prod" 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /devenv.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "devenv": { 4 | "locked": { 5 | "dir": "src/modules", 6 | "lastModified": 1732585607, 7 | "owner": "cachix", 8 | "repo": "devenv", 9 | "rev": "a520f05c40ebecaf5e17064b27e28ba8e70c49fb", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "dir": "src/modules", 14 | "owner": "cachix", 15 | "repo": "devenv", 16 | "type": "github" 17 | } 18 | }, 19 | "flake-compat": { 20 | "flake": false, 21 | "locked": { 22 | "lastModified": 1696426674, 23 | "owner": "edolstra", 24 | "repo": "flake-compat", 25 | "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "edolstra", 30 | "repo": "flake-compat", 31 | "type": "github" 32 | } 33 | }, 34 | "flake-compat_2": { 35 | "flake": false, 36 | "locked": { 37 | "lastModified": 1696426674, 38 | "owner": "edolstra", 39 | "repo": "flake-compat", 40 | "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 41 | "type": "github" 42 | }, 43 | "original": { 44 | "owner": "edolstra", 45 | "repo": "flake-compat", 46 | "type": "github" 47 | } 48 | }, 49 | "gitignore": { 50 | "inputs": { 51 | "nixpkgs": [ 52 | "pre-commit-hooks", 53 | "nixpkgs" 54 | ] 55 | }, 56 | "locked": { 57 | "lastModified": 1709087332, 58 | "owner": "hercules-ci", 59 | "repo": "gitignore.nix", 60 | "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 61 | "type": "github" 62 | }, 63 | "original": { 64 | "owner": "hercules-ci", 65 | "repo": "gitignore.nix", 66 | "type": "github" 67 | } 68 | }, 69 | "nixpkgs": { 70 | "locked": { 71 | "lastModified": 1716977621, 72 | "owner": "cachix", 73 | "repo": "devenv-nixpkgs", 74 | "rev": "4267e705586473d3e5c8d50299e71503f16a6fb6", 75 | "type": "github" 76 | }, 77 | "original": { 78 | "owner": "cachix", 79 | "ref": "rolling", 80 | "repo": "devenv-nixpkgs", 81 | "type": "github" 82 | } 83 | }, 84 | "nixpkgs-python": { 85 | "inputs": { 86 | "flake-compat": "flake-compat", 87 | "nixpkgs": [ 88 | "nixpkgs" 89 | ] 90 | }, 91 | "locked": { 92 | "lastModified": 1730716553, 93 | "owner": "cachix", 94 | "repo": "nixpkgs-python", 95 | "rev": "8fcdb8ec34a1c2bae3f5326873a41b310e948ccc", 96 | "type": "github" 97 | }, 98 | "original": { 99 | "owner": "cachix", 100 | "repo": "nixpkgs-python", 101 | "type": "github" 102 | } 103 | }, 104 | "nixpkgs-stable": { 105 | "locked": { 106 | "lastModified": 1731797254, 107 | "owner": "NixOS", 108 | "repo": "nixpkgs", 109 | "rev": "e8c38b73aeb218e27163376a2d617e61a2ad9b59", 110 | "type": "github" 111 | }, 112 | "original": { 113 | "owner": "NixOS", 114 | "ref": "nixos-24.05", 115 | "repo": "nixpkgs", 116 | "type": "github" 117 | } 118 | }, 119 | "pre-commit-hooks": { 120 | "inputs": { 121 | "flake-compat": "flake-compat_2", 122 | "gitignore": "gitignore", 123 | "nixpkgs": [ 124 | "nixpkgs" 125 | ], 126 | "nixpkgs-stable": "nixpkgs-stable" 127 | }, 128 | "locked": { 129 | "lastModified": 1732021966, 130 | "owner": "cachix", 131 | "repo": "pre-commit-hooks.nix", 132 | "rev": "3308484d1a443fc5bc92012435d79e80458fe43c", 133 | "type": "github" 134 | }, 135 | "original": { 136 | "owner": "cachix", 137 | "repo": "pre-commit-hooks.nix", 138 | "type": "github" 139 | } 140 | }, 141 | "root": { 142 | "inputs": { 143 | "devenv": "devenv", 144 | "nixpkgs": "nixpkgs", 145 | "nixpkgs-python": "nixpkgs-python", 146 | "pre-commit-hooks": "pre-commit-hooks" 147 | } 148 | } 149 | }, 150 | "root": "root", 151 | "version": 7 152 | } 153 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![ib-trading-app-ver](https://img.shields.io/docker/v/omdv/ib-trading-app?label=ib-trading-app&logo=docker) 2 | ![ib-trading-app-size](https://img.shields.io/docker/image-size/omdv/ib-trading-app?label=ib-trading-app&logo=docker) 3 | 4 | 5 | # Description 6 | 7 | This is the boilerplate or framework to create your own trading application with Interactive Brokers. It is **not** a finished product - the provided example will work end-to-end, but you are expected to add your own trading logic and workflow. This said, I do use it for my own trading and will continue adding some key elements, without exposing my own algorithm. 8 | 9 | The **systematic** approach to trading requires your trading application to have several services. I designed this framework to be modular, loosely coupled and containerized: 10 | 11 | 1. You will need the IBKR gateway to provide the API. [IBC](https://github.com/IbcAlpha/IBC) emerged as the default way to manage the IBKR gateway. Still, this setup is finicky, especially when containerized. After many months I stopped maintaining my own docker image and recommend using the image from [extrange](https://github.com/extrange/ibkr-docker), which is quite stable, as long as you include health checks to ensure it restarts on failure and use right settings to restart if you miss the MFA window. 12 | 13 | 2. The main "know-how" - the image of the trading application itself. I use `ib_async` library with scheduler. You can see the example of the end-to-end implementation in `app` folder. You will need to modify it and add github workflow to build your custom image. 14 | 15 | 3. Storage and messaging backends. I recommend using `ntfy.sh` service, which is free and nothing short of amazing. Just make sure you select an obfuscated name for your topic. 16 | 17 | 4. Backtesting backend. You need to track the performance of your strategy and compare it with the *theoretical* performance. I developed my own backend for backtesting options strategy, using duckdb, sql and arrows, instead of pandas for significant performance boost. I will try to open portions of my backtest repo in the future. 18 | 19 | 20 | # Tech stack 21 | - `devenv` and `poetry` for reproducible dev environment 22 | - `duckdb` for storage and analytics 23 | - `docker-compose` or `helm chart` for deployment 24 | - `pulumi` or `terraform` for infrastructure-as-a-code 25 | 26 | # Usage 27 | 28 | Utility will be limited, unless you build your own trading application. This said below are some general notes on deployment and usage. 29 | 30 | ### Local development 31 | 32 | Local deployment is using docker-compose. Add your variables to `.envrc`, `direnv allow`. Running `task evd-up` will start TWS gateway locally. `python app/main.py` will start the trading application. There is a mock class available to mock the `IB` API. 33 | 34 | 35 | ### AWS deployment 36 | 37 | My preferred way to run it is with docker-compose on a single EC2 instance. I use `pulumi` for IaaC. Instructions to be added... 38 | 39 | 40 | ### Helm chart deployment 41 | 42 | There is a helm chart, if you prefer k8s. 43 | 44 | ```bash 45 | helm repo add ibkr-trading https://omdv.github.io/ibkr-trading/ 46 | helm search repo ibkr-trading 47 | ``` 48 | OCI has a very generous free tier and I was successful deploying this chart within it, however it proved to be too much hassle. Chart will be supported, but not updated frequently. 49 | 50 | 51 | ### GCP deployment with Terraform 52 | 53 | My first deployment outside of local was on GCP with Terraform. It was based on two separate VMs hosting gateway and application containers, connected via VPC. 54 | 55 | I will **not support** this moving forward. 56 | 57 | Expected env variables: 58 | ```bash 59 | export TF_VAR_TWS_USER_ID = 60 | export TF_VAR_TWS_PASSWORD = 61 | export TF_VAR_TRADING_MODE = <"paper" or "live"> 62 | export TF_VAR_project_id = 63 | ``` 64 | 65 | Review and deploy: 66 | 67 | ```bash 68 | cd ./deployments/google 69 | terraform init 70 | terraform plan 71 | terraform apply 72 | ``` 73 | 74 | 75 | ## Troubleshooting / Usefull snippets 76 | 77 | - Update container image by `gcloud compute instances update-container ib-app --container-image $TF_VAR_app_image` 78 | - Or connect to application instance and check docker logs. 79 | - If you already had GCP project import it: `terraform import google_project.project $TF_VAR_project_id` 80 | 81 | 82 | # References 83 | 84 | Inspired by the following projects: 85 | 86 | - [IBC and TWS on ubuntu](https://dimon.ca/how-to-setup-ibc-and-tws-on-headless-ubuntu-in-10-minutes) 87 | - [IBGateway docker image for GCP](https://github.com/dvasdekis/ib-gateway-docker-gcp) 88 | - [Systematic trading](https://www.amazon.com/Systematic-Trading-designing-trading-investing/dp/0857194453) 89 | -------------------------------------------------------------------------------- /app/services/option_spread.py: -------------------------------------------------------------------------------- 1 | """ 2 | Option spread related services 3 | """ 4 | 5 | import logging 6 | 7 | from ib_async import Contract, IB, ComboLeg, Order 8 | from ib_async.order import Trade 9 | 10 | from models import OptionSpread 11 | from services.contract import ContractService 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class OptionSpreadService: 17 | def __init__(self, ibkr: IB, option_spread: OptionSpread): 18 | self.ibkr = ibkr 19 | self.spread = option_spread 20 | 21 | def get_short_leg_contract(self) -> Contract: 22 | """ 23 | Get the short leg contract 24 | """ 25 | for leg in self.spread.legs: 26 | if leg.position_size < 0: 27 | short_contract_id = leg.conId 28 | break 29 | 30 | contract = Contract(conId=short_contract_id, exchange="SMART") 31 | self.ibkr.qualifyContracts(contract) 32 | return contract 33 | 34 | def get_long_leg_contract(self) -> Contract: 35 | """ 36 | Get the long leg contract 37 | """ 38 | for leg in self.spread.legs: 39 | if leg.position_size > 0: 40 | long_contract_id = leg.conId 41 | break 42 | 43 | contract = Contract(conId=long_contract_id, exchange="SMART") 44 | self.ibkr.qualifyContracts(contract) 45 | return contract 46 | 47 | def create_spread_contract(self) -> Contract: 48 | """ 49 | Create the spread contract from the OptionSpreads object 50 | """ 51 | short_contract = self.get_short_leg_contract() 52 | long_contract = self.get_long_leg_contract() 53 | 54 | # Create empty combo contract 55 | contract = Contract( 56 | symbol=short_contract.symbol, 57 | secType="BAG", 58 | currency="USD", 59 | exchange="SMART", 60 | ) 61 | 62 | # Add legs to the contract 63 | legs = [] 64 | for side, spread in [("short", short_contract), ("long", long_contract)]: 65 | cds = self.ibkr.reqContractDetails(spread) 66 | leg = ComboLeg() 67 | leg.conId = cds[0].contract.conId 68 | leg.ratio = 1 69 | leg.exchange = "SMART" 70 | leg.action = "BUY" if side == "long" else "SELL" 71 | legs.append(leg) 72 | 73 | contract.comboLegs = legs 74 | 75 | # Log contract details 76 | logger.debug("Target spread: {}", contract) 77 | 78 | return contract 79 | 80 | def get_current_price(self) -> float: 81 | """ 82 | Get the current price of the spread 83 | """ 84 | short_contract = self.get_short_leg_contract() 85 | short_contract_service = ContractService(self.ibkr, short_contract) 86 | short_price = short_contract_service.get_current_price("bid") 87 | 88 | long_contract = self.get_long_leg_contract() 89 | long_contract_service = ContractService(self.ibkr, long_contract) 90 | long_price = long_contract_service.get_current_price("ask") 91 | 92 | price = long_price - short_price 93 | 94 | return price 95 | 96 | def get_spread_delta(self) -> float: 97 | """ 98 | Use the saved delta from the short leg 99 | TODO: check if this is sufficient or needs to be refreshed 100 | """ 101 | return self.spread.delta 102 | 103 | def trade_spread(self) -> Trade: 104 | """ 105 | Trade the spread with price adjustment logic if the order doesn't fill 106 | """ 107 | spread_contract = self.create_spread_contract() 108 | current_price = self.get_current_price() 109 | max_attempts = 3 110 | price_increment = 0.05 111 | 112 | order = Order() 113 | order.action = "BUY" 114 | order.totalQuantity = self.spread.size 115 | order.orderType = "LMT" 116 | order.lmtPrice = current_price 117 | 118 | logger.info( 119 | "Placing order for spread: {} x {} at {}", 120 | spread_contract, 121 | self.spread.size, 122 | current_price, 123 | ) 124 | trade = self.ibkr.placeOrder(spread_contract, order) 125 | 126 | # Wait for order to fill 127 | for attempt in range(max_attempts): 128 | filled = False 129 | timeout = 30 # seconds to wait for fill 130 | 131 | while timeout > 0 and not filled: 132 | if trade.orderStatus.status == "Filled": 133 | logger.info("Order filled at {}", trade.orderStatus.avgFillPrice) 134 | filled = True 135 | break 136 | 137 | self.ibkr.sleep(1) 138 | timeout -= 1 139 | 140 | if filled: 141 | return trade 142 | 143 | # If not filled, adjust price upward 144 | new_price = order.lmtPrice + price_increment 145 | logger.info( 146 | "Order not filled after %d seconds. Adjusting price to {}", 30, new_price 147 | ) 148 | 149 | # Cancel existing order 150 | self.ibkr.cancelOrder(trade.order) 151 | 152 | # Place new order with adjusted price 153 | order.lmtPrice = new_price 154 | trade = self.ibkr.placeOrder(spread_contract, order) 155 | 156 | if not filled: 157 | logger.warning("Failed to execute spread trade after %d attempts", max_attempts) 158 | self.ibkr.cancelOrder(trade.order) 159 | return None 160 | -------------------------------------------------------------------------------- /app/trader/trading_logic.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from loguru import logger 3 | from ib_async import IB, Index 4 | from ib_async.contract import Option, Contract 5 | from ib_async.util import df 6 | 7 | from models import OptionSpread, OptionWithSize 8 | from services.option_spread import OptionSpreadService 9 | from services.contract import ContractService 10 | from services.utilities import next_trading_day 11 | 12 | 13 | def need_to_open_spread(ibkr: IB, spreads: list[OptionSpread]): 14 | """ 15 | Check trade logic 16 | TODO: add the logic here 17 | TODO: check for delta of the short leg 18 | TODO: check for symbols - we assume it is only SPXW 19 | """ 20 | # When empty, we need to open a spread 21 | return_flag = True 22 | 23 | if len(spreads) == 2: 24 | return_flag = False 25 | if len(spreads) == 1: 26 | # Check if the existing spread expires today 27 | today = dt.datetime.now().strftime("%Y%m%d") 28 | if spreads[0].expiry == today: 29 | option_spread_service = OptionSpreadService(ibkr, spreads[0]) 30 | current_delta = option_spread_service.get_spread_delta() 31 | logger.info("Current delta: {}", current_delta) 32 | 33 | if current_delta > -0.02: 34 | return_flag = True 35 | else: 36 | return_flag = False 37 | else: 38 | return_flag = False 39 | 40 | return return_flag 41 | 42 | 43 | def target_delta() -> float: 44 | """ 45 | Target delta 46 | TODO: add the logic here 47 | 48 | """ 49 | return -0.05 50 | 51 | 52 | def target_width() -> int: 53 | """ 54 | Target width 55 | TODO: add the logic here 56 | """ 57 | return 100 58 | 59 | 60 | def position_size(ibkr: IB) -> int: 61 | """ 62 | Position size 63 | TODO: add the logic here 64 | """ 65 | 66 | # Get the net value 67 | net_value = float( 68 | [ 69 | v 70 | for v in ibkr.accountValues() 71 | if v.tag == "NetLiquidationByCurrency" and v.currency == "BASE" 72 | ][0].value 73 | ) 74 | 75 | # Calculate the position size 76 | kelly_factor = 0.25 77 | multiplier = 100 78 | 79 | position_size = round(net_value * kelly_factor / target_width() / multiplier) 80 | return int(position_size) 81 | 82 | 83 | def _short_leg_contract_to_open(ibkr: IB, expiry: str) -> tuple[Contract, float]: 84 | """ 85 | Get the short leg contract 86 | TODO: Model Greeks vs Last Greeks? 87 | """ 88 | # Create a contract for SPX 89 | spx = Index("SPX", "CBOE") 90 | ibkr.qualifyContracts(spx) 91 | 92 | # Get the current price of the underlying 93 | contract_service = ContractService(ibkr, spx) 94 | underlying_price = contract_service.get_current_price() 95 | 96 | # Request option chain 97 | chains = ibkr.reqSecDefOptParams(spx.symbol, "", spx.secType, spx.conId) 98 | chain = next(c for c in chains if c.tradingClass == "SPXW" and c.exchange == "SMART") 99 | 100 | # Filter out puts and get the right expiration 101 | strikes = [ 102 | s 103 | for s in sorted(chain.strikes) 104 | if s < underlying_price and s > underlying_price * 0.93 105 | ] 106 | expirations = [e for e in chain.expirations if e == expiry] 107 | puts = [ 108 | Option("SPX", expiration, strike, right, "SMART", tradingClass="SPXW") 109 | for right in ["P"] 110 | for expiration in expirations 111 | for strike in strikes 112 | ] 113 | puts = ibkr.qualifyContracts(*puts) 114 | 115 | # Get tickers with greeks 116 | tickers = ibkr.reqTickers(*puts) 117 | contracts = df([t.contract for t in tickers]) 118 | 119 | try: 120 | model_greeks = df([t.modelGreeks for t in tickers]) 121 | contracts = contracts.merge(model_greeks, left_index=True, right_index=True) 122 | except TypeError: 123 | logger.warning("No model greeks available") 124 | 125 | contracts = contracts[contracts["delta"] > target_delta()] 126 | strike = contracts.iloc[-1].strike 127 | short_contract = [e for e in puts if e.strike == strike][0] 128 | 129 | logger.debug("Target short contract: {}", short_contract) 130 | return short_contract, contracts.iloc[-1].delta 131 | 132 | 133 | def _long_leg_contract_to_open( 134 | ibkr: IB, expiry: str, short_contract: Contract 135 | ) -> Contract: 136 | """ 137 | Get the long leg contract 138 | """ 139 | # TODO: check that the strike exists in the chain 140 | 141 | short_strike = short_contract.strike 142 | long_strike = short_strike - target_width() 143 | 144 | long_contract = Option( 145 | symbol="SPX", 146 | lastTradeDateOrContractMonth=expiry, 147 | strike=long_strike, 148 | multiplier=100, 149 | right="P", 150 | tradingClass="SPXW", 151 | exchange="SMART", 152 | currency="USD", 153 | ) 154 | long_contract = ibkr.qualifyContracts(long_contract)[0] 155 | logger.debug("Target long contract: {}", long_contract) 156 | return long_contract 157 | 158 | 159 | def get_spread_to_open(ibkr: IB, spreads: list[OptionSpread]) -> OptionSpread: 160 | """ 161 | Determine which spread to open 162 | """ 163 | expiry = next_trading_day() 164 | short_leg, short_delta = _short_leg_contract_to_open(ibkr, expiry) 165 | logger.debug("Short leg: {}", short_leg) 166 | long_leg = _long_leg_contract_to_open(ibkr, expiry, short_leg) 167 | logger.debug("Long leg: {}", long_leg) 168 | 169 | # assign size to the legs 170 | legs = [ 171 | OptionWithSize( 172 | position_size=-position_size(ibkr), option=short_leg, delta=short_delta 173 | ), 174 | OptionWithSize(position_size=+position_size(ibkr), option=long_leg, delta=None), 175 | ] 176 | logger.debug("Legs: {}", legs) 177 | 178 | spread = OptionSpread(legs=legs) 179 | return spread 180 | 181 | 182 | if __name__ == "__main__": 183 | ibkr = IB() 184 | ibkr.connect("localhost", 8888) 185 | contract = get_spread_to_open(ibkr, []) 186 | logger.info("Target spread: {}", contract) 187 | ibkr.disconnect() 188 | --------------------------------------------------------------------------------