Skip to main content

Tutorial 5: Add a broker

This tutorial walks through adding a new broker to mts1b-brokers — either an existing broker you have credentials for, or a new broker entirely (via PR to the OSS repo).

Two paths

PathWhen to use
Configure an existing adapterThe broker is already in mts1b-brokers (IBKR, Coinbase, Schwab, Moomoo, Tradier, Kraken, paper)
Write a new adapterYou're adding a broker not yet in the repo (and ideally PR'ing it back)

Path A — Configure an existing adapter

Example: connect to Coinbase Advanced Trade.

Step 1 — Get API credentials

Coinbase Advanced Trade → Settings → API → Create New API Key.

Permissions needed:

  • view (positions, orders, balances)
  • trade (place/cancel orders)

NOT needed:

  • transfer (we never move funds across accounts; transfers go through mts1b-treasury explicitly)

Step 2 — Store credentials in Vault

vault kv put secret/mts1b/brokers/coinbase \
api_key="<your_api_key>" \
api_secret="<your_api_secret>" \
passphrase="<your_passphrase>" \
trading_mode="live" # or "paper" — but Coinbase has no paper sandbox

Step 3 — Add to the deploy config

mts1b-deploy menuconfig
# In "Brokers" section, enable: coinbase
# Save
mts1b-deploy install --config mts1b.config

Step 4 — Verify

mts mts1b-brokers test --broker coinbase

Expected:

✓ coinbase: API reachable
✓ coinbase: auth OK
✓ coinbase: account info OK BTC=0.012, USD=$1,234.56
✓ coinbase: order book OK BTC-USD bid=$95,100, ask=$95,120
✓ coinbase: time skew OK drift=42ms
✓ coinbase: WebSocket stream OK
ALL CHECKS PASSED

Step 5 — Create a fund

mts mts1b-treasury fund create \
--fund-id crypto-mean-revert \
--nav 10000 \
--base-currency USD \
--broker coinbase

You're done. The strategy code is identical to what runs on paper — only the fund's broker field changed.

Path B — Write a new adapter

You want to add support for, say, Robinhood Crypto. Here's the path.

Step 1 — Implement the BrokerProtocol

mts1b_brokers/robinhood/client.py
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import AsyncIterator

import httpx

from mts1b_foundation.orders import Order, Fill, Position, Side
from mts1b_foundation.protocols import BrokerProtocol
from mts1b_platform.http import http_client
from mts1b_platform.retry import with_retry
from mts1b_platform.logging import get_logger

log = get_logger(__name__)


@dataclass
class RobinhoodCryptoClient(BrokerProtocol):
"""Robinhood Crypto via Robinhood Crypto API."""

name: str = "robinhood-crypto"
api_key: str = ""
private_key_pem: str = ""
base_url: str = "https://trading.robinhood.com"

def __post_init__(self):
self._client: httpx.AsyncClient | None = None

async def __aenter__(self):
self._client = http_client(
"robinhood",
base_url=self.base_url,
timeout=20.0,
retries=3,
)
return self

async def __aexit__(self, *_):
if self._client:
await self._client.aclose()

# --- BrokerProtocol implementation ---

@with_retry(retries=3, backoff=1.5)
async def submit(self, order: Order) -> Order:
"""Submit an order. Returns the order with broker-assigned ID."""
payload = self._render_order(order)
log.info("submitting order", extra={"order_id": order.order_id, "symbol": order.symbol})
resp = await self._client.post("/api/v1/crypto/trading/orders/", json=payload)
resp.raise_for_status()
data = resp.json()
return order.model_copy(update={
"broker_order_id": data["id"],
"submitted_at": datetime.utcnow(),
})

@with_retry(retries=3, backoff=1.5)
async def cancel(self, order_id: str) -> bool:
resp = await self._client.post(f"/api/v1/crypto/trading/orders/{order_id}/cancel/")
return resp.status_code in (200, 204)

async def get_open_orders(self) -> list[Order]:
resp = await self._client.get("/api/v1/crypto/trading/orders/?state=open")
return [self._parse_order(o) for o in resp.json()["results"]]

async def get_positions(self) -> list[Position]:
resp = await self._client.get("/api/v1/crypto/trading/holdings/")
return [self._parse_position(p) for p in resp.json()["results"]]

