Skip to main content
Developers

Example bots

Two complete, small moving-average crossover bots, one in Python and one in TypeScript. They show the patterns every trading program needs. They are examples to learn from, not trading advice.

Downloads

Also: requirements.txt · package.json · openapi.yaml (to generate a client in your own language)

What they do

  1. Read /me and refuse to run unless the key is for a demo account.
  2. Every 15 seconds, read candles and compare a fast and a slow moving average of the closed bars.
  3. When the signal flips, close the bot's opposite positions and open one in the new direction, tagged with the bot's client_tag.
  4. Print order events from the stream as they happen.

Run them

Python
pip install -r requirements.txt
export JAFX_API_KEY='jafx_demo_…'
python ma_cross_bot.py --symbol EURUSD --timeframe M5 --volume 0.01

Patterns worth copying

  • One idempotency key per intended action. The key is generated when the bot decides to trade, and every retry of that call reuses it. A dropped connection can never open a second position.
  • Retry only what is safe to retry. 429, 503 and 409 request_in_progress wait for Retry-After and try again; other errors are reported, not retried.
  • Follow, never resend. A 202 answer (pending or outcome_unknown) is followed by polling the request until it is decided.
  • Respect the platform's brakes. trading_halted pauses the bot; daily_loss_limit_reached stops it for the day.
  • Streams reconnect with backoff and stop on 4401 (bad key) and 4409 (another copy running).
  • Tags keep strategies apart. The bot only ever touches positions carrying its own client_tag, so you can run it next to manual trading.

Python

ma_cross_bot.py
"""
Moving-average crossover bot for the JAFX Trading API (demo accounts).

    pip install -r requirements.txt
    export JAFX_API_KEY=jafx_demo_...        # from the web terminal: Developers -> Create API key
    python ma_cross_bot.py --symbol EURUSD --timeframe M5 --volume 0.01

What it shows: authentication, reading /me limits, candles, idempotent orders,
handling 202 pending / outcome_unknown, stopping on trading_halted, and the
orders stream. It is an example, not trading advice.
"""

import argparse
import asyncio
import json
import os
import sys
import uuid

import httpx
import websockets

BASE = os.environ.get("JAFX_API_BASE", "https://terminal.jafx.com/trading-api/v1")
KEY = os.environ.get("JAFX_API_KEY", "")
TAG = "ma-cross"


class ApiError(Exception):
    def __init__(self, status, problem):
        super().__init__(f"{status} {problem.get('code')}: {problem.get('detail') or problem.get('title')}")
        self.status = status
        self.code = problem.get("code")
        self.retry_after = problem.get("retry_after")


async def call(client, method, path, body=None, idempotency_key=None):
    headers = {"Authorization": f"Bearer {KEY}"}
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key
    for attempt in range(4):
        try:
            r = await client.request(method, f"{BASE}{path}", json=body, headers=headers, timeout=20)
        except httpx.TransportError:
            # Network blip: retrying is safe because writes carry the same Idempotency-Key.
            await asyncio.sleep(1 + attempt)
            continue
        if r.status_code in (429, 503) or (r.status_code == 409 and r.json().get("code") in ("request_in_progress", "order_busy")):
            await asyncio.sleep(float(r.headers.get("Retry-After", "1")))
            continue
        data = r.json() if r.content else None
        if r.status_code >= 400:
            raise ApiError(r.status_code, data or {})
        return r.status_code, data
    raise RuntimeError(f"{method} {path} kept failing")


def sma(values, n):
    return sum(values[-n:]) / n if len(values) >= n else None


async def follow_request(client, account, result):
    """A 202 means the broker is still deciding (or the answer was lost): poll, never resend."""
    while result.get("status") in ("pending", "outcome_unknown"):
        await asyncio.sleep(1)
        _, result = await call(client, "GET", f"/accounts/{account}/requests/{result['request_id']}")
    return result


async def stream_orders(key):
    url = BASE.replace("https://", "wss://").replace("http://", "ws://") + "/stream"
    delay = 1
    while True:
        try:
            async with websockets.connect(url, additional_headers={"Authorization": f"Bearer {key}"}) as ws:
                delay = 1
                await ws.send(json.dumps({"op": "subscribe", "channels": ["orders"]}))
                async for raw in ws:
                    frame = json.loads(raw)
                    if frame.get("ch") == "orders":
                        print(f"[stream] {frame['event']}: {frame['data']}")
        except websockets.ConnectionClosed as e:
            code = e.rcvd.code if e.rcvd else None
            if code in (4401, 4409):
                print(f"[stream] closed with {code}; not reconnecting")
                return
        except OSError as e:
            print(f"[stream] {e}")
        await asyncio.sleep(delay)
        delay = min(delay * 2, 30)


