Skip to content

Theory of Operation

Ron Hinchley edited this page Mar 24, 2026 · 2 revisions

Theory of Operation

Overview

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.


Startup Sequence

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

Connection & Transport

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.Decoder and dispatches to the Portfolio wrapper (which implements EWrapper)
  • send_msg() is thread-safe: if called from within the event loop it writes directly; from any other thread it schedules via call_soon_threadsafe

TWS/Gateway ports by account type:

Account TWS Port Gateway Port
Paper 7497 4002
Live 7496 4001

Market Data

Market Data Types

On connect, the engine probes IB to detect whether live data is available:

  1. Sends reqMarketDataType(1) then a SPY snapshot request
  2. If IB downgrades to type 3 (delayed), the callback fires → confirmed delayed
  3. If no callback within 5 seconds → confirmed live
Type Meaning
1 Live (requires data subscription)
3 Delayed (~15 min, free for all accounts)

Stream Lifecycle

Streams are reference-counted per symbol. When a plugin calls request_stream("SPY", ...):

  1. Executive checks if IB is already subscribed for SPY
  2. If not, sends reqMktData / reqRealTimeBars to IB
  3. Increments the ref-count for SPY
  4. 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.

Bar Aggregation

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.


Plugin Execution

Threading Model

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.

Signal Cycle

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

Circuit Breaker

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.


Holdings & Bookkeeping

What Holdings Track

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.

The Unassigned Plugin

_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

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.

Reconciliation

reconcile compares plugin holdings against the live IB account and reports discrepancies. It does not auto-correct — a reconciliation report is advisory.


Order Lifecycle

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()

Order Attribution

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.


State Persistence

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.


MessageBus

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)

CommandServer

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


Theory of Operation

  • Startup sequence
  • Market data & streams
  • Plugin execution
  • Holdings & bookkeeping
  • Order lifecycle
  • State persistence

CLI — Task Guide


Plugin Manual ← complete reference

Bar Store

Plugin Design

  • 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

Clone this wiki locally