/** * 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(method: string, path: string, body?: unknown, idempotencyKey?: string): Promise<{ status: number; data: T }> { const headers: Record = { 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 { 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 { 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) })