Building a trading bot
Build a minimal Python bot: create an account, issue an API key, inspect a market and its order book, place a limit order with the nonce protocol, then cancel it. Two strategy shapes follow: the observe-only template that ships in the repo, and a market-making sketch you can drop into the same loop.
Clone and run the full SDK from loaf-python-api-bot-template .
Environment: Examples use https://api.loafmarkets.com/api as the REST base and placeholder asset opera (tokenName). Do not run this tutorial against production with real funds until you have tested and reviewed compliance requirements.
What you will build
| Step | Goal |
|---|---|
| 1 | Sign up on beta.loafmarkets.com |
| 2 | Create an API key in the API menu UI |
| 3 | Load market metadata and subscribe to the order book over WebSocket |
| 4 | Submit a limit order (nonce → submit) |
| 5 | Cancel that order |
Prerequisites
- Python 3.9+
- A modern browser
- Basic familiarity with REST and environment variables
git clone https://github.com/Loaf-Markets/loaf-python-api-bot-template.git
cd loaf-python-api-bot-template
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dotenv]"Configuration
Copy .env.example to .env and set:
export LOAF_API_KEY="your-api-key" # shown once when you create the key (step 2)
export LOAF_API_BASE_URL="https://api.loafmarkets.com/api"
export LOAF_TARGET_TOKEN="opera"
# Optional — private portfolio WebSocket only:
# export LOAF_USER_ID="123"
# export LOAF_WS_URL="wss://api.loafmarkets.com/ws"| Variable | Purpose |
|---|---|
LOAF_API_KEY | Programmatic key from the API settings UI (step 2) |
LOAF_API_BASE_URL | REST base including /api |
LOAF_TARGET_TOKEN | Lowercase property tokenName |
LOAF_USER_ID | Numeric Loaf user id from GET /api/auth/profile for portfolio:{userId} |
LOAF_WS_URL | Optional WebSocket override; otherwise derived from the REST base |
All authenticated requests use the same API key as Authorization: Bearer …. LoafClient() reads these env vars when you omit constructor arguments.
from loaf import LoafClient
client = LoafClient() # uses LOAF_API_KEY / LOAF_API_BASE_URL1. Create your Loaf account
- Open https://beta.loafmarkets.com/ .
- Choose Sign up and register with email or connect a Web3 wallet. Either path creates a self-custodial Loaf account suitable for API trading.
- Complete any onboarding prompts in the app (wallet setup, profile, and KYC if you plan to move fiat or trade size that requires verification).
Bots are available to all account types on this environment; production may impose additional account or KYC checks (for example wholesale offerings). See Offerings when you need primary-market subscription.
Store recovery and wallet credentials securely. Loaf does not hold your keys; lost access cannot be reset through support in the same way as a centralized exchange password.
2. Create an API key
Programmatic access uses an API key you manage in the product UI, not your login password.
- Sign in and go to https://beta.loafmarkets.com/api .
- Create a key with a clear label (for example
trading-bot-dev). - Copy the secret immediately; it is shown only once. Put it in
LOAF_API_KEY.
You can rotate keys by revoking old ones in the same UI. HTTP key management is not part of the public API reference; the UI is the recommended path for your first bot.
For the private portfolio:{userId} WebSocket channel, call GET /api/auth/profile once with this key and copy userId into LOAF_USER_ID. See Get your userId.
Send the API key as Authorization: Bearer $LOAF_API_KEY on every authenticated endpoint in this guide: market reads that require auth, order placement, cancellation, and history. The SDK attaches this header for you.
3. Read the market and orders on the book
Before quoting or crossing the spread, load market context and the central limit order book for your token.
Market metadata
List tradeable properties, then load Info header / overview for your tokenName:
import os
from loaf import LoafClient
client = LoafClient()
token = os.environ.get("LOAF_TARGET_TOKEN", "opera")
listing = client.market.properties().get("properties") or []
print("tradeable:", [p.tokenName for p in listing])
header = client.market.info_header(token)
print("tokenName:", header.tokenName)
print("header:", header)
overview = client.market.info_overview(token)
# Gate trading on disclosure / status fields from overview if your bot needs them.Orders and WebSocket market channels use this same lowercase tokenName, not the ticker and not propertyId.
client.market.property(token) returns an embedded order-book snapshot as well (no auth required for public trade routes). See Trade and Info.
Orders on the book
Resting bids and asks stream on the Loaf WebSocket API. The SDK opens a threaded client, authenticates when needed, and subscribes to orderbook:{tokenName} (e.g. orderbook:opera). Updates use type orderbook_update with bids and asks arrays (each level has price, quantity, and orderId). Payloads may still include numeric propertyId.
Example message shape:
{
"type": "orderbook_update",
"propertyId": 42,
"bids": [{ "price": 124.0, "quantity": 5.0, "orderId": 8801 }],
"asks": [{ "price": 126.0, "quantity": 3.0, "orderId": 8802 }]
}ws = client.websocket()
@ws.on_orderbook
def on_book(msg):
bid = msg.bids[0].price if msg.get("bids") else None
ask = msg.asks[0].price if msg.get("asks") else None
print("book", bid, ask)
ws.subscribe_orderbook(token)
ws.start()
ws.wait_until_connected(timeout=10)Useful public channels for a bot: orderbook, markprice, and trades (all keyed by tokenName). Private fills and order transitions need portfolio:{userId} and LOAF_USER_ID.
orderbook and trades (and leaderboard) are full-picture streams — subscribe and do not poll REST for live state. markprice, volume, chart, and portfolio are deltas only: ask once via REST to seed current state (Trade, History, Portfolio), then listen on WebSocket. See Ask once vs listen.
News as a signal
Headlines are a separate public service. REST seed is https://news.loafmarkets.com/v1/headlines; live updates are wss://news.loafmarkets.com/v1/ws/headlines. No API key. LoafClient does not wrap it. Use requests plus a WebSocket client (pip install requests websocket-client).
Same pattern as delta market feeds: REST once to seed, then listen. Do not place or cancel from a headline alone; fold scores into the same observe loop that already holds the book.
import json
import os
import requests
from websocket import WebSocketApp
NEWS_REST = "https://news.loafmarkets.com/v1/headlines"
NEWS_WS = "wss://news.loafmarkets.com/v1/ws/headlines"
token = os.environ.get("LOAF_TARGET_TOKEN", "opera")
seed = requests.get(NEWS_REST, params={"limit": 20, "min_impact": 5})
seed.raise_for_status()
headlines = seed.json()["headlines"]
seen = {h["id"] for h in headlines}
last_seen_id = headlines[0]["id"] if headlines else None
def on_open(ws):
ws.send(json.dumps({
"type": "subscribe",
"filters": {"min_impact": 5}, # add "token": token only after GET /v1/assets/{token}/headlines returns 200
"last_seen_id": last_seen_id,
}))
def on_message(ws, raw):
msg = json.loads(raw)
if msg.get("type") != "article":
if msg.get("type") == "replay_complete" and (
msg.get("truncated") or msg.get("reason") == "cursor_expired"
):
pass # re-seed GET /v1/headlines
return
article = msg["article"]
if article["id"] in seen:
return
seen.add(article["id"])
print(article["title_rewritten"], article.get("sentiment_score"), article.get("impact_score"))
# YOUR STRATEGY GOES HERE — do not place/cancel from this callback
ws = WebSocketApp(NEWS_WS, on_open=on_open, on_message=on_message)
# ws.run_forever() # run on a thread if the order-book client already owns the main loopUseful query/filter knobs: min_impact, feed_type=property on REST, or filters.token on the socket once per-asset grading is on. A 503 on /v1/assets/... means grading is disabled, not that the asset has no news. Full contract: News API.
4. Place an order
Order placement is a two-step flow: nonce → submit.
POST /orders/nonce→ receivenonceanddeadlinePOST /orderswith the order body → receiveorderId
Both steps use your API key. Each order needs a fresh nonce; do not reuse one.
The SDK’s limit_buy / create helpers fetch a nonce for you when you omit it. This guide shows the dance explicitly so you see the protocol.
Trading eligibility: While a competition round is ACTIVE, only admitted accounts may place orders. Otherwise you get CompetitionEligibilityError (check client.competition.queue_position()). Outside an active round, trading is unrestricted. A platform-wide halt raises TradingHaltedError (403).
4a. Request a nonce
nonce_payload = client.orders.nonce()
nonce = nonce_payload["nonce"]
print("nonce=", nonce)4b. Build and submit the order
Construct a limit order below the best ask for a tutorial buy so it rests on the book instead of crossing (adjust prices using step 3 book data).
import loaf
token = os.environ.get("LOAF_TARGET_TOKEN", "opera")
price = 120.0 # set from the live book in a real bot
quantity = 1
try:
placed = client.orders.create(
token,
side="BUY",
quantity=quantity,
type="LIMIT",
price=price,
time_in_force="GTC",
deadline=0,
nonce=nonce, # fresh from 4a — never reuse
)
except loaf.CompetitionEligibilityError:
raise SystemExit("Not admitted to the active competition round")
except loaf.TradingHaltedError:
raise SystemExit("Trading halted platform-wide")
except loaf.LoafValidationError as exc:
raise SystemExit(f"Rejected: {exc.message} {exc.details}")
if not placed.success:
raise RuntimeError(placed.errorMessage or "Order rejected")
order_id = placed.orderId
print("placed orderId:", order_id)For a market order, set type="MARKET" and omit price (the SDK forces price to 0). See Orders — Create order.
Convenience wrappers still exist once you understand the protocol:
# Auto-fetches a nonce — equivalent to 4a + 4b for a limit buy
placed = client.orders.limit_buy(token, quantity=1, price=120.0)On 503, do not blind-retry: if the matching engine was unreachable before commit, the order was not placed (retry with a fresh nonce). If confirmation failed after commit, check open orders / portfolio before sending another identical order. The SDK never auto-retries order placement for this reason.
A successful response means the exchange accepted the order into the book. It does not mean the order filled. Fills arrive asynchronously on your private portfolio:{userId} WebSocket channel (set LOAF_USER_ID).
Save orderId from the response. Re-check the book from step 3 to see your order among resting bids.
5. Cancel the order
Cancel with POST /orders/cancel and the orderId from step 4:
cancelled = client.orders.cancel(order_id)
print(cancelled)
# Panic / flatten:
# client.orders.cancel_all()Confirm cancellation with a one-shot client.history.active_orders() seed (or wait until your orderId is gone from the WebSocket book / portfolio:{userId} feed). Do not poll active orders in a loop for live state — seed once, then listen.
Strategy shapes
Observe loop (bot.py)
The template’s default loop only observes. It:
- Checks credentials via
portfolio.component(). - Resolves
LOAF_TARGET_TOKEN(or the first listed property). - Subscribes to
orderbook,markprice, andtrades(plusportfoliowhenLOAF_USER_IDis set). - Calls
Strategy.on_tickevery 5 seconds on the main thread while WebSocket handlers update best bid/ask/mark under a lock.
def on_tick(self) -> None:
with self._lock:
bid, ask, mark = self.best_bid, self.best_ask, self.mark_price
spread = (ask - bid) if (bid is not None and ask is not None) else None
print(f"[{self.token_name}] bid={bid} ask={ask} spread={spread} mark={mark}")
# YOUR STRATEGY GOES HERE — place/cancel using the nonce dance aboveRun it with python bot.py. Wire place/cancel inside on_tick when you are ready to trade.
Market-making sketch
Once the tutorial steps work, a simple quoting loop on top of the same observe skeleton:
- Subscribe to
orderbook:{tokenName}(and optionallymarkpriceafter seeding mark from REST). - Compute a quote: e.g. best bid + tick for your bid, best ask − tick for your ask, sized to a max inventory cap.
- Reconcile your resting orders: seed once with
history.active_orders(), then applyportfolio:{userId}/ book updates against your last knownorderIds. - Replace stale quotes:
orders.cancel(order_id), request a new nonce, thenorders.create(..., nonce=…). - Stop on risk events: wide spread, missing book side,
CompetitionEligibilityError,TradingHaltedError, orsuccess: false.
Control loop: read book state from WebSocket → if spread is acceptable, cancel stale quotes → nonce, place → sleep → repeat. If the spread is too wide or the book is empty, sleep and alert instead of quoting. Use orders.cancel_all() as a flatten / panic path.
Hardening checklist for anything beyond a tutorial:
- Idempotency: track nonces and
orderIds locally; never reuse a nonce. - Secrets: store the API key in a vault; never commit
.env. - Observability: log HTTP status,
errorMessage, and book snapshots on failures. - Compliance: KYC, position limits, and jurisdiction rules before production capital.
Reference
| Topic | Link |
|---|---|
| Info (header, overview) | Info API |
| Nonce, place, cancel | Orders API |
| Your fills and history | History API |
| Live feeds | WebSocket API |
| Headlines (separate host) | News API |
| Competition eligibility | Competition API |
| Python SDK + bot template | loaf-python-api-bot-template |
| Dev app | api.loafmarkets.com |
| API keys UI | api.loafmarkets.com/api |