-
-
Notifications
You must be signed in to change notification settings - Fork 0
Theory of Operation
TWS Headless is a single-process asyncio application. One coroutine reads the IB socket; everything else runs on top of that event loop or in dedicated threads managed by the executive.
main.py
1. Parse config (port, client_id, plugin dirs, ...)
2. Create Portfolio, DataFeed, MessageBus, PluginExecutive, CommandServer
3. AsyncIBTransport.connect(host, port, client_id)
4. Handshake with TWS (send API sign + version → receive server version)
5. on_connected() fires
a. Probe market data type (live vs. delayed, 5-second timeout)
b. Portfolio.load() → pull positions, account summary, open orders
c. DataFeed.start()
d. PluginExecutive.start() → load + start all configured plugins
e. CommandServer.start() → begin accepting ibctl connections
6. asyncio event loop runs indefinitely
AsyncIBTransport replaces the IB EClient/EReader/Connection stack with a single coroutine:
- Reads 4-byte length-prefix frames from the TCP socket
- Decodes messages via
ibapi.Decoderand dispatches to thePortfoliowrapper (which implementsEWrapper) -
send_msg()is thread-safe: if called from within the event loop it writes directly; from any other thread it schedules viacall_soon_threadsafe
TWS/Gateway ports by account type:
| Account | TWS Port | Gateway Port |
|---|---|---|
| Paper | 7497 | 4002 |
| Live | 7496 | 4001 |
On connect, the engine probes IB to detect whether live data is available:
- Sends
reqMarketDataType(1)then a SPY snapshot request - If IB downgrades to type 3 (delayed), the callback fires → confirmed delayed
- If no callback within 5 seconds → confirmed live
| Type | Meaning |
|---|---|
| 1 | Live (requires data subscription) |
| 3 | Delayed (~15 min, free for all accounts) |
Streams are reference-counted per symbol. When a plugin calls request_stream("SPY", ...):
- Executive checks if IB is already subscribed for SPY
- If not, sends
reqMktData/reqRealTimeBarsto IB - Increments the ref-count for SPY
- When the last plugin calls
cancel_stream("SPY"), the ref-count hits zero and IB is unsubscribed
Multiple plugins watching the same symbol pay no extra IB cost.
IB delivers 5-second bars (reqRealTimeBars). The BarAggregator inside DataFeed accumulates these into:
- 1-minute bars (12 × 5-sec)
- 5-minute bars (60 × 5-sec)
- 15-minute bars (180 × 5-sec)
- 1-hour bars (720 × 5-sec)
A completed bar fires the on_bar callback when its boundary closes. The current in-progress bar keeps updating.
The engine uses three thread boundaries:
| Thread | What runs here |
|---|---|
| asyncio event loop | IB socket read/decode, Portfolio callbacks, DataFeed delivery |
| executive runner thread |
calculate_signals() for all plugins, signal reconciliation, order dispatch |
| socket/command thread |
handle_request(), ibctl command handlers |
Plugin callbacks map to threads:
| Callback | Thread |
|---|---|
on_tick, on_bar, on_tick_by_tick, on_depth
|
IB reader (asyncio) |
on_order_fill, on_order_status, on_commission, on_pnl, on_ib_error
|
IB reader (asyncio) |
calculate_signals |
executive runner |
handle_request |
socket/command |
start, stop, freeze, resume
|
executive control |
Rule: IB reader callbacks must return quickly. No blocking I/O, no locks that may wait, no heavy computation.
On every execution tick (driven by bar boundaries or a timer):
ExecutiveRunner
for each plugin (in registration order):
if plugin.is_enabled and not plugin.is_frozen:
signals = plugin.calculate_signals()
Reconcile signals:
- Group by symbol
- Resolve conflicts (same symbol, multiple plugins)
- Apply position limits
For each actionable signal:
Build IB order (contract + Order object)
portfolio.place_order_custom(contract, order)
Register order with originating plugin
Each plugin has an independent circuit breaker for calculate_signals:
- Closed (normal): signals flow
- Open (tripped): after 5 consecutive unhandled exceptions; signals suppressed
- Half-open (recovery): after 5 minutes; one attempt allowed
- Closed again: on first successful run after half-open
Exceptions are logged at ERROR level. The plugin is not stopped — it just stops generating signals until the breaker resets.
Each plugin maintains two ledgers:
| Ledger | Purpose |
|---|---|
initial_funding |
Cash and positions at the time the plugin was funded |
current_holdings |
Live cash balance and open positions |
Holdings persist across restarts in SQLite. They are plugin-internal bookkeeping and do not constrain what IB actually holds.
_unassigned is a system plugin that acts as the "unallocated pool". When the portfolio is loaded, positions and cash not claimed by any plugin land in _unassigned. Operators fund other plugins by transferring from _unassigned.
Transfers are pure bookkeeping — no IB orders are placed:
transfer cash _unassigned → momentum_plugin $10,000
transfer position _unassigned → momentum_plugin SPY 100
The source plugin's cash/positions decrease; the destination plugin's increase. Nothing changes at IB.
reconcile compares plugin holdings against the live IB account and reports discrepancies. It does not auto-correct — a reconciliation report is advisory.
plugin.calculate_signals()
→ TradeSignal(symbol="SPY", action="BUY", quantity=10)
ExecutiveRunner
→ Build Contract (via ContractBuilder)
→ Build Order (MKT, LMT, etc.)
→ portfolio.place_order_custom(contract, order)
→ IB reqId allocated
→ send to IB via send_msg()
IB → orderStatus callbacks → portfolio → plugin.on_order_status()
IB → execDetails callback → portfolio → plugin.on_order_fill()
IB → commissionReport → portfolio → plugin.on_commission()
When a signal produces an order, the executive links the IB orderId back to the originating plugin. Fills, status changes, and commission reports are then routed to that plugin's callbacks.
For orders shared across multiple plugins (future feature), commission is split proportionally.
All persistence uses SQLite. The plugin registry path is per-account — ~/.ib_plugin_store_<account_id>.db — automatically selected at connect time based on the IB account number. Default (no account detected): ~/.ib_plugin_store.db.
| Table | Contents |
|---|---|
plugin_states |
Arbitrary key-value state dict per plugin |
plugin_holdings |
Cash balances per plugin |
plugin_positions |
Initial and current positions per plugin |
forex_cost_basis |
Original cost basis for forex positions |
migration_log |
Records of one-time JSON→SQLite migrations |
schema_versions |
Schema version tracking |
Execution history (fills, commissions) lives in a separate DB: ~/.ib_executions.db.
The MessageBus is an in-process pub/sub system. Plugins use it to share indicators and signals without tight coupling:
SMAPublisher plugin → publish("indicators_sma", {"sma_20": 452.3})
SMASubscriber plugin ← subscribe("indicators_sma", self._on_sma)
- Each channel retains the last 1,000 messages
- Delivery is synchronous (callback fires in the publisher's thread)
-
publish()is thread-safe (uses an internal RLock)
ibctl connects to the engine via a Unix domain socket at /tmp/tws_headless.sock. Each command is a line of text; the response is JSON. An optional token file enables simple authentication.
The server is single-threaded per connection but handles multiple connections via asyncio.
TWS Headless
- Startup sequence
- Market data & streams
- Plugin execution
- Holdings & bookkeeping
- Order lifecycle
- State persistence
- See what's going on
- Fund a plugin
- Transfer assets
- Load and start a plugin
- Stop or pause a plugin
- Place a manual trade
- Send a plugin request
- Manage instrument list
- Reconcile holdings
- Move paper → live
- Shut down
- Full command reference
Plugin Manual ← complete reference
- File layout
- Lifecycle methods
- State persistence
- Market data streams
- Trade signals
- Order callbacks
- Holdings management
- MessageBus
- ContractBuilder
- Instrument compliance
- Multiple instances (slots)
- CLI help & messaging
- Threading rules
- Full example