A Python/aiohttp bridge between IB Gateway and consumer services (relayport and others). Exposes a small REST + WebSocket API for placing orders, listing trades, and streaming fills.
This file holds cross-cutting rules that apply everywhere. Per-directory rules live in nested CLAUDE.md files (loaded on demand when Claude touches files in that subtree). Architectural prose lives in docs/ARCHITECTURE.md.
| When you are working in… | Read… |
|---|---|
cli/ |
cli/CLAUDE.md — deploy modes, env files, rsync invariant, Makefile rules |
services/ (anywhere) |
services/CLAUDE.md — test conventions, model layout, E2E |
services/bridge/ |
services/bridge/CLAUDE.md — IB Gateway connection, auth, WS streaming, reconcile |
services/shared/ |
services/shared/CLAUDE.md — reserved-but-empty shared namespace |
infra/ |
infra/CLAUDE.md — Caddy snippets, route_prefixes |
infra/gateway-controller/ |
infra/gateway-controller/CLAUDE.md — controller endpoints + monitor-gateway |
types/ |
types/CLAUDE.md — TS + Python types regeneration |
Playbooks (rare procedures) live as skills in .claude/skills/:
export-new-model-to-types— register a name inschema_gen.py:SCHEMA_MODELSand regeneratebump-ib-async-version— keepbridge_models.pyWS shapes in sync withib_async
This project (ibkr_bridge) and its sibling project relayport share the same CLI deploy/destroy/sync infrastructure pattern. Any change to cli/core/deploy.py, cli/core/destroy.py, or cli/core/sync.py in this project must be mirrored in the sibling project, and vice versa. This includes: Terraform state management, reserved IP handling, rsync exclusions, env file push logic, and compose startup commands. When you modify CLI core logic here, explicitly remind the user to apply the equivalent change to relayport, and offer to do it in the same session.
- Always apply best practices by default. Use idiomatic Python naming, file organization, and patterns. When a clearly better approach exists, use it directly and explain why — don't ask permission.
- NEVER use deprecated APIs. Examples:
asyncio.get_event_loop()→asyncio.get_running_loop();datetime.utcnow()→datetime.now(UTC); Pydantic v1parse_obj/dict()→ v2model_validate/model_dump. Scan docs for "deprecated" before relying on anything new. A deprecation warning in CR is a regression — fix the call, don't suppress. - No unused imports. After any edit, verify every
importis used. Remove what isn't. - No
__all__. All imports are explicit (from module import X); star-imports are never used.make linthas a hard fail that greps for__all__and aborts. - No
assertfor runtime guards.assertis stripped underpython -O. Useif … raise RuntimeError(...)(ordie()) for invariants that must hold at runtime. - Run
make lintafter every code change. Ruff enforces unused imports, import ordering, unused variables, bugbear pitfalls, modern idioms.make lint FIX=1auto-fixes safe issues. - Centralise env var reads into typed getter functions. Each env var is read in exactly one place — a getter in the module that owns it (e.g.
get_ib_host()inclient/__init__.py). Getters apply.strip()and type conversion. Never callos.environ.get()inline outside a getter. - Getters must validate and fail fast. Every getter must validate and
raise SystemExit("<descriptive message>")on bad input. Wrapint()/float()intry/except ValueError. Check emptiness on required strings. Callers should never have to validate a getter's return value. - Prefer pure functions over side-effect functions. Compute and return values; let the caller decide. If a side-effect function is truly unavoidable, add an inline comment at every call site explaining what is mutated and why.
- Never bulk-set
os.environwith empty-string fallbacks.os.environ[key] = env(name, "")silently overrides downstream defaults (Terraformvariabledefaults, library config) with empty strings, breakingtonumber(), validation blocks, and non-string parsing. Only export when source is present and non-empty;os.environ.pop(key, None)otherwise. - Verify Markdown table integrity after every edit. Count column dividers on changed row(s) AND the header/separator rows — all must match. Known failure modes: (1) bare
|inside a cell splits the row — escape as\|or rewrite; (2) extra| ----- |in separator. Sanity check:awk -F'\|' 'NR>=START && NR<=END { print NR": "NF" cells" }' file.md. - Update README.md when changing public interfaces. CLI commands, Makefile targets, API endpoints, env vars.
- Register new modules in
pyproject.toml. When adding a new Python package or module underservices/ortypes/python/, immediately add it topyproject.toml(testpaths,tool.ruff.src,known-first-party) and the Makefilelint:/typecheck:targets.
- No hardcoded credentials. Passwords, API tokens, secrets, keys must come from env vars (
.env,TF_VAR_*). Never write real values in source files. - No hardcoded IPs. Use
DROPLET_IPfrom.env.droplet. In docs use1.2.3.4as placeholder. - No hardcoded domains. Use
example.comvariants in docs and code (trade.example.com,vnc.example.com); actual domains loaded at runtime viaSITE_DOMAIN/VNC_DOMAIN. - No email addresses or personal info. Never write real names, emails, or IBKR account IDs in committed files.
- No developer-machine paths. Never write absolute paths like
/Users/john/…orC:\Users\john\…in committed files. Reference sibling projects by name only. - No logging of secrets or sensitive operational data. Never
log.info()tokens, passwords, API keys, account IDs, IPs, or domains. Log actions and outcomes, not credential values. Prefer counts, symbols, statuses over full objects. .env,.env.droplet,.env.test,*.tfvarsare gitignored. Never commit them. Useenv_examples/templates with placeholder values.- Terraform state is gitignored —
terraform.tfstatecontains SSH keys and IPs. - Auth middleware must reject empty
API_TOKEN.hmac.compare_digest("", "")returnsTrue, so an emptyAPI_TOKENsilently disables auth. The middleware checksif not api_token: return HTTP 500beforecompare_digest.API_TOKENis inrequired_envfor standalone deploy, where the CLI blocks deployment if missing; sync/shared deploy rely on correct.envcontents rather than explicitrequired_envvalidation.
- Python >= 3.11. Uses
X | Noneunion syntax natively (nofrom __future__ import annotations). Docker usespython:3.11-slim. - Run
make typecheck,make test, andmake lintafter every code change. Non-negotiable before deploying. mypy + ruff + pytest must all pass.make typecheckalso runstsc --noEmitontypes/typescript/. - Run E2E tests after modifying any E2E test OR infrastructure file (
docker-compose*.yml,Dockerfile,Caddyfile, anything underinfra/). E2E requires the Docker stack with IB Gateway. Workflow:make e2e-up— start the stack (idempotent, waits up to 240s for Gateway connection).make e2e-run— run tests.- Fix code →
make e2e-run→ repeat. Volume mounts keep code in sync — no rebuild. make e2e-down— tear down only after all tests pass.
- Every Python file must be covered by
make typecheck. New module → add to the mypy invocation in the Makefile. - After modifying any model in
services/bridge/bridge_models.py, runmake typesto regenerate TypeScript + Python type packages. - Always verify type safety by breaking it first. After any refactor that touches types, deliberately introduce a type error, run
make typecheck, confirm it fails. Then revert. Never assume mypy catches something — prove it. - Avoid
dict[str, Any]round-trips. Nevermodel_dump()→dict→Model(**data). Use explicit kwargs ormodel_copy(update=...). - Prefer strict
Literaltypes over barestron Pydantic models. Financial code demands precision. Use existingLiteralaliases (Action,OrderType,SecType,TimeInForce,ExecSide) when the value set is known. Fall back tostronly when IB Gateway genuinely returns unbounded values — document why inline (seeTradeDetail.actionandTradeDetail.orderType). - No
# type: ignorewithout justification. Fix the root cause. If unavoidable (untypedib_asyncattributes):# type: ignore[attr-defined] # ib_async.Foo has no stubs. Bare# type: ignoreis never acceptable. - Use
cast()instead of# type: ignore[arg-type]. When passing a mock or compatible object where mypy expects a concrete type (e.g.IBClient),cast(IBClient, mock).cast()preserves downstream type-checking;# type: ignoresilently disables it. - Use
cast()forib_asyncvalues. The library has no type stubs. When mappingib_asyncvalues to typed models,cast(ExecSide, ex.side)asserts the correct type without# type: ignore. - Use
@overloadfor sentinel-default patterns. Express the two signatures via@overloadinstead of# type: ignoreon the return.
Field(default_factory=list)for mutable defaults — only when genuinely optional. Never bare[]or{}.- Do not add defaults to fields that are always populated. A default makes the field optional in the generated JSON Schema / TS. Defaults only for fields that are legitimately absent.
ConfigDict(extra="forbid")on external-contract models (API request/response, WS envelopes). ProducesadditionalProperties: falsein JSON Schema, keeping generated TS strict.
- Every error must produce a clear, actionable message. Include context: operation, input identifier, upstream status.
- API responses must never leak internal details. Return structured error JSON with appropriate HTTP status. Never expose tracebacks, paths, or class names to callers.
- Isolate failures. The bridge has multiple concerns (connection, orders, trades, WS broadcast). A failure in one must not take down the others.
- Never silently swallow errors. Every
exceptmustlog.exception(...)or re-raise. Bareexcept: passis never acceptable. log.exception()for unexpected errors. Reservelog.error()for known/expected failures where a traceback is noise.- Distinguish recoverable from fatal. Connection losses are recoverable (auto-reconnect). Missing config → fail fast with
raise SystemExit(msg). SystemExitmust carry a descriptive message. Neverraise SystemExit(1).- Env var parsing must fail fast, not fall back silently. Wrap
int()/float()intry/except ValueError: raise SystemExit(...). Fall back only on missing vars, never on invalid values. - Validate at system boundaries, trust internally. Validate at the entry point (API payloads, env vars, IB Gateway responses). Once validated, internal code does not re-validate.
- Never assume a default for financial enum fields. When mapping IB Gateway values to constrained types, validate exactly. For read-only fields with genuinely unbounded values (e.g.
TradeDetail.action,TradeDetail.orderType), usestrwith an inline comment explaining why. - HTTP handlers must catch and map exceptions. Distinguish
ValueError(400) fromRuntimeError(500) and return structured JSON responses. - Include context in error messages. Bad:
"Order failed". Good:"Contract qualification failed for AAPL: timeout after 20s".
- Assume concurrency by default. The bridge is async (aiohttp). Any handler can be interrupted at an
await. Before merging any code touching shared state, ask: "Can two callers interleave here? What breaks?" - The
IBClientis shared across all handlers. Theibconnection object is stored on theIBClientsingleton, shared viaaiohttp.web.AppKeyacross all concurrent requests. Do not store request-specific state on the client. - Financial operations require extra scrutiny. Order placement, account mutation — review for races, double-execution, partial failure, idempotency.
- Use
asyncio.get_running_loop(), neverasyncio.get_event_loop().get_event_loop()is deprecated since 3.10 and the behaviour will change to an error. Every call site that needs a loop runs inside one (syncib_asynccallbacks dispatched on the running loop, asyncIsolatedAsyncioTestCasemethods), soget_running_loop()always works. Tests patchingloop.call_latermust useget_running_loop()and beasync defmethods onIsolatedAsyncioTestCase—get_event_loop()in a sync test silently creates a fresh loop nobody runs, masking real bugs. - Schedule background tasks via
asyncio.get_running_loop().create_task(coro), notasyncio.ensure_future(coro).ensure_futurefalls back toget_event_loop()and may attach to a stale loop. Retain the returnedTaskin a tracking set (self._background_tasks) withtask.add_done_callback(set.discard)to prevent GC mid-run. - Reconnection is asynchronous.
on_disconnect()schedules_reconnect()viaget_running_loop().create_task(...)— handlers must checkclient.is_connectedbefore performing operations and return 503 if disconnected.
- All dependencies use exact pins (
==). Both runtime (services/bridge/requirements.txt) and dev (requirements-dev.txt). Builds must be reproducible — never>=,~=, or unpinned versions. requirements-dev.txtcontains only dev-only tools (mypy, pytest, ruff). Runtime deps belong in servicerequirements.txt. Both installed together — never add a runtime dep torequirements-dev.txt(creates duplicate Dependabot PRs).- New dep → pin immediately. Runtime: exact (
==) in service file. Dev: exact (==) in dev file.
Each image has a different risk profile for Dependabot bumps:
| Image | Risk | When to merge |
|---|---|---|
ghcr.io/gnzsnz/ib-gateway |
CRITICAL | Never auto-merge. Manual review + E2E required. Changes can affect TWS API behaviour, IBC restart logic, 2FA handling, and the VNC RFB-banner health check. Check the upstream changelog against the autorestart file, IBC version, and port config before merging. |
python:3.11-slim |
Medium | Patch bumps within 3.11 are safe. Never bump to a different minor without running make test, make typecheck, make e2e. |
caddy:2-alpine |
Medium | Minor/patch within 2.x is generally safe — stable config format. Scan changelog for directive changes. |
theasp/novnc |
Medium | SHA digest bumps generally safe. Verify the RFB banner health check still passes. |
docker:XX-cli |
Medium | monitor-gateway.sh uses {{.Actor.ID}} (Docker 29+). Patch/minor within the same major are safe; major bumps need a Docker events API compatibility check. |
alpine:3.XX |
Low | Minor/patch safe. Major bumps require verifying all apk add packages still resolve. |
willfarrell/autoheal |
Low | SHA digest bumps safe to auto-merge — low-blast-radius watchdog. |
Rule: Low can be merged without manual testing. Medium requires a changelog review. CRITICAL requires manual E2E testing.
- Python:
loggingmodule, f-strings,aiohttpfor the async HTTP server,ib_asyncfor IB Gateway communication. - CLI: stdlib only (
subprocess,urllib.request,json,os). No third-party deps. Lazy dispatch viaimportlib.import_module. - Terraform: secrets marked
sensitive = trueinvariables.tf.
Maintenance note. Each directory CLAUDE.md has a Copilot mirror at .github/instructions/<slug>.instructions.md with an applyTo: glob — the slug is hyphenated path (infra-gateway-controller), not necessarily a single directory name. Root rules also mirror to .github/copilot-instructions.md. When editing any CLAUDE.md, update its mirror in the same commit. See docs/INSTRUCTION_FILES.md for the full layout and sync rules.