Part of Broker Architecture
The previous post evaluated the four brokers in this system. This post is about what makes them coexist — the behaviors that keep broker quirks out of the core logic, the dispatch mechanism that's stood up to a broker migration, and the parts of the design I'd do differently next time.
The problem with broker APIs
Every broker thinks they're the only broker you'll ever use. Their APIs reflect this: proprietary symbol formats, bespoke authentication flows, different conventions for the same concepts. The same futures contract — E-mini S&P 500 — is @ES on TradeStation, ES on Tradovate, and /ES on ThinkOrSwim. The Euro FX contract is worse: @EC on TradeStation (completely different code), 6E on Tradovate, /6E on ThinkOrSwim.
If you let these differences leak into your core logic, you don't have a trading system — you have a TradeStation system that happens to also talk to Tradovate. Every strategy, every position check, every order submission becomes coupled to whichever broker you integrated first. Switching brokers means rewriting everything that touches a symbol, an order, or a position.
I learned this the hard way. The broker behavior I introduced in an earlier post was the fix, but I only showed the surface. The actual architecture has four behaviors — including parts I'd revisit.
Four behaviors, four responsibilities
The abstraction isn't one big interface. It's four, each with a specific job:
Broker— the registry. What can this broker do? Returns module references for the other three layers.BrokerAdapter— the translator. How does this broker name things? Handles symbol conversion.AccountManager— the executor. How does this broker execute orders? Full order lifecycle as a GenServer.MarketDataSource— the feed. How does this broker deliver bars, quotes, and contract details? Covers historical candle retrieval, live subscriptions, and feed lifecycle.
An earlier post showed the Broker and BrokerAdapter behaviors. Here I'm focused on how the four compose — the dispatch chain that lets the trading pipeline stay broker-agnostic, and the places where that composition is messier than I'd like.
The dispatch chain
The broker module is stored as an atom in the database — not a string, not an enum, the actual module name. When the system needs to interact with a broker, it calls behavior callbacks directly on that stored module:
defp build_children(%TradingSession{} = session) do
broker_account = session.broker_account
# broker_account.broker IS the module — dispatch is a function call
am_module = broker_account.broker.account_manager()
am_config =
struct(Module.concat(am_module, :Config),
broker_account: broker_account,
symbol: session.trading_system.symbol
)
am_spec = {am_module, %{trader_id: session.id, config: am_config}}
trader_spec = {MyApp.Trading.RealtimeTrader, session}
case build_ds_spec(session) do
nil -> [am_spec, trader_spec]
ds_spec -> [am_spec, ds_spec, trader_spec]
end
end
broker_account.broker might be MyApp.Brokers.TradeStation or MyApp.Brokers.Tradovate — the session supervisor doesn't care. It calls .account_manager() on whatever module is stored there and builds a child spec from what comes back.
The data source follows the same pattern:
defp build_ds_spec(%TradingSession{data_source_broker: nil}), do: nil
defp build_ds_spec(%TradingSession{} = session) do
broker = session.data_source_broker
if broker.data_source?() do
ds_module = broker.data_source()
# ... build spec using ds_module ...
end
end
No case statements. No pattern matching on broker atoms. The broker module is the dispatch mechanism itself. This works because Elixir modules are atoms, and behaviors enforce the callback contract at compile time.
The tradeoff is that the set of valid brokers is fixed at compile time — you can't add a broker without deploying new code. That's fine for this system. Broker integrations are weeks of work. Runtime extensibility isn't on the wish list.
Broker-agnostic dispatch in the trading pipeline
By the time a strategy produces a decision, the trading pipeline has no idea which broker will execute it. The RealtimeTrader resolves the account manager module once during startup and stores the reference. From that point, every order operation goes through the stored module:
# In the decision handler — strategy says "submit this order"
state.account_manager.module.submit_order(state.session.id, bracket_order)
# Strategy says "modify existing orders"
state.account_manager.module.modify_orders(state.session.id, orders)
# Strategy says "cancel everything"
state.account_manager.module.cancel_orders(state.session.id, orders)
The decision handler has no idea whether it's talking to TradeStation or Tradovate. It receives a decision tuple from the strategy, persists the order to the database, and dispatches to whatever account manager module was configured for this session. The order format is the same. The response format is the same. The account manager handles the translation to broker-specific API calls internally.
Symbol translation works the same way. When a position comes back from the broker with a broker-specific symbol, the adapter translates it before the rest of the system sees it:
adapter = state.session.broker_account.broker.adapter()
root_symbol = adapter.to_root_symbol(position_symbol)
The core logic works exclusively with canonical exchange codes. ES, not @ES. 6E, not EC.
The MarketDataSource behavior is on the same pattern. The trader asks for historical bars, subscribes to a live feed, and updates its subscription when the contract rolls. Whether bars arrive over SSE from TradeStation or WebSocket from ThinkOrSwim is invisible:
ds = state.market_data_source.module
{:ok, candles} = ds.get_historical_candles(state.session.id, symbol, start_time, end_time, interval)
:ok = ds.update_subscription(state.session.id, new_symbol)
:ok = ds.shutdown(state.session.id)
The strategy works with candle structs. It doesn't care what wire format delivered them. The evaluation post covered the streaming protocols — TradeStation over SSE, Tradovate over a custom WebSocket text protocol, ThinkOrSwim over WebSocket with numbered-field JSON. Three wire formats, three framing conventions. The behaviors define what data comes out, not how it gets there.
Partial implementations
Not every broker does everything. ThinkOrSwim provides market data but can't execute futures orders. The behavior handles this explicitly:
defmodule MyApp.Brokers.ThinkOrSwim do
@behaviour MyApp.Brokers.Broker
def get_name, do: "ThinkOrSwim"
def data_source?, do: true
def data_source, do: MyApp.Trading.MarketDataSources.TOSDataSource
def account_management?, do: false
def account_manager, do: nil
def list_accounts(_token, _env), do: {:ok, []}
# ...
end
The account_manager/0 callback returns nil. The list_accounts/2 callback returns an empty list. This isn't a workaround — it's the design. The UI uses these flags to determine what to show:
def compute_supported_broker_keys(symbol) do
MyApp.Brokers.list()
|> Enum.filter(fn broker -> broker.adapter().supports_symbol?(symbol) end)
|> MapSet.new(& &1.broker_key())
end
def get_market_data_sources(user) do
MyApp.Brokers.list()
|> Enum.filter(fn broker_module ->
broker_module.data_source?() and broker_module.user_has_credentials?(user)
end)
end
MyApp.Brokers.list/0 returns the configured list of broker modules from application config. The UI iterates them polymorphically — filtering by capability and credentials — without knowing which brokers exist or what they can do. Adding a new broker to the config list is all it takes for it to appear in the UI.
Auth isolation
The three active brokers use completely different auth models — and the differences run deeper than token format:
TradeStation and ThinkOrSwim use OAuth2 — standard token refresh, handled by a dedicated TokenStore GenServer that polls every 10 seconds and refreshes automatically when the token is about to expire.
Tradovate uses username/password authentication. No OAuth dance — a direct POST with username, password, device ID, and API credentials. The response format and error cases are both different from the OAuth brokers, and Tradovate has two separate error messages for the same 2FA-enabled condition. It also has a rate-limiting mechanism (p-ticket responses) that requires a mandatory wait if you request tokens too aggressively — something you can't cleanly automate.
And here's a detail that matters in production: when Tradovate's token store refreshes a token, it needs to notify the live WebSocket session to re-authenticate. This is the inverse of the TradeStation model, where the session pulls from the token store. The Tradovate token store pushes:
defp notify_session_token_refreshed(%{creds: creds, subdomain: subdomain}, new_access_token) do
session_key = "TradovateSession:#{creds.username}:#{subdomain}"
case Registry.lookup(MyApp.Registry, session_key) do
[{pid, _}] -> GenServer.cast(pid, {:token_refreshed, new_access_token})
[] -> :ok
end
end
None of this complexity is visible from the trading pipeline. The account manager calls submit_order/2, and authentication is somebody else's problem — whether that means refreshing an OAuth token, posting credentials to a REST endpoint, notifying a WebSocket session to re-auth, or — in a previous life — bridging to a Python port for a non-standard key exchange. The boundary is clean.
What I'd change
This is the version running in production. It's stable, it's survived a broker migration, and it keeps broker quirks out of the core logic. It's also not finished. One gap has been closed recently; two remain.
1. Per-session supervisors (resolved)
The supervision tree post described a gap: all sessions shared a flat DynamicSupervisor, with no structural relationship between the processes that belonged together. If the account manager crashed, the data source and trader kept running with stale references until their own failures cascaded.
This is now fixed. Each trading session runs under its own SessionSupervisor with :one_for_all semantics. If any one of the three crashes, all three restart together as a unit. The build_children code shown earlier in this post is the actual production dispatch — it returns child specs to the per-session supervisor, not to the flat DynamicSupervisor directly.
2. A single Broker facade instead of four disparate surfaces
Look at how callers navigate the abstraction today:
am_module = broker_account.broker.account_manager()
ds_module = broker_account.broker.data_source()
adapter = broker_account.broker.adapter()
am_module.submit_order(session_id, order)
adapter.to_root_symbol(position_symbol)
ds_module.get_historical_candles(session_id, symbol, start_time, end_time, interval)
The caller has to know the internal structure — which module answers which question. That's a leaky abstraction. The four-behavior split is a clean decomposition internally, but I've let it show through the boundary.
What I'd prefer is a single Broker facade: one interface the trading pipeline talks to, with the internal split hidden behind it. The caller asks Broker.submit_order(broker_account, order) or Broker.to_root_symbol(broker_account, symbol) and the facade delegates to the right underlying module. Four behaviors still exist inside — they're a good decomposition of the work — but the surface area callers see collapses to one.
3. Protocols instead of module-atom + config-struct dispatch
The current dispatch pairs a module name with a config struct (%TradeStationAccountManager.Config{}, etc.) and wires them together at startup. It works, but it's not idiomatic Elixir. Protocols would be more natural: pass a %TradeStation{} struct where the data and the polymorphism travel together, and the protocol dispatches based on the struct's type. No module lookup, no config-struct pairing, no Module.concat call to find the right Config struct.
Protocols would also compose better with the facade — the facade functions become protocol callbacks, and each broker's struct is the implementation. The module-atom pattern has served me well, but it predates Elixir-native patterns I've since come to trust more.
The next post goes deeper: the GenServer state design sitting behind these behaviors — what lives in memory, what gets persisted, and how the system reconciles with the broker after a restart.
The facade and protocol changes aren't in production yet — refactoring the abstraction that every trading session runs through, while managing real capital, requires careful sequencing. The abstraction got me through a broker migration in weeks. The next one will be faster — not because the code is perfect, but because I know where it's wrong.
Discussion
Create a free account to join the conversation.