async def stream_fills(self) -> AsyncIterator[Fill]:
"""Poll fills endpoint at 5-second cadence (Robinhood has no WS for crypto)."""
seen: set[str] = set()
while True:
resp = await self._client.get("/api/v1/crypto/trading/executions/")
for ex in resp.json().get("results", []):
if ex["id"] in seen:
continue
seen.add(ex["id"])
yield self._parse_fill(ex)
await asyncio.sleep(5)

# --- Internal helpers ---

def _render_order(self, order: Order) -> dict:
return {
"symbol": order.symbol.replace("-", ""), # BTC-USD → BTCUSD
"side": order.side.value,
"quantity": str(order.quantity),
"type": order.order_type.value,
"limit_price": str(order.limit_price) if order.limit_price else None,
"time_in_force": order.tif.value,
"client_order_id": order.idempotency_key,
}

def _parse_order(self, data: dict) -> Order:
... # construct Order from Robinhood response

def _parse_position(self, data: dict) -> Position:
...

def _parse_fill(self, data: dict) -> Fill:
...

Step 2 — Register in the broker registry

mts1b_brokers/__init__.py
from .robinhood.client import RobinhoodCryptoClient
from .ibkr.client import IbkrClient
from .coinbase.client import CoinbaseClient
# ... existing

BROKER_REGISTRY = {
"robinhood-crypto": RobinhoodCryptoClient, # NEW
"ibkr": IbkrClient,
"coinbase": CoinbaseClient,
# ...
}

Step 3 — Add tests

tests/test_robinhood.py
import pytest
from mts1b_brokers.robinhood import RobinhoodCryptoClient
from mts1b_foundation.protocols import BrokerProtocol


def test_implements_broker_protocol():
client = RobinhoodCryptoClient(api_key="x", private_key_pem="x")
assert isinstance(client, BrokerProtocol)


@pytest.mark.live
async def test_get_positions_live(robinhood_creds):
"""Requires real Robinhood creds. Skipped in default CI."""
async with RobinhoodCryptoClient(**robinhood_creds) as client:
positions = await client.get_positions()
assert isinstance(positions, list)

CI runs test_implements_broker_protocol always; live tests only when credentials are present.

Step 4 — Document

Each broker gets a page in mts1b-docs:

docs/repos/brokers/robinhood-crypto.md
# Robinhood Crypto

Adapter for Robinhood Crypto API.

## Capabilities

| Feature | Supported | Notes |
|---|:-:|---|
| Spot trading | ✅ | BTC, ETH, DOGE, ... (full list in their docs) |
| Limit / market orders | ✅ | |
| Stop orders | ✅ | Server-side stop loss |
| Margin | ❌ | Robinhood Crypto is spot-only |
| Shorting | ❌ | |
| WebSocket fills | ❌ | Polling at 5s cadence |
| Paper sandbox | ❌ | No sandbox; use mts1b-brokers/paper instead |

## Auth

API key + Ed25519 signature. See [Robinhood docs](https://...) for key generation.

## Quirks

- Symbol format: `BTCUSD` (no separator), not `BTC-USD`. Adapter normalizes.
- Fees: 0% for primary subscribers, otherwise spread-based.
- Rate limits: 100 req/min per endpoint.

Step 5 — PR upstream

Open a PR against MTS1B/mts1b-brokers. The maintainer CI will run:

  • pytest (unit tests + protocol conformance)
  • mypy strict
  • ruff
  • AST dedupe check (your code can't shadow protected symbols)
  • License audit
  • AI review via mts1b-githubbot

If green, a maintainer reviews and merges.

What a "complete" broker adapter does

CapabilityWhy
✅ Implement BrokerProtocolThe interface contract
pass isinstance(x, BrokerProtocol)runtime + mypy verify the contract
✅ Idempotent submit (dedup on idempotency_key)OMS retries are safe
✅ Symbol normalizationCaller passes BTC-USD, adapter handles native format
✅ Fee normalizationAdapter emits fees in account base currency
✅ Retries on transient HTTP errorsUse mts1b-platform/retry
✅ Rate-limit awareUse mts1b-platform/ratelimit
✅ Log redactionNever log raw API responses; use mts1b-platform/security/redact
✅ Live tests gated behind pytest.mark.liveCI is hermetic
✅ Capabilities matrix in docsWhat works, what doesn't

See also