Skip to main content

Build a trading bot

This page builds a complete bot in one Python file. It's a moving-average crossover strategy, kept simple on purpose. The strategy is just an example; the rest is the plumbing every bot needs:

  • authenticates with an API key and refreshes its token,
  • loads the symbol's trading limits,
  • warms up with historical candles,
  • streams live ticks over the WebSocket and builds 1-minute bars,
  • places market orders with SL/TP, rounded to the symbol's rules,
  • learns the result from WebSocket events, not the HTTP response,
  • reconciles its positions from REST after every (re)connect,
  • reconnects with backoff and logs in again if its session is killed.
Demo first

Run this against a demo account until you trust it. It places real orders on whatever account the API key belongs to. It's example code — not investment advice.

Setup​

  1. Create an API key for your demo account.

  2. Install the one dependency (Python 3.9+):

    pip install aiohttp
  3. Export your credentials:

    export OT_API="https://api.onlytradeplatform.com" # your broker's API host
    export OT_CLIENT_ID="26100003_4829104756"
    export OT_CLIENT_SECRET="b41c0f…"

The code​

Save as bot.py and run python bot.py.

bot.py
import asyncio
import json
import logging
import os
import time
import uuid

import aiohttp

API = os.environ.get("OT_API", "https://api.onlytradeplatform.com")
WS_URL = API.replace("https://", "wss://").replace("http://", "ws://") + "/ws/v1/"
CLIENT_ID = os.environ["OT_CLIENT_ID"]
CLIENT_SECRET = os.environ["OT_CLIENT_SECRET"]

SYMBOL = "EURUSD"
VOLUME = 0.10 # lots
FAST, SLOW = 10, 30 # SMA periods, in 1-minute bars
SL_POINTS = 200 # stop loss distance, in points
TP_POINTS = 400 # take profit distance, in points
BOT_TAG = "smabot" # prefix of every order comment, to recognise our trades

log = logging.getLogger("bot")


class ApiError(Exception):
def __init__(self, status, message):
super().__init__(f"{status}: {message}")
self.status = status


class OnlyTrade:
"""Thin client: token handling + REST calls in the standard envelope."""

def __init__(self, http: aiohttp.ClientSession):
self.http = http
self.tokens = None

async def login(self):
# API key → token. remember_me=true avoids the 5-minute idle logout.
async with self.http.post(
f"{API}/auth/v1/oauth2/token",
params={"grant_type": "client_credentials", "remember_me": "true"},
auth=aiohttp.BasicAuth(CLIENT_ID, CLIENT_SECRET),
) as r:
body = await r.json(content_type=None)
if not body.get("success"):
raise ApiError(r.status, body.get("message"))
self.tokens = body["data"]
log.info("logged in as %s (scope %s)", self.tokens["account_id"], self.tokens["scope"])

async def refresh(self):
# Refresh tokens rotate: always keep the newest one. If refreshing
# fails, fall back to a fresh login with the API key.
async with self.http.post(
f"{API}/auth/v1/oauth2/refresh/token",
params={"remember_me": "true"},
json={"refresh_token": self.tokens["refresh_token"]},
) as r:
body = await r.json(content_type=None)
if body.get("success"):
self.tokens = body["data"]
else:
await self.login()

def auth_header(self):
return {"Authorization": f"Bearer {self.tokens['access_token']}"}

async def call(self, method, path, **kwargs):
for attempt in range(2):
async with self.http.request(
method, API + path, headers=self.auth_header(), **kwargs
) as r:
text = await r.text()
body = json.loads(text) if text else {"success": r.ok, "data": None}
if r.status == 401 and attempt == 0:
await self.refresh() # expired token: refresh once, retry
continue
if not body.get("success"):
raise ApiError(r.status, body.get("message"))
return body["data"]


class Bot:
def __init__(self, ot: OnlyTrade):
self.ot = ot
self.sym = None # symbol settings
self.closes = [] # closed 1-minute bar closes
self.bar_minute = None # minute of the bar being built
self.bar_close = None
self.bid = self.ask = None
self.positions = {} # position_id -> position, for SYMBOL only
self.in_flight = None # (comment, sent_at) of the order awaiting a result

# ---------- startup ----------

async def load_symbol(self):
self.sym = await self.ot.call(
"GET", "/api/v1/symbols/me/by_name", params={"symbol": SYMBOL}
)
log.info("%s id=%s digits=%s lots %s-%s step %s stop_level %s",
SYMBOL, self.sym["id"], self.sym["digits"], self.sym["min_value"],
self.sym["max_value"], self.sym["step"], self.sym["stop_level"])

