DEX aggregation
One trading screen, many exchanges, joined by a single port that the UI never sees behind.
The app trades on more than one perpetual-futures exchange, but the trading screen is written as if there were only one. That is the whole trick of the aggregation layer: every exchange plugs in behind a single port, and the rest of the app talks to the port, never to a specific exchange.
What it means for you
You pick a venue once. The order ticket, the charts, the orderbook, the positions table, all of it works the same way regardless of which venue is live. When you switch venues, the app cleanly tears down the old connection and brings up the new one, so you never see stale data leak from one exchange into another.
Today there are two venues. One is a synthetic mock-venue used for development and demos, which behaves like a real exchange without touching real money. The other is Hyperliquid, the first live integration. Adding the next real exchange does not mean rewriting the trading screen. It means writing one new module that implements the same port.
The idea: one port, many venues
The architecture is hexagonal, which is a long word for a simple rule. There is one interface, the Adapter, that defines everything the app needs from an exchange. It lives in apps/client/src/modules/shared/domain/. Every venue is its own module that implements that interface and exports a factory function:
// each venue exports a factory that returns the shared port
createMockVenue(): Adapter
createHyperliquidVenue(config): AdapterThe trading module depends only on the Adapter. It is not merely a convention that trading/ avoids importing a venue. It is impossible: a lint rule forbids trading/ from importing mock-venue/, hyperliquid/, or any future venue module, and the build fails if it tries. The only place that knows which venue is real is the composition root, app/, which picks the venue, builds it, and hands the resulting Adapter to the trading screen through context.
Why the boundary is enforced, not trusted
A boundary that lives only in a style guide erodes. Within a quarter, some feature reaches past it for a quick win and the abstraction quietly stops being true. Here the boundary is a lint rule, so the day someone imports a venue into the trading module, the build stops them.
Venues are built from capabilities
Real exchanges do not all offer the same features. One might support TWAP orders, another might not. One might report a vault balance, another might not. Forcing every venue to implement one giant interface, faking the parts it does not support, would be dishonest and brittle.
Instead, a venue is composed from capability slices. The Adapter is a record of optional capabilities, and a venue provides only the ones it actually supports. The trading screen checks for a capability before using it, and renders an honest "not supported here" state when it is absent.
The capability slices include:
connection, the live connection status, which every venue must provideportfolio, the headline account value, PnL, and volumebalances, the per-asset balancespositionsandperpsPositionsSnapshot, open positionsopenOrders, working orderstrader, placing, modifying, and cancelling ordersfeeSchedule, the fee tiermarketDataandcandles, prices and chart historyleverageControllerandmarginModeController, account settings
A consumer that wants to know "how much can this account trade" reads through the portfolio capability rather than poking at a raw exchange field. That indirection is not ceremony. It is what lets a single piece of UI render correctly across exchanges whose internal data shapes differ.
A real example of why the port matters
Hyperliquid has account abstraction modes. A classic account keeps its perp margin where you would expect it. A unified or portfolio-margin account keeps all balances in the spot side, and the raw perp-margin fields report close to zero even when the account is fully funded.
A naive integration reads the raw perp-margin field, sees roughly zero, and renders a funded account as empty. That is a real bug class. The aggregation layer kills it: the Hyperliquid venue detects the account mode once and projects a mode-correct account value through the portfolio capability. Everything outside the venue (the order ticket, the trade gate, the portfolio page) consumes the projected value and never sees Hyperliquid's mode vocabulary at all. The exchange's quirk stays sealed inside the exchange's module.
Switching venues cleanly
Switching venues is a hard remount. When you change venue, the app disposes the old Adapter (closing its streams) and mounts a fresh one. Every component that was subscribed re-subscribes to the new instance. There is no shared mutable state to scrub between venues, because the whole subtree is rebuilt. That is the simplest correct way to guarantee no data from one exchange bleeds into another.
Under the hood
- The port and domain types live in
apps/client/src/modules/shared/domain/. This is the single source of truth for what an exchange must look like to the rest of the app. - Venue modules (
apps/client/src/modules/mock-venue/,apps/client/src/modules/hyperliquid/) implement the port and export theircreate...Venue()factory. The Hyperliquid module isolates the exchange SDK to a handful of gateway files; the SDK is not allowed to be imported anywhere else, again enforced by lint. - Live streams go through one shared reconnect helper (
shared/services/with-reconnect). Raw WebSocket use is banned in client code. Each stream exposes the same five-state connection status, so connection indicators across the app read from one source rather than inventing their own flags. - The composition root (
app/) is the only module allowed to import a venue. It owns venue selection and the remount.
Design records
Runtime venue switching by hard remount (ADR-0001), where the Adapter provider lives (ADR-0002), capability-composed venues (ADR-0008), the Hyperliquid gateway and reconnect approach (ADR-0009, ADR-0010, ADR-0020), and the account abstraction mode handling (ADR-0033).