async def main():
    p = argparse.ArgumentParser()
    p.add_argument("--symbol", default="EURUSD")
    p.add_argument("--timeframe", default="M5")
    p.add_argument("--volume", default="0.01")
    p.add_argument("--fast", type=int, default=10)
    p.add_argument("--slow", type=int, default=30)
    args = p.parse_args()
    if not KEY:
        sys.exit("Set JAFX_API_KEY")

    async with httpx.AsyncClient() as client:
        _, me = await call(client, "GET", "/me")
        account = me["account"]
        print(f"key {me['name']} on account {account} ({me['mode']}); limits: {me['constraints']}")
        if me["mode"] != "demo":
            sys.exit("This example only runs on demo accounts")
        asyncio.create_task(stream_orders(KEY))

        last_signal = None
        while True:
            try:
                _, candles = await call(client, "GET", f"/candles/{args.symbol}?timeframe={args.timeframe}&limit={args.slow + 5}")
                closes = [c["close"] for c in candles["data"] if c["complete"]]
                fast, slow = sma(closes, args.fast), sma(closes, args.slow)
                if fast is None or slow is None:
                    await asyncio.sleep(10)
                    continue
                signal = "buy" if fast > slow else "sell"
                if signal != last_signal:
                    _, positions = await call(client, "GET", f"/accounts/{account}/positions?client_tag={TAG}")
                    for pos in positions["data"]:
                        if pos["side"] != signal:
                            # One idempotency key per intended action.
                            _, r = await call(client, "POST", f"/accounts/{account}/positions/{pos['ticket']}/close", {}, str(uuid.uuid4()))
                            print("closed", pos["ticket"], (await follow_request(client, account, r))["status"])
                    if not any(pos["side"] == signal for pos in positions["data"]):
                        order = {"symbol": args.symbol, "side": signal, "type": "market", "volume": args.volume, "client_tag": TAG}
                        status, r = await call(client, "POST", f"/accounts/{account}/orders", order, str(uuid.uuid4()))
                        r = await follow_request(client, account, r)
                        print(f"{signal} {args.volume} {args.symbol}: {r['status']} ticket={r.get('ticket')}")
                    last_signal = signal
            except ApiError as e:
                if e.code == "trading_halted":
                    print("Trading halted by JAFX; waiting a minute")
                    await asyncio.sleep(60)
                elif e.code in ("daily_loss_limit_reached",):
                    print("Daily loss limit reached; stopping for today")
                    return
                else:
                    print("API error:", e)
            await asyncio.sleep(15)


if __name__ == "__main__":
    asyncio.run(main())

TypeScript

ma-cross-bot.ts
/**
 * Moving-average crossover bot for the JAFX Trading API (demo accounts).
 *
 *   npm install
 *   JAFX_API_KEY=jafx_demo_… npx tsx ma-cross-bot.ts EURUSD M5 0.01
 *
 * Shows: authentication, /me limits, candles, idempotent orders, 202 handling,
 * trading_halted, and the orders stream. An example, not trading advice.
 */

import { randomUUID } from 'node:crypto'
import WebSocket from 'ws'

const BASE = process.env.JAFX_API_BASE ?? 'https://terminal.jafx.com/trading-api/v1'
const KEY = process.env.JAFX_API_KEY ?? ''
const TAG = 'ma-cross-ts'
const [symbol = 'EURUSD', timeframe = 'M5', volume = '0.01'] = process.argv.slice(2)

class ApiError extends Error {
  constructor(readonly status: number, readonly code: string, detail: string) {
    super(`${status} ${code}: ${detail}`)
  }
}

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))

