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
| Path | When to use |
|---|---|
| Configure an existing adapter | The broker is already in mts1b-brokers (IBKR, Coinbase, Schwab, Moomoo, Tradier, Kraken, paper) |
| Write a new adapter | You'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 throughmts1b-treasuryexplicitly)
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
| Capability | Why |
|---|---|
✅ Implement BrokerProtocol | The interface contract |
✅ pass isinstance(x, BrokerProtocol) | runtime + mypy verify the contract |
✅ Idempotent submit (dedup on idempotency_key) | OMS retries are safe |
| ✅ Symbol normalization | Caller passes BTC-USD, adapter handles native format |
| ✅ Fee normalization | Adapter emits fees in account base currency |
| ✅ Retries on transient HTTP errors | Use mts1b-platform/retry |
| ✅ Rate-limit aware | Use mts1b-platform/ratelimit |
| ✅ Log redaction | Never log raw API responses; use mts1b-platform/security/redact |
✅ Live tests gated behind pytest.mark.live | CI is hermetic |
| ✅ Capabilities matrix in docs | What works, what doesn't |
See also
mts1b-brokersrepo spec — full list of supported brokers- Foundation types — Order, Fill, Position, BrokerProtocol
mts1b-platform— http, retry, ratelimit, redaction primitives