async def load_history(self):
now = int(time.time())
bars = await self.ot.call("GET", "/api/v1/market/history", params={
"symbol_id": self.sym["id"], "resolution": "1m",
"from": now - 6 * 3600, "to": now, "count_back": SLOW * 3,
})
# The last bar is still forming; keep only completed ones.
self.closes = [b["close"] for b in bars[:-1]]
log.info("warmed up with %d bars", len(self.closes))

async def reconcile(self):
"""Rebuild local state from REST. Run after every (re)connect."""
open_positions = await self.ot.call("GET", "/api/v1/positions/accounts/me")
self.positions = {p["id"]: p for p in open_positions
if p["symbol_id"] == self.sym["id"]}
self.in_flight = None
log.info("reconciled: %d open %s position(s)", len(self.positions), SYMBOL)

# ---------- trading ----------

def point(self):
return 10 ** -self.sym["digits"]

def round_volume(self, volume):
step = self.sym["step"]
vol = round(round(volume / step) * step, 8)
return min(max(vol, self.sym["min_value"]), self.sym["max_value"])

async def open_position(self, side):
# side: 0 buy, 1 sell. SL/TP are measured from the price we'd close
# at (bid for a buy, ask for a sell) and must clear stop_level.
digits, pt = self.sym["digits"], self.point()
min_dist = (self.sym["stop_level"] + 1) * pt
sl_dist, tp_dist = max(SL_POINTS * pt, min_dist), max(TP_POINTS * pt, min_dist)
if side == 0:
price, ref = self.ask, self.bid
sl, tp = ref - sl_dist, ref + tp_dist
else:
price, ref = self.bid, self.ask
sl, tp = ref + sl_dist, ref - tp_dist

comment = f"{BOT_TAG}-{uuid.uuid4().hex[:8]}"
order = {
"symbol_id": self.sym["id"],
"type": 0, # market
"side": side,
"volume": self.round_volume(VOLUME),
"order_price": round(price, digits), # required, never 0
"stop_loss": round(sl, digits),
"take_profit": round(tp, digits),
"comment": comment,
}
self.in_flight = (comment, time.time())
# 201 only means "accepted" — the fill or rejection arrives on the socket.
msg = await self.ot.call("POST", "/api/v1/orders/accounts/me", json=order)
log.info("sent %s: %s", comment, msg)

async def close_position(self, pos):
# volume is required; the full volume closes the whole position.
await self.ot.call("POST", f"/api/v1/positions/{pos['id']}/accounts/me",
json={"volume": pos["volume"]})
log.info("closing position %s", pos["id"])

async def on_bar(self, close):
self.closes.append(close)
self.closes = self.closes[-SLOW * 3:]
if len(self.closes) < SLOW + 1:
return
sma = lambda n, end: sum(self.closes[end - n:end]) / n
n = len(self.closes)
fast_now, slow_now = sma(FAST, n), sma(SLOW, n)
fast_prev, slow_prev = sma(FAST, n - 1), sma(SLOW, n - 1)

if fast_prev <= slow_prev and fast_now > slow_now:
signal = 0 # crossed up → buy
elif fast_prev >= slow_prev and fast_now < slow_now:
signal = 1 # crossed down → sell
else:
return

if self.in_flight or self.bid is None:
return
for pos in list(self.positions.values()):
if pos["side"] != signal:
await self.close_position(pos) # flip: close the opposite trade
if not any(p["side"] == signal for p in self.positions.values()):
await self.open_position(signal)

# ---------- WebSocket ----------

async def on_tick(self, line):
f = line.split(",")
if int(f[0]) != self.sym["id"]:
return
self.bid, self.ask, ts = float(f[1]), float(f[2]), int(f[8])
minute = ts // 60_000
if self.bar_minute is not None and minute != self.bar_minute:
await self.on_bar(self.bar_close) # previous minute is complete
self.bar_minute, self.bar_close = minute, self.bid

async def on_event(self, ev):
kind, payload = ev.get("type"), ev.get("payload")

if kind in ("position_create", "position_update", "position_close"):
if payload.get("symbol_id") != self.sym["id"]:
return
if payload.get("status") == 2: # closed
self.positions.pop(payload["id"], None)
log.info("position %s closed, profit %s", payload["id"], payload.get("profit"))
else:
self.positions[payload["id"]] = payload
if kind == "position_create":
log.info("filled: position %s %s @ %s", payload["id"],
payload["volume"], payload["open_price"])
if self.in_flight and payload.get("comment") == self.in_flight[0]:
self.in_flight = None