async function call<T = any>(method: string, path: string, body?: unknown, idempotencyKey?: string): Promise<{ status: number; data: T }> {
  const headers: Record<string, string> = { Authorization: `Bearer ${KEY}` }
  if (body !== undefined) headers['Content-Type'] = 'application/json'
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey
  for (let attempt = 0; attempt < 4; attempt++) {
    let res: Response
    try {
      res = await fetch(`${BASE}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
    } catch {
      await sleep(1000 * (attempt + 1)) // safe to retry: writes reuse the same Idempotency-Key
      continue
    }
    const data = res.status === 204 ? null : await res.json()
    const retryable = res.status === 429 || res.status === 503 || (res.status === 409 && ['request_in_progress', 'order_busy'].includes(data?.code))
    if (retryable) {
      await sleep(Number(res.headers.get('retry-after') ?? '1') * 1000)
      continue
    }
    if (!res.ok) throw new ApiError(res.status, data?.code ?? 'unknown', data?.detail ?? data?.title ?? '')
    return { status: res.status, data }
  }
  throw new Error(`${method} ${path} kept failing`)
}

/** 202 = still deciding (or answer lost): poll the request, never resend under a new key. */
async function follow(account: number, result: any): Promise<any> {
  while (result.status === 'pending' || result.status === 'outcome_unknown') {
    await sleep(1000)
    result = (await call(`GET`, `/accounts/${account}/requests/${result.request_id}`)).data
  }
  return result
}

function sma(values: number[], n: number): number | null {
  return values.length >= n ? values.slice(-n).reduce((a, b) => a + b, 0) / n : null
}

function streamOrders(delayMs = 1000): void {
  const ws = new WebSocket(`${BASE.replace(/^http/, 'ws')}/stream`, { headers: { Authorization: `Bearer ${KEY}` } })
  ws.on('open', () => {
    delayMs = 1000
    ws.send(JSON.stringify({ op: 'subscribe', channels: ['orders'] }))
  })
  ws.on('message', raw => {
    const f = JSON.parse(raw.toString())
    if (f.ch === 'orders') console.log(`[stream] ${f.event}`, f.data)
  })
  ws.on('error', e => console.error('[stream] error:', e.message))
  ws.on('close', code => {
    if (code === 4401 || code === 4409) return console.error(`[stream] closed with ${code}; not reconnecting`)
    setTimeout(() => streamOrders(Math.min(delayMs * 2, 30_000)), delayMs)
  })
}

async function main(): Promise<void> {
  if (!KEY) throw new Error('Set JAFX_API_KEY')
  const me = (await call('GET', '/me')).data
  if (me.mode !== 'demo') throw new Error('This example only runs on demo accounts')
  const account: number = me.account
  console.log(`key ${me.name} on account ${account}; limits`, me.constraints)
  streamOrders()

  let last: 'buy' | 'sell' | null = null
  for (;;) {
    try {
      const candles = (await call('GET', `/candles/${symbol}?timeframe=${timeframe}&limit=40`)).data
      const closes: number[] = candles.data.filter((c: any) => c.complete).map((c: any) => c.close)
      const fast = sma(closes, 10)
      const slow = sma(closes, 30)
      if (fast !== null && slow !== null) {
        const signal = fast > slow ? 'buy' : 'sell'
        if (signal !== last) {
          const positions = (await call('GET', `/accounts/${account}/positions?client_tag=${TAG}`)).data.data
          for (const p of positions.filter((p: any) => p.side !== signal)) {
            const r = await follow(account, (await call('POST', `/accounts/${account}/positions/${p.ticket}/close`, {}, randomUUID())).data)
            console.log('closed', p.ticket, r.status)
          }
          if (!positions.some((p: any) => p.side === signal)) {
            const order = { symbol, side: signal, type: 'market', volume, client_tag: TAG }
            const r = await follow(account, (await call('POST', `/accounts/${account}/orders`, order, randomUUID())).data)
            console.log(`${signal} ${volume} ${symbol}: ${r.status} ticket=${r.ticket}`)
          }
          last = signal
        }
      }
    } catch (e) {
      if (e instanceof ApiError && e.code === 'trading_halted') { console.log('Trading halted by JAFX; waiting'); await sleep(60_000) }
      else if (e instanceof ApiError && e.code === 'daily_loss_limit_reached') { console.log('Daily loss limit reached; stopping'); return }
      else console.error(e)
    }
    await sleep(15_000)
  }
}

main().catch(e => { console.error(e); process.exit(1) })

Automated and AI-driven trading can lose money quickly; nothing in these docs is investment advice.

JAFX (PTY) LTD · FSCA FSP No. 52058 · Trading leveraged derivatives with JAFX carries a high risk of losing money rapidly. It is not suitable for everyone.