All posts
7 min read

A multi-broker abstraction the engine never sees

How KAI Terminal connects to multiple brokers without letting a single broker name leak into the risk engine — capability-based interfaces, a dependency sink, and registries keyed by broker type.

dotnetarchitectureabstractionbrokers

KAI Terminal trades through more than one broker. A user can connect, say, two different accounts, see a blended P&L across both, and have the automated risk engine exit or roll positions on either. The hard part isn't talking to two APIs — it's making sure the interesting code never has to know which broker it's talking to.

The test I held myself to: you should be able to grep the entire risk engine and never find a broker's name. Not "Upstox", not "Zerodha", nothing. The engine reasons about positions, marks, and rules; which venue a position lives on is somebody else's problem.

Capabilities, not brokers

The usual instinct is one big IBroker interface with twenty methods. That ages badly: every broker has to implement all of it, half the methods are awkward no-ops, and the interface becomes a junk drawer.

Instead the brokerage surface is split by capability. Each thing a broker can do is its own small interface:

// One narrow capability per interface — token-aware, broker-agnostic.
public interface IBrokerOrderProvider     { /* place + exit a position */ }
public interface IBrokerPositionsProvider { /* read the live book */ }
public interface IBrokerFundsProvider     { /* available funds */ }
public interface IBrokerMarginProvider    { /* pre-trade margin/charges */ }
public interface IBrokerAuthenticator     { /* OAuth URL + token exchange */ }
// ...and so on, one per capability.

The codebase is even organised this way physically — folder equals capability equals namespace (Orders/, Positions/, Funds/, Margins/, Authentication/, …). Each broker contributes one implementation per capability and nothing more. Adding a capability doesn't force a change on brokers that don't have it.

The dependency sink

Here's the move that makes the "engine never sees a broker" rule actually hold.

All of those interfaces — every broker abstraction — live in the core project, the one with no dependencies. The core is the dependency sink: domain records and broker interfaces both live there, and everything points inward toward it.

   Core  ◀── Engine          (engine depends only on interfaces in Core)
    ▲  ▲
    │  └──── Brokers          (concrete Upstox/Zerodha impls live here)
    └─────── everything else

The concrete implementations live in a separate Brokers project that depends on core. The engine also depends on core — but not on Brokers. It physically cannot reference a concrete broker, because the dependency arrow doesn't exist. The compiler enforces the architecture. You can't accidentally new up an Upstox client in the engine; it isn't on the reference graph.

This is the difference between an abstraction that's a convention ("please don't import brokers in the engine") and one that's a constraint (it won't compile). Only the second kind survives contact with a deadline.

Resolving the right implementation

If the engine only holds interfaces, how does the right broker get picked at run time? A registry per capability, keyed by a BrokerType enum:

public interface IBrokerOrderRegistry
{
    IBrokerOrderProvider For(BrokerType broker);
}

Each implementation is registered in DI and indexed by its .Broker property. When a position needs exiting, the caller asks the registry for the provider that matches that position's own broker and calls it. The exit places a reverse market order on the position's own venue — the registry guarantees you're always talking to the right one.

Adding a broker is a checklist, not a refactor

Because the seams are all interfaces, onboarding a new broker is mechanical:

  1. Add the BrokerType enum value.
  2. Implement the capability interfaces you want to support — orders, positions, funds, margins, authentication, an instrument source.
  3. Register them.

No engine change. No endpoint change. The new venue lights up across blended P&L and the risk engine automatically, because everything upstream only ever talked to the interface. (Market data is the one thing that isn't per-broker — it's app-scoped through a single analytics feed — so it's not on the list.)

Pure parsers hiding inside the shell

One detail worth calling out, because it ties back to how the rest of the system is built: each provider is really two things wearing one coat. There's the I/O — a single HTTP call — and there's a pure parser that turns the broker's JSON or CSV into our domain records. They're kept separate on purpose.

// Shell: the one I/O call.
var raw = await http.GetPositionsAsync(token);
// Core: a pure function, testable on a captured payload — no network.
return PositionsParser.Parse(raw);

That means broker quirks — every API shapes its payloads differently — are absorbed by a function you can test against a saved response, with no mock and no live connection. The messy reality of each vendor stays quarantined at the edge.

The same idea, one level up

The capability split also let us do something stricter for safety: a decorator sits in front of the positions capability and enforces an index-options-only policy at the data boundary, classifying instruments against the instrument master rather than by parsing symbols. The engine downstream just receives a clean, already-filtered book. Another concern handled at the seam, invisible to the core.

The throughline across all of it: put the policy and the decisions in the core, keep the vendor-specific mess at the edge, and let the dependency graph make the separation a law rather than a hope.

SR

Suvrajit Ray

Founder & Engineer — KAI Terminal

Open to opportunities

I build low-latency trading systems end to end — a .NET real-time risk engine and a React/Next.js cockpit for Indian index-options sellers.