elif kind == "order_rejected":
log.warning("order rejected: %s", payload.get("rejection_msg"))
self.in_flight = None

elif kind in ("bad_request", "forbidden", "internal_server_error"):
log.warning("%s (%s): %s", kind, payload.get("reason"), payload.get("message"))
if payload.get("reason") == "order_create":
self.in_flight = None

elif kind == "account_Summary":
pass # live balance/equity/margin — add risk checks here

elif kind in ("session_client_logout", "unauthorized"):
# Session killed (limit reached, disconnected, …): log in again.
raise SessionLost(kind)

async def run(self):
await self.ot.login()
await self.load_symbol()
await self.load_history()
backoff = 1
while True:
try:
await self.stream()
backoff = 1
except SessionLost as e:
log.warning("session lost (%s) — logging in again", e)
await self.ot.login()
except (aiohttp.ClientError, asyncio.TimeoutError, ApiError) as e:
log.warning("connection problem: %s", e)
if isinstance(e, aiohttp.WSServerHandshakeError) and e.status == 401:
await self.ot.refresh()
log.info("reconnecting in %ss", backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)

async def stream(self):
url = f"{WS_URL}?session_id={self.ot.tokens['session_id']}"
# aiohttp answers the server's pings automatically (every ~5 s).
async with self.ot.http.ws_connect(url, headers=self.ot.auth_header()) as ws:
log.info("websocket connected")
await self.reconcile() # never trust state across a gap
await ws.send_json({"type": "start_market_feed", "payload": {}}) # once!
async for msg in ws:
if msg.type == aiohttp.WSMsgType.BINARY:
await self.on_tick(msg.data.decode())
elif msg.type == aiohttp.WSMsgType.TEXT:
if msg.data == "10": # reply to an app-level "9" ping
continue
await self.on_event(json.loads(msg.data))
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
if self.in_flight and time.time() - self.in_flight[1] > 10:
log.warning("no result for %s after 10s — reconciling", self.in_flight[0])
await self.reconcile()
log.warning("websocket closed")


class SessionLost(Exception):
pass


async def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) as http:
ot = OnlyTrade(http)
try:
await Bot(ot).run()
finally:
if ot.tokens: # free the session slot on shutdown
async with http.delete(f"{API}/auth/v1/oauth2/logout", headers=ot.auth_header()):
log.info("logged out")


if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass

How it works​

Authentication. login() exchanges the API key for a token with remember_me=true. Every REST call goes through call(), which refreshes the token once on a 401 and retries. If the refresh fails, it logs in with the key again. The bot only logs in at startup and after its session is killed, so it doesn't use up your 5 sessions.

Warm-up. load_history() fetches the last ~90 one-minute candles so the moving averages are ready as soon as ticks arrive.

Live data. After connecting, the bot sends start_market_feed once and receives binary CSV ticks for every symbol. It keeps only its own symbol and builds 1-minute bars from the bid. When a minute rolls over, the finished bar goes to the strategy.

Orders. open_position() rounds the volume to the symbol's step and the prices to digits. It places SL/TP at least stop_level away from the price the trade would close at. The 201 response is only an acknowledgement. The bot marks the order in flight and waits.

Results. on_event() settles the in-flight order on position_create (matched by the unique comment) or on a rejection (order_rejected, or bad_request with reason: "order_create"). If nothing comes back within 10 seconds, it re-reads positions from REST.

Reconnects. Whenever the socket drops, the loop reconnects with exponential backoff. It reconciles positions from REST first, because events sent while disconnected are lost. session_client_logout or unauthorized means the session is gone, so the bot logs in again instead of just reconnecting.

Shutdown. On exit it calls logout, which frees the session slot.

Adapting it​

  • Another strategy: replace on_bar(). Everything else stays.
  • Several symbols: keep one sym/bar state per symbol, and still send start_market_feed only once — it already streams every symbol.
  • Risk limits: handle account_Summary in on_event() (it has equity, free_margin, margin_level and per-position profit) and stop trading at your limit. On a prop account, also read prop_status from GET /api/v1/accounts/me.
  • Other languages: the flow maps 1:1 — any HTTP client plus any WebSocket client that can send an Authorization header (or put the token in the access_token query parameter) and handle binary frames.

Before you run it on a live account, go through Running a bot in production.