Part of the IBKR Saga
The question wasn't hard to answer. A stop order is the most basic risk management primitive in trading — the floor under every position. If the broker can't be trusted to execute it, the broker can't be trusted. I stopped looking for a fix and started looking for a replacement.
The trust problem
After the stop order incident, I had a system that technically worked. The Python port handled IBKR's encryption. The hardening code caught silent failures. But every time I walked away from the terminal, there was a question in the back of my mind: is it actually doing what I told it to?
If I can't trust the broker to execute a stop order — the single most important risk management primitive — then the broker is a liability, not infrastructure.
This wasn't a technical decision. It was a risk decision. And risk decisions in trading are binary: you either trust the counterparty or you don't. I didn't.
What I actually needed
Before evaluating replacements, I wrote down what mattered. Not features — requirements. The things that, if absent, would make a broker unusable for this system:
- Authentication I don't have to port. IBKR required a custom Python encryption bridge — a dependency I wrote and own, on a protocol I didn't design. TradeStation uses OAuth2. Standard libraries, automatic token refresh, invisible. The system runs for days; auth shouldn't be something I think about.
- Stop orders that actually execute. This sounds absurd to list as a requirement. It wasn't absurd to me anymore.
- Streaming data feeds. The system needs real-time bar data for signal generation. Polling is not an option at the intervals I trade.
- Futures support with continuous contracts. I trade futures. The broker needs to support them natively, including continuous contract symbols, so I'm not manually rolling contracts in code.
TradeStation met all four. Tradovate met most of them and became the secondary broker. ThinkOrSwim stayed as a data-only source — their API doesn't support futures order execution.
The rebuild
The rebuild was not a port. I didn't take the IBKR integration and swap out API calls. I started from the broker behavior and built down.
This is the interface every broker in the system must implement:
defmodule MyApp.Brokers.Broker do
@callback get_name() :: String.t()
@callback broker_key() :: String.t()
@callback data_source?() :: boolean()
@callback data_source() :: module()
@callback account_management?() :: boolean()
@callback account_manager() :: module()
@callback credential_module() :: module()
@callback user_has_credentials?(user :: User.t()) :: boolean()
@callback get_user_credentials(user :: User.t()) :: list(struct())
@callback list_accounts(token :: struct(), env :: :live | :paper) ::
{:ok, list(AccountBalance.t())} | {:error, term()}
@callback adapter() :: module()
end
That's the whole thing. Every broker is a module that implements these callbacks. The core trading system never calls TradeStation or Tradovate directly — it calls through the behavior. When I replaced IBKR, the core system didn't change at all. I deleted the IBKR modules and wrote new ones that satisfied the same contract.
Here's what the TradeStation implementation looks like:
defmodule MyApp.Brokers.TradeStation do
@behaviour MyApp.Brokers.Broker
alias MyApp.Connections.TradeStation.TSSession
alias MyApp.Connections.TradeStationToken
alias MyApp.Trading.AccountManagers.TradeStationAccountManager
alias MyApp.Trading.MarketDataSources.TradeStationDataSource
def get_name, do: "TradeStation"
def broker_key, do: "trade_station"
def data_source?, do: true
def data_source, do: TradeStationDataSource
def account_management?, do: true
def account_manager, do: TradeStationAccountManager
def credential_module, do: TradeStationToken
def adapter, do: MyApp.Connections.TradeStation.Adapter
def user_has_credentials?(user) do
user
|> Ash.load!(:trade_station_tokens, lazy?: true)
|> Map.get(:trade_station_tokens)
|> Enum.any?(fn token -> !is_nil(token.expires_at) end)
end
def list_accounts(token, env) do
subdomain = if env == :paper, do: "sim-api", else: "api"
TSSession.fetch_accounts_stateless(token, subdomain)
end
end
Compare that to Tradovate, the secondary broker:
defmodule MyApp.Brokers.Tradovate do
@behaviour MyApp.Brokers.Broker
def get_name, do: "Tradovate"
def broker_key, do: "tradovate"
def data_source?, do: false
def data_source, do: nil
def account_management?, do: true
def account_manager, do: MyApp.Trading.AccountManagers.TradovateAccountManager
def credential_module, do: MyApp.Connections.TradovateCreds
def adapter, do: MyApp.Connections.Tradovate.Adapter
# user_has_credentials?/1 and list_accounts/2 omitted —
# same shape as TradeStation, different token struct and endpoint
end
Notice data_source?: false on Tradovate. It can execute orders, but the system doesn't use it for market data. That distinction lives in the behavior, not in a conditional somewhere in the core. Adding a new broker means implementing the callbacks and dropping a module in. The system discovers it. Nothing else changes.
The adapter layer: symbol translation
Every broker has its own symbology. CME calls it ES. TradeStation calls it @ES (continuous contract) or ESH26 (specific expiry). Tradovate uses the CME codes natively. IBKR used its own internal contract IDs.
The system uses canonical exchange codes internally. Each broker implements a BrokerAdapter behavior that handles translation:
defmodule MyApp.Trading.BrokerAdapter do
@callback to_broker_symbol(root_symbol :: String.t(), symbol :: Symbol.t()) :: String.t()
@callback to_root_symbol(broker_symbol :: String.t()) :: String.t()
@callback supports_continuous_contract?(symbol_code :: String.t()) :: boolean()
@callback supports_symbol?(symbol :: Symbol.t()) :: boolean()
end
TradeStation's adapter is the most involved because their symbology diverges significantly from CME codes for certain products:
defmodule MyApp.Connections.TradeStation.Adapter do
@behaviour MyApp.Trading.BrokerAdapter
# Root symbol -> TradeStation broker code
# Only include mappings where the codes differ
@symbol_to_broker %{
"6E" => "EC", # Euro FX
"6J" => "JY", # Japanese Yen
"6B" => "BP", # British Pound
"ZC" => "C", # Corn
"ZS" => "S", # Soybeans
"ZB" => "US", # 30-Year Treasury Bond
"ZN" => "TY", # 10-Year Treasury Note
"LE" => "LC", # Live Cattle
"HE" => "LH", # Lean Hogs
# ... 30+ more mappings
}
@impl true
def to_broker_symbol(root_symbol, symbol) do
broker_code = Map.get(@symbol_to_broker, root_symbol, root_symbol)
if supports_continuous_contract?(broker_code) do
"@#{broker_code}"
else
build_futures_symbol(broker_code, symbol)
end
end
@impl true
def to_root_symbol(broker_symbol) do
broker_symbol = String.replace_prefix(broker_symbol, "@", "")
base_code = strip_maturity(broker_symbol)
Map.get(@broker_to_symbol, base_code, base_code)
end
end
Tradovate's is simpler:
defmodule MyApp.Connections.Tradovate.Adapter do
@behaviour MyApp.Trading.BrokerAdapter
# Tradovate uses canonical exchange codes natively.
# to_broker_symbol and to_root_symbol are identity functions.
# supports_continuous_contract? always returns true because
# Tradovate resolves root symbols to the active front-month
# contract server-side.
end
This adapter layer is where broker-specific quirks get contained. The core trading logic never sees a @ES or an ECH26. It works with ES and lets the adapter handle the rest. When a position comes back from TradeStation with a broker-specific symbol, the adapter translates it back to canonical before the rest of the system touches it.
TSSession: the session GenServer
TSSession is a GenServer with an unusual lifecycle: it shuts itself down after 60 seconds of inactivity, and restores its data feeds automatically on crash — no external coordination needed. A few things in this module are worth calling out.
Idle shutdown with a heartbeat. TSSession shuts itself down after 60 seconds of inactivity. The account manager pings it every 15 seconds to keep it alive. If the account manager crashes, the session cleans up after itself instead of leaking.
@seconds_unneeded_to_shutdown 60
def handle_info(:still_needed?, state) do
cutoff = DateTime.utc_now() |> DateTime.add(@seconds_unneeded_to_shutdown * -1)
if DateTime.compare(cutoff, state.last_needed) == :gt do
debug("No longer needed, goodbye!")
state = shutdown_all_feeds(state)
{:stop, :normal, Map.put(state, :data_feeds, %{})}
else
data_feeds = prune_stale_data_feeds(state.data_feeds, cutoff)
Process.send_after(self(), :still_needed?, 30_000)
{:noreply, Map.put(state, :data_feeds, data_feeds)}
end
end
Feed persistence and restoration. When a TSSession restarts (remember, these are :transient workers under a DynamicSupervisor), it restores its data feeds from a Cachex cache. Feeds aren't cleared on abnormal termination — only on normal shutdown. This means a crash-and-restart reconnects to all the data feeds the session was running, without any external coordination.
def terminate(reason, state) do
if reason != :normal, do: error("TSSession terminated: #{inspect(reason)}")
# Don't clear persisted feeds on abnormal termination
# This allows them to be restored when the session restarts
if reason == :normal do
clear_session_feeds(get_key(state))
end
# ... shutdown feeds ...
end
Token refresh is somebody else's problem. TSSession doesn't manage tokens. It delegates to a dedicated TokenStore GenServer that handles OAuth2 refresh automatically. Every API call pulls a fresh token from the store. If the token is expired, the store refreshes it before returning. The session never thinks about auth state.
def handle_call({:get_contract_details, symbol}, _from, state) do
token = TokenStore.get_token(state.token)
state = Map.put(state, :token, token)
case do_get_contract_details(symbol, state) do
{:ok, details} -> {:reply, {:ok, details}, state}
error -> {:reply, error, state}
end
end
Compare this to IBKR, where establishing a session required a Python port and a custom encryption handshake just to authenticate. The difference in operational complexity is not incremental — it's categorical.
Order execution
Orders come in as internal structs, get translated to TradeStation's format, and go out over REST:
def to_trade_station_order(%Order{} = order, account_manager_state) do
Trading.update_order!(order, %{sent_at: DateTime.utc_now(:millisecond)})
%{
AccountID: account_manager_state.selected_account.account_id,
Symbol: account_manager_state.contract_details.symbol,
OrderType: type(order.type),
Quantity: Integer.to_string(order.quantity),
LimitPrice: if(order.type == :limit, do: order.submitted_price, else: nil),
StopPrice: if(order.type == :stop, do: order.submitted_price, else: nil),
TimeInForce: tif(order),
TradeAction: side(order.side),
OrderConfirmID: order.id,
AdvancedOptions: advanced_options(order)
}
|> Enum.reduce(%{}, fn {key, value}, acc ->
if is_nil(value), do: acc, else: Map.put(acc, key, value)
end)
|> Enum.into(%{})
end
Bracket orders (entry + stop loss + optional profit target) get composed from individual orders with TradeStation's OSO (Order Sends Order) structure:
def to_trade_station_order(%BracketOrder{} = bracket, account_manager_state) do
primary_order = to_trade_station_order(bracket.primary_order, account_manager_state)
stop_loss_order = to_trade_station_order(bracket.stop_loss_order, account_manager_state)
oso_orders =
if bracket.profit_target_order do
profit_target_order =
to_trade_station_order(bracket.profit_target_order, account_manager_state)
[profit_target_order, stop_loss_order]
else
[stop_loss_order]
end
primary_order
|> Map.put(:OSOs, [%{Type: "BRK", Orders: oso_orders}])
end
This is a real bracket order going to production. The stop loss is part of the order structure itself — not a separate order placed after the entry fills, which is how I had to do it with IBKR. If the entry fills, the stop is guaranteed to be working. No race condition. No silent failure window.
The account manager
The account manager is the bridge between the core trading logic and the broker session. Like the broker behavior, there's an AccountManager behavior that every broker must implement:
defmodule MyApp.Trading.AccountManager do
@callback new(trader_id :: String.t(), args :: any()) :: {:ok, pid()}
@callback get_platform() :: atom()
@callback get_accounts(identifier :: any()) ::
{:ok, list(AccountBalance.t())} | {:error, term()}
@callback submit_order(identifier :: any(), order :: Order.t() | BracketOrder.t()) ::
{:ok, term()} | {:error, term()}
@callback modify_orders(identifier :: any(), orders :: list(Order.t())) ::
{:ok, term()} | {:error, term()}
@callback cancel_orders(identifier :: any(), orders :: list(Order.t())) ::
{:ok, term()} | {:error, term()}
@callback start_account_event_stream(identifier :: any()) ::
{:ok, map()} | {:error, term()}
@callback shutdown(identifier :: any()) :: :ok
# ...
end
The TradeStation account manager is a GenServer (:transient, like everything else in the trading pipeline) that starts a TSSession in its handle_continue, subscribes to account events, and proxies order operations:
def handle_continue(%{config: config} = args, _state) do
broker_account = config.broker_account
subdomain =
case broker_account.account_type do
:paper -> "sim-api"
:live -> "api"
end
session_config = %{token: broker_account.token, subdomain: subdomain}
case DynamicSupervisor.start_child(MyApp.Servers, {TSSession, session_config}) do
{:ok, _} ->
session_started(args, session_config, subdomain)
{:error, {:already_started, _pid}} ->
session_started(args, session_config, subdomain)
error ->
error("Failed to start TSSession: #{inspect(error)}")
{:stop, :error}
end
end
Notice the {:error, {:already_started, _pid}} match. If the session already exists — maybe from another account manager using the same credentials — we just attach to it. Sessions are shared resources, registered in a global Registry. This is the kind of thing that falls out naturally from OTP's process model. With IBKR, this was a source of bugs.
What actually changed
The IBKR session module actually pioneered the still_needed? pattern — it was the first broker I integrated, and it showed me everything I was doing wrong. When a session leaked, it left orphaned child processes in the supervision tree with no owner. IBKR compounds this: they only allow a single active session per account. When a new session started, the broker would invalidate the old one, the old session's WebSocket would die, and I'd get a crash from a process I'd already forgotten about. Memorable debugging sessions.
Here's what the migration looked like in concrete terms. 2,740 lines across 25 files, gone:
- Deleted: The entire IBKR connection module, the Python port, the encryption bridge.
- Added:
MyApp.Connections.TradeStation(TSSession, TokenStore, Adapter, Orders, data feeds). - Added:
MyApp.Brokers.TradeStation— the behavior implementation. - Added:
MyApp.Trading.AccountManagers.TradeStationAccountManager. - Unchanged: The core trading logic. Signal generation. Position management. The LiveView dashboard. The supervision tree. All of it.
The fact that the core system didn't change is the point. The broker behavior and adapter layer aren't over-engineering — they're what made a broker migration possible without rewriting the trading logic. When you're running real capital, that separation isn't optional.
What's better now
Two operational wins that don't show up in code:
- Paper and live share the same code path. The only difference is the subdomain:
sim-apivsapi. IBKR required entirely separate credentials for paper trading — a different account, a different auth setup, effectively a second integration to maintain. - No error suppression list. IBKR required filtering 40+ broker-specific error codes to avoid false positives. TradeStation needs none — errors are honest.
The multi-broker architecture
Adding a new broker means implementing two behaviors (Broker and BrokerAdapter) and one GenServer (AccountManager). The core system discovers and uses it without any changes. Currently: TradeStation primary, Tradovate secondary, ThinkOrSwim data-only.
This matters because I've now been through a broker migration. I know what it costs. The architecture exists specifically so the next one — if it happens — is measured in days, not weeks.
I'm never locked into a single broker again.
What's next
An upcoming post is about the gap between the architecture I have and the architecture I want. The broker behavior and adapter layer work. But the supervision model underneath them doesn't match the design I actually want — each broker session should live under its own supervisor with one_for_all semantics. Right now they all share a flat DynamicSupervisor. The gap is intentional.
The IBKR Saga
- · Building a Python Port in Elixir to Crack IBKR's Encryption
- · When the Broker Lies: Debugging Stop Orders That Silently Fail
- → Why I Fired My Broker (and What I Replaced Them With)