REST API bridge to Interactive Brokers — place orders and list trades via HTTP, backed by the IB Gateway. Deployed to a DigitalOcean droplet with a single make deploy.
Warning
This project is under active development and not yet ready for prime time. You're welcome to use it, but expect frequent breaking changes.
Interacting with IBKR programmatically normally requires maintaining a persistent TWS or Gateway connection and speaking the IB API protocol. This project wraps that complexity behind a simple REST API:
- Place orders via
POST /ibkr/orderwith a JSON body - List trades via
GET /ibkr/trades— session trades + completed orders, deduplicated - Health check via
GET /ibkr/health— see connection status and trading mode - Automatic reconnection — exponential backoff + watchdog, survives Gateway restarts
- Browser-based 2FA — noVNC container provides browser access to the Gateway GUI for authentication
- Gateway lifecycle control — restart the Gateway container from a web endpoint without SSH
All deployed behind Caddy with automatic HTTPS, on a single DigitalOcean droplet.
Looking for trade fill polling and webhook delivery? See ibkr_relay — a companion project that polls the Flex Web Service for fills and forwards them to your webhook URL.
- API Endpoints
- Architecture
- Quick Start
- Configuration
- Domains & HTTPS
- Droplet Sizing
- Commands
- Pause & Resume
- Testing
- TypeScript Types
- Project Structure
- Security
- Current Status
All /ibkr/* endpoints require Authorization: Bearer <API_TOKEN> header.
POST /ibkr/order
{
"contract": {
"symbol": "AAPL",
"secType": "STK",
"exchange": "SMART",
"currency": "USD"
},
"order": {
"action": "BUY",
"totalQuantity": 10,
"orderType": "LMT",
"lmtPrice": 150.0,
"tif": "DAY"
}
}Response:
{
"status": "PreSubmitted",
"orderId": 684196618,
"action": "BUY",
"symbol": "AAPL",
"totalQuantity": 10,
"orderType": "LMT",
"lmtPrice": 150.0
}GET /ibkr/trades
Returns session trades + completed orders, deduplicated by permanent order ID:
{
"trades": [
{
"orderId": 684196618,
"action": "BUY",
"totalQuantity": 10,
"orderType": "LMT",
"lmtPrice": 150.0,
"tif": "DAY",
"symbol": "AAPL",
"secType": "STK",
"exchange": "SMART",
"currency": "USD",
"status": "Filled",
"filled": 10,
"remaining": 0,
"avgFillPrice": 149.95,
"fills": [
{
"execId": "0001f4e8.67890abc.01.01",
"time": "2026-04-10T10:30:00",
"exchange": "ISLAND",
"side": "BOT",
"shares": 10,
"price": 149.95,
"commission": 1.0,
"commissionCurrency": "USD",
"realizedPNL": 0.0
}
]
}
]
}GET /ibkr/health
Returns {"connected": true, "tradingMode": "paper"}. No auth required.
Note: Caddy rewrites
/ibkr/healthto/healthupstream. When accessing the bridge directly (e.g.http://localhost:15101), useGET /health.
GET /ibkr/ws/events
Streams real-time trade execution events over WebSocket. Requires Authorization: Bearer <API_TOKEN> (sent as HTTP header during the upgrade handshake).
Query parameters:
last_seq— replay buffered events withseq >this value (default:0)
Events are JSON envelopes wrapping bridge metadata (type, seq, timestamp, and on fill events source) around an inner fill payload whose nested contract / execution / commissionReport fields mirror ib_async 2.1.0 exactly:
{
"type": "commissionReportEvent",
"seq": 42,
"timestamp": "2026-04-11T10:30:00+00:00",
"source": "live",
"fill": {
"contract": {
"secType": "STK",
"conId": 265598,
"symbol": "AAPL",
"exchange": "SMART",
"primaryExchange": "NASDAQ",
"currency": "USD",
"..."
},
"execution": {
"execId": "0001f4e8.67890abc.01.01",
"time": "2026-04-11T10:30:00+00:00",
"acctNumber": "UXXXXXXX",
"exchange": "ISLAND",
"side": "BOT",
"shares": 10.0,
"price": 149.95,
"permId": 684196618,
"cumQty": 10.0,
"avgPrice": 149.95,
"..."
},
"commissionReport": {
"execId": "0001f4e8.67890abc.01.01",
"commission": 1.0,
"currency": "USD",
"realizedPNL": 0.0,
"yield_": 0.0,
"yieldRedemptionDate": 0
},
"time": "2026-04-11T10:30:00+00:00"
}
}Event types:
execDetailsEvent— fill executed (preliminary, may lack commission). Only emitted for live, same-user fills.commissionReportEvent— fill with commission data (confirmed). Emitted for live same-user fills and for fills surfaced by the cross-user reconcile path (see below).connected— bridge connected to IB Gatewaydisconnected— bridge lost IB Gateway connection
Each fill event carries a source field indicating provenance:
"live"— emitted by ib_async's push callbacks (real-time, same-user fills)"reconciled"— emitted by the position-triggeredreqExecutionspath (typically cross-user fills, e.g. orders placed from the mobile app)
Status events (connected / disconnected) carry only type, seq, and timestamp — no fill or source field. The WsEnvelope TypeScript type is a discriminated union over type, so consumers narrow with a single if (env.type === "commissionReportEvent") { ... } and TypeScript guarantees env.fill and env.source are present.
Features:
- Cross-user fill detection — when a position changes (including from orders placed by another IBKR user on the same account, e.g. from the mobile app), the bridge calls
reqExecutionsand broadcasts each new fill ascommissionReportEventwithsource: "reconciled". This complements the live callbacks, which IB only fires for fills the bridge's own user placed. - Server-side execId dedupe — the bridge tracks every broadcast
execId(keyed to its fill timestamp) and emits each fill at most once. Both the live and the reconcile broadcast paths check the dedupe map before emitting, so whichever path wins the race wins — the other drops silently. The map persists across reconnects (a transient connection blip never re-broadcasts today's fills) and is pruned at the start of every reconcile to drop entries older than 2 days — bounding memory to roughlyfills_per_day × 2entries. - Replay on reconnect — pass
?last_seq=Nto receive buffered events since that sequence number - Ring buffer — last 500 events buffered server-side (configurable via
WS_BUFFER_SIZE) - Up to 10 simultaneous subscribers (configurable via
WS_MAX_SUBSCRIBERS) - Zombie detection — server sends WebSocket pings every 30s (configurable via
WS_HEARTBEAT_INTERVAL)
POST /gateway/cgi-bin/start-gateway # Start the ib-gateway container
GET /gateway/cgi-bin/gateway-status # Check ib-gateway container state
These endpoints are served on the VNC domain (not SITE_DOMAIN), accessible via the gateway-controller container. The entire VNC domain is protected by HTTP Basic Auth (username defaults to admin, password is VNC_SERVER_PASSWORD). The bcrypt hash for Caddy is auto-computed from VNC_SERVER_PASSWORD at deploy time using htpasswd — no manual hash generation needed on macOS/Linux. On Windows, set VNC_BASIC_AUTH_HASH in .env directly (see env var table below).
┌──────────────────────────────────────────────────────────┐
│ DigitalOcean Droplet │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ caddy (reverse proxy + auto HTTPS) │ │
│ │ trade.example.com → bridge:5000 │ │
│ │ vnc.example.com → novnc:8080 │ │
│ │ vnc.example.com/gateway/* → controller:9000 │ │
│ │ Ports: 80 (HTTP→redirect), 443 (HTTPS) │ │
│ └───────┬─────────────┬─────────────┬──────────┘ │
│ │ │ │ │
│ ┌───────▼──────┐ ┌────▼─────┐ ┌─────▼──────────────┐ │
│ │ bridge │ │ novnc │ │ gateway-controller │ │
│ │ REST API │ │ Browser │ │ Start/status via │ │
│ │ Orders + │ │ VNC GUI │ │ Docker socket │ │
│ │ Trades │ │ │ │ │ │
│ └───────┬──────┘ └─────┬────┘ └────────────────────┘ │
│ │ │ │
│ ┌───────▼──────────────▼──────┐ │
│ │ ib-gateway │ │
│ │ IBKR Gateway (Java) │ │
│ │ TWS API on ports 4003/4004 │ │
│ │ VNC on port 5900 │ │
│ └─────────────────────────────┘ │
│ │
│ Firewall: SSH from deployer IP only │
│ HTTP/HTTPS open (Caddy auto-redirects HTTP → HTTPS) │
└──────────────────────────────────────────────────────────┘
Six containers in a single Docker network:
ib-gateway— IB Gateway running in Docker via gnzsnz/ib-gateway. Exposes the TWS API (port 4003 live / 4004 paper) and VNC (port 5900) for 2FA authentication. Configured withAUTO_RESTART_TIME(default11:30 PMinTIME_ZONE,America/New_Yorkby default, overridable) so IBC proactively restarts before IBKR's nightly ~11:45 PM ET session reset, writing an autorestart file that allows the next start to skip 2FA.restart: unless-stoppedkeeps the container running across these daily restarts — typically requiring 2FA only once per week when IBKR expires the session.bridge— Python aiohttp service that connects to the Gateway via ib_async and exposes the REST API. Handles auto-reconnection with exponential backoff and a periodic watchdog.novnc— noVNC browser-based VNC client for accessing the Gateway GUI. Used for 2FA authentication and monitoring. Auto-reconnects in the browser on unclean disconnect.caddy— Caddy 2 reverse proxy with automatic HTTPS via Let's Encrypt. Routes API traffic to the bridge, VNC traffic to noVNC.gateway-controller— Lightweight CGI container with Docker socket access. Provides HTTP endpoints to start/check the Gateway container without SSH. Also runs a background monitor that watches for ib-gateway exits: on a 2FA timeout it stops the restarted container (preventing an infinite restart loop) and sends an alert email prompting you to authenticate via VNC; on any other unexpected exit it sends a crash alert.autoheal— Watches containers labelledautoheal=trueand restarts them when they become unhealthy. Used to restartnovncwhen the VNC backend is unreachable.
- Docker Compose v2 (the Go rewrite,
docker compose) - Terraform installed
- A DigitalOcean API token
- An IBKR account (paper or live)
# 1. Clone and configure
git clone https://github.com/tradegist/ibkr_bridge.git
cd ibkr_bridge
make setup # Create .venv, install dependencies, and copy env templates
# Edit .env with your IB credentials, domains, and API token
# Edit .env.droplet with your deployment config (DEPLOY_MODE, DO_API_TOKEN, etc.)
# 2. Deploy
make deploy
# 3. Complete 2FA
# Open https://vnc.example.com in your browser and authenticate
# 4. Verify connection
curl -s https://trade.example.com/ibkr/health | python3 -m json.tool
# Should show: {"connected": true, "tradingMode": "paper"}
# 5. Place a test order (paper mode)
make order Q=1 SYM=AAPL T=MKT
# 6. Tear down when done
make destroyUse shared mode (DEPLOY_MODE=shared) when ibkr_bridge runs on a droplet that is already hosting another project (e.g. relayport). Caddy is provided by the host — ibkr_bridge deploys Caddy snippets to /opt/caddy-shared/ and connects to the host's shared Docker network.
# Set in .env.droplet (CLI-only config)
DEPLOY_MODE=shared
DROPLET_IP=1.2.3.4 # provided by the host project owner
SSH_KEY=~/.ssh/shared-key # provided by the host project owner
# Set in .env (pushed to the droplet so Compose can expand it)
SHARED_NETWORK=relay-net # must match the host project's network name
make deploymake deploy templates the Caddy snippet with a bcrypt hash derived from VNC_SERVER_PASSWORD. How it is computed depends on your OS:
macOS — htpasswd is built-in. make deploy computes the hash automatically, nothing to do.
Linux — install htpasswd first, then make deploy handles the rest:
apt install apache2-utils # Debian/UbuntuWindows — htpasswd is not available. Pre-compute the hash in Git Bash or WSL and add it to .env before running make deploy:
htpasswd -nbB admin yourpassword
# Output: admin:$2y$05$...
# Copy only the hash part (after "admin:") into .env:
VNC_BASIC_AUTH_HASH=$2y$05$...Run the stack locally without TLS or Caddy:
make local-up # build and start all services
make local-down # stop and removeEndpoints after startup:
| Service | URL |
|---|---|
| REST API | http://localhost:15101/health |
| VNC | http://localhost:15100 |
docker-compose.local.yml adds read-only bind mounts that shadow the baked-in image files with your local source tree. Code changes are visible on container restart — no rebuild needed:
make sync ENV=local # restart all containersmake local-up is only needed for the initial build or after changing requirements.txt / Dockerfile.
Configuration is split into two files to separate container config from CLI-only deployment config.
make setup copies the templates from env_examples/ automatically on first run.
| Variable | Required | Default | Description |
|---|---|---|---|
TWS_USERID |
Yes | — | IBKR username |
TWS_PASSWORD |
Yes | — | IBKR password |
VNC_SERVER_PASSWORD |
Yes | — | Password for VNC access to the Gateway GUI |
TRADING_MODE |
No | paper |
paper or live |
VNC_DOMAIN |
Yes | — | Domain for VNC access (e.g. vnc.example.com) |
SITE_DOMAIN |
Yes | — | Domain for the REST API (e.g. trade.example.com) |
API_TOKEN |
Yes | — | Bearer token for /ibkr/* endpoints (openssl rand -hex 32) |
JAVA_HEAP_SIZE |
No | 768 |
IB Gateway Java heap in MB. Determines auto-selected droplet size. |
TIME_ZONE |
No | America/New_York |
Timezone (tz database format) |
AUTO_RESTART_TIME |
No | 11:30 PM |
IBC scheduled daily restart time (HH:MM AM/PM, interpreted in TIME_ZONE). The default 11:30 PM is 15 minutes before IBKR's nightly ~11:45 PM ET session reset — if TIME_ZONE is not America/New_York, set this accordingly. Writes an autorestart file so the next start skips 2FA. |
VNC_BASIC_AUTH_USER |
No | admin |
Username for VNC domain basic auth |
VNC_BASIC_AUTH_HASH |
No | auto-computed | Bcrypt hash for VNC basic auth. Auto-derived from VNC_SERVER_PASSWORD at deploy time via htpasswd (macOS built-in; apt install apache2-utils on Linux). On Windows, pre-compute with htpasswd -nbB admin <password> and set here. |
WS_BUFFER_SIZE |
No | 500 |
Ring buffer size for WebSocket event replay on client reconnect |
WS_MAX_SUBSCRIBERS |
No | 10 |
Maximum simultaneous WebSocket subscribers |
WS_HEARTBEAT_INTERVAL |
No | 30 |
WebSocket ping interval in seconds for zombie connection detection |
SHARED_NETWORK |
Shared mode | — | Docker network name for cross-project communication (e.g. relay-net). Must be set in .env (not .env.droplet) so it gets pushed to the droplet for Compose to expand. Required when DEPLOY_MODE=shared. The CLI idempotently creates this network on the droplet and applies docker-compose.shared-network.yml to mark it external: true. |
RESEND_API_KEY |
No | — | Resend API key. Required to enable crash alerting. |
ALERT_REPORT_EMAIL_TO |
No | — | Recipient address for crash alert emails. Required to enable crash alerting. |
ALERT_EMAIL_FROM |
No | onboarding@resend.dev |
Sender address for alert emails. Must be a domain verified in your Resend account. Defaults to Resend's shared address if unset. |
The name reflects its origin (droplet infrastructure config) but its scope is broader: any var that belongs on the developer's machine rather than the server lives here — deployment credentials, SSH keys, and local CLI preferences like DEFAULT_CLI_ENV.
| Variable | Required | Default | Description |
|---|---|---|---|
DEPLOY_MODE |
Yes | — | standalone (own droplet via Terraform) or shared (deploy to existing droplet) |
DO_API_TOKEN |
Yes* | — | DigitalOcean API token (standalone mode only — can be removed after first deploy) |
DROPLET_IP |
Yes* | — | Droplet IP (from Terraform output in standalone; provided by host in shared) |
SSH_KEY |
No | ~/.ssh/ibkr-bridge |
SSH key path — shared mode only. In standalone, Terraform auto-generates the key; never set this. |
DROPLET_SIZE |
No | (auto) | Override droplet size slug (e.g. s-1vcpu-2gb). When set, ignores JAVA_HEAP_SIZE for sizing. |
DEFAULT_CLI_ENV |
No | prod |
Default CLI target: prod (HTTPS via SITE_DOMAIN) or local (localhost:15101) |
* DO_API_TOKEN is required for standalone mode only (first deploy). DROPLET_IP is set automatically by Terraform output in standalone, or provided by the host in shared mode.
Two domain names are required. Caddy uses them to automatically provision TLS certificates from Let's Encrypt.
- Point both domains to the droplet's reserved IP as A records:
trade.example.com A 1.2.3.4 vnc.example.com A 1.2.3.4 - Set them in
.env:SITE_DOMAIN=trade.example.com VNC_DOMAIN=vnc.example.com - Start the stack — Caddy will automatically obtain and renew the certificates.
The droplet size is auto-selected based on JAVA_HEAP_SIZE:
| Heap (MB) | Droplet | RAM |
|---|---|---|
| ≤ 1024 | s-1vcpu-2gb |
2 GB |
| ≤ 3072 | s-2vcpu-4gb |
4 GB |
| ≤ 6144 | s-4vcpu-8gb |
8 GB |
| > 6144 | s-8vcpu-16gb |
16 GB |
Override with DROPLET_SIZE in .env to use a specific slug regardless of heap size.
All operations are available via make or the Python CLI directly. Run make help to see the full list:
make deploy Deploy infrastructure (Terraform + Docker)
make destroy Permanently destroy all infrastructure
make pause Snapshot droplet + delete (save costs)
make resume Restore droplet from snapshot
make setup Create .venv and install all dependencies
make sync Push .env + restart (S=service B=1 LOCAL_FILES=1 SKIP_E2E=1 SKIP_POST_CHECK=1 ENV=local)
make sanity-check-deployment Run claude sanity check against the droplet
make order Place a stock order (Q=qty SYM=symbol T=type [P=price] ...)
make types Regenerate TypeScript types from Pydantic models
make test Run unit tests (pytest)
make typecheck Run mypy strict type checking
make lint Run ruff linter (FIX=1 to auto-fix)
make e2e Run E2E tests (starts/stops stack automatically)
make e2e-up Start E2E test stack (ib-gateway + bridge)
make e2e-run Run E2E tests (stack must be up)
make e2e-down Stop and remove E2E test stack
make local-up Start full stack locally (no TLS, direct port access)
make local-down Stop local stack
make logs Stream logs (S=service ENV=local)
make stats Show container resource usage
make gateway Start IB Gateway + show connection status
make ssh SSH into the droplet
make help Show available commands
You can also invoke the CLI directly with python3 -m cli <command>:
python3 -m cli deploy
python3 -m cli sync bridge
python3 -m cli order 10 AAPL MKT # BUY 10 AAPL at market
python3 -m cli order -5 TSLA LMT 250.00 # SELL 5 TSLA at $250 limit
python3 -m cli pause
python3 -m cli resume
python3 -m cli destroymake examples:
make deploy # provision droplet + start containers
make sync # push .env + restart all services
make sync S=bridge # push .env + restart bridge only
make sync B=1 # push .env + rebuild images + restart
make sync LOCAL_FILES=1 # rsync files + rebuild + restart (full deploy)
make order Q=10 SYM=AAPL T=MKT # BUY 10 AAPL at market
make order Q=-5 SYM=TSLA T=LMT P=250 # SELL 5 TSLA limit $250
make order Q=1 SYM=AAPL T=LMT P=150 TIF=GTC # GTC order
make order Q=1 SYM=AAPL T=LMT P=150 RTH=1 # Allow outside RTH
make order Q=100 SYM=SAP T=MKT CUR=EUR EX=IBIS # European exchange
make test # run unit tests
make typecheck # strict mypy checking
make lint # run ruff linter
make logs # stream service logs (droplet)
make logs S=bridge ENV=local # stream local bridge logsAfter changing a variable in .env, restart only the affected service:
| Variable | Service | Command |
|---|---|---|
API_TOKEN |
bridge | make sync S=bridge |
TRADING_MODE |
bridge, ib-gateway | make sync |
TWS_USERID/PASSWORD |
ib-gateway | make sync S=gateway |
VNC_SERVER_PASSWORD |
ib-gateway | make sync S=gateway |
SITE_DOMAIN |
caddy | make sync S=caddy |
VNC_DOMAIN |
caddy | make sync S=caddy |
RESEND_API_KEY |
gateway-controller | make sync S=gateway-controller |
ALERT_REPORT_EMAIL_TO |
gateway-controller | make sync S=gateway-controller |
ALERT_EMAIL_FROM |
gateway-controller | make sync S=gateway-controller |
| Multiple or unsure | all | make sync |
When DEFAULT_CLI_ENV=local (or ENV=local), make sync restarts all containers. Bind mounts in docker-compose.local.yml ensure code changes are picked up automatically:
make sync # restart (when DEFAULT_CLI_ENV=local)
make sync ENV=local # explicit overridemake sync only pushes .env and restarts containers. When you change Python code, Dockerfiles, or Compose config, use LOCAL_FILES=1:
make sync LOCAL_FILES=1This runs a pre-deploy pipeline (branch check → clean tree → typecheck → tests → rsync → rebuild).
After make sync LOCAL_FILES=1 and make deploy, the CLI runs a best-effort sanity check that SSHes into the droplet, captures docker compose ps and recent logs (docker compose logs --since 5m --tail 100 — --tail is per service, total capped at 50,000 characters before sending), then pipes the captured text via stdin to the local claude CLI for summarization into a one-line [GREEN|YELLOW|RED] verdict. Claude runs as a pure text summarizer (no tool access) — SSH/docker invocation happens from Python, so there's no agent-driven shell execution.
The check is best-effort: missing claude binary, SSH failure, network/auth/rate-limit errors, or a 60s timeout each produce a one-line warning and never abort the deploy. Opt out via SKIP_POST_DEPLOY_CHECK=1, --skip-post-check, or make sync SKIP_POST_CHECK=1 / make deploy SKIP_POST_CHECK=1.
Run on demand without syncing — ignores both opt-outs since you explicitly invoked it:
make sanity-check-deploymentTo stop billing for the droplet without losing state:
make pause # snapshot droplet + delete
make resume # restore from snapshot + reassign IPCosts while paused:
- Droplet: $0 (deleted)
- Snapshot: ~$0.06/GB/month
- Reserved IP: $5/month while unassigned (free when assigned)
After resume, open the VNC URL to complete 2FA — the Gateway needs re-authentication.
make test # run unit tests (pytest)
make typecheck # strict mypy checking
make lint # run ruff linter (FIX=1 to auto-fix)E2E tests run against a local Docker stack (docker-compose.test.yml) with the IB Gateway and bridge service. Paper account credentials are required — real orders are placed in paper mode.
make e2e # start stack → run tests → stop stack
make e2e-up # start test stack (waits up to 240s for Gateway connection)
make e2e-run # run E2E tests (stack must be up)
make e2e-down # stop and remove test stack- Credentials live in
.env.test(gitignored). Template:env_examples/env.test. make e2e-runrestarts thebridgecontainer to pick up code changes from volume mounts.- Test bridge runs on
localhost:15010with tokentest-token. - Gateway startup takes 30–120 seconds (Java + IBKR authentication). The
e2e-uptarget waits up to 240 seconds and detects session conflicts (another TWS/Gateway using the same credentials).
API types are available as a TypeScript package under types/typescript/:
types/typescript/
index.d.ts # Top-level barrel (hand-maintained; namespace map)
package.json # @tradegist/ibkr-bridge-types
http/
index.d.ts # Auto-generated barrel (re-exports every public type)
types.d.ts # Auto-generated from bridge_models.py SCHEMA_MODELS
types.schema.json # Intermediate JSON Schema
Everything under http/ is regenerated by make types. The discriminated union WsEnvelope is emitted automatically by the JSON Schema → TypeScript pipeline — no hand-edited union aliases.
Usage:
import type { IbkrBridgeHttp } from "@tradegist/ibkr-bridge-types";
const req: IbkrBridgeHttp.PlaceOrderPayload = {
contract: { symbol: "AAPL", secType: "STK", exchange: "SMART", currency: "USD" },
order: { action: "BUY", totalQuantity: 10, orderType: "MKT" },
};
const resp: IbkrBridgeHttp.PlaceOrderResponse = ...;
const trades: IbkrBridgeHttp.ListTradesResponse = ...;Types are auto-generated from the Pydantic models via make types. The package is not yet published to npm.
├── Makefile # CLI shortcuts (make deploy, make order, etc.)
├── cli/ # Python CLI (replaces shell scripts)
│ ├── __init__.py # Project-specific config (CoreConfig, helpers)
│ ├── __main__.py # Entry point (python3 -m cli <command>)
│ ├── order.py # Place an order via CLI
│ └── core/ # Project-agnostic (reusable across projects)
│ ├── __init__.py # CoreConfig dataclass, generic helpers
│ ├── deploy.py # Standalone (Terraform) or shared (rsync + compose)
│ ├── destroy.py # Terraform destroy
│ ├── pause.py # Snapshot + delete droplet
│ ├── resume.py # Restore from snapshot
│ └── sync.py # rsync files + pre-deploy checks + restart
├── env_examples/ # Configuration templates (make setup copies to .<name>)
│ ├── env # App config template (.env)
│ └── env.droplet # CLI-only deployment config template (.env.droplet)
├── docker-compose.yml # Container orchestration (6 services)
├── docker-compose.shared.yml # Shared-mode overlay (disables Caddy)
├── docker-compose.shared-network.yml # Marks SHARED_NETWORK as external (CLI pre-creates)
├── docker-compose.local.yml # Local dev override (direct port access, no TLS)
├── docker-compose.test.yml # E2E test override (paper mode, no Caddy/noVNC)
├── services/
│ └── bridge/
│ ├── Dockerfile # Python 3.11-slim
│ ├── requirements.txt # ib_async, aiohttp, httpx, pydantic
│ ├── main.py # Entrypoint (IB connection + HTTP server)
│ ├── bridge_models.py # Pydantic models (request/response types)
│ ├── client/ # IB Gateway client
│ │ ├── __init__.py # IBClient (connection, reconnection, watchdog, fill reconcile)
│ │ ├── event_hub.py # EventHub (pub/sub broadcast + ring buffer for WS replay)
│ │ ├── orders.py # OrdersNamespace (place orders)
│ │ └── trades.py # TradesNamespace (list trades + fills)
│ ├── bridge_routes/ # HTTP API
│ │ ├── __init__.py # Route orchestrator (create_routes)
│ │ ├── constants.py # Shared constants (AUTH_PREFIX, client_key, hub_key)
│ │ ├── health.py # GET /health handler
│ │ ├── middlewares.py # Auth middleware (Bearer token, HMAC-safe)
│ │ ├── order_place.py # POST /ibkr/order handler
│ │ ├── trades_list.py # GET /ibkr/trades handler
│ │ └── ws_events.py # GET /ibkr/ws/events WebSocket handler
│ └── tests/e2e/ # E2E tests
│ ├── conftest.py # httpx fixtures (api + anon_api)
│ └── test_smoke.py # Health + auth smoke tests
├── infra/
│ ├── caddy/
│ │ ├── Caddyfile # Reverse proxy config (SITE_DOMAIN + VNC_DOMAIN)
│ │ ├── docker-entrypoint.sh # Hashes VNC_SERVER_PASSWORD → VNC_BASIC_AUTH_HASH
│ │ ├── sites/
│ │ │ └── ibkr-bridge.caddy # SITE_DOMAIN API routes (/ibkr/order, /ibkr/trades, /ibkr/ws/events, /health)
│ │ └── domains/
│ │ └── ibkr-vnc.caddy # VNC_DOMAIN routes (noVNC + gateway-controller, basic auth)
│ ├── gateway-controller/
│ │ ├── Dockerfile # Alpine + docker CLI for container control
│ │ ├── entrypoint.sh # Starts monitor-gateway in background, then execs httpd
│ │ ├── start-gateway.sh # CGI: start ib-gateway container
│ │ ├── gateway-status.sh # CGI: check ib-gateway status
│ │ └── monitor-gateway.sh # Background daemon: watch for unexpected exits, send Resend alert
│ └── novnc/
│ └── index.html # Custom noVNC landing page
├── terraform/
│ ├── main.tf # Droplet, firewall, reserved IP, SSH key
│ ├── variables.tf # Terraform variables
│ ├── outputs.tf # Droplet IP, VNC URL, Site URL, SSH key
│ └── cloud-init.sh # Docker install + project directory
├── schema_gen.py # JSON Schema generator (Pydantic → TS types)
└── types/
├── typescript/ # @tradegist/ibkr-bridge-types npm package
│ ├── index.d.ts # Barrel: exports IbkrBridgeHttp namespace
│ ├── package.json
│ └── http/ # IbkrBridgeHttp namespace
│ ├── index.d.ts
│ └── types.d.ts # Generated from bridge_models.py SCHEMA_MODELS
└── python/ # ibkr-bridge-types PyPI package
└── ibkr_bridge_types/
- Firewall restricts SSH (22) to the deployer's IP only
- HTTP/HTTPS open (Caddy auto-redirects HTTP → HTTPS)
- All
/ibkr/*endpoints require Bearer token (HMAC-safe comparison) - Empty
API_TOKENreturns HTTP 500 (preventshmac.compare_digest("", "")bypass) - VNC access is password-protected (
VNC_SERVER_PASSWORD) - No credentials stored in the repository
- Terraform infrastructure (droplet, firewall, SSH key, reserved IP)
- Docker Compose orchestration (5 containers)
- IB Gateway connection with auto-reconnect + watchdog
- Place orders (market + limit, with TIF and outside-RTH support)
- List trades (session + completed, deduplicated)
- Gateway lifecycle control (start/status via HTTP)
- Browser-based VNC for 2FA
- HTTPS via Caddy + Let's Encrypt
- Makefile CLI (
make deploy,make order, etc.) - TypeScript type definitions (
@tradegist/ibkr-bridge-types, not yet published) - E2E tests against paper account
- Deploy modes: standalone (Terraform) + shared (existing droplet)
- Crash alerting via email (Resend) — 2FA timeouts stop the container + send alert, unexpected exits send a crash alert
- VNC auto-reconnect — browser retries on disconnect, no manual refresh needed
- Cancel order endpoint