/** * adona-robot in the browser: candle history and the live stream, for one end user. * * No dependency. The page calls the API directly, so your site's origin must be * declared on your account page (browser origins); the token comes from your own * server (token-server.ts), never the API key. * * const bars = await history("EURUSD", "M1"); // once, when the chart opens * const stream = openStream(["quote:EURUSD", "candle:EURUSD:M1"], { * onQuote: (q) => console.log(q.symbol, q.bid, q.ask), * onBar: (channel, bar) => console.log(channel, bar.time, bar.close), * onStop: (reason) => console.warn("stream stopped:", reason), * }); * // later, e.g. when the chart unmounts: stream.close(); * * The stream carries closed bars of every timeframe, S1 to D1: * "candle:EURUSD:S1" delivers each one-second bar as it closes, and * "candle:EURUSD:D1" the day's bar at the 17:00 New York close. * * Fetch history once, then let the stream keep the chart current: polling * /v1/candles spends slices on bars the stream already delivers. Prices are * strings, exactly as served; convert them at the last moment, for display. */ const API = "https://api.adona-robot.com"; const STREAM = "wss://api.adona-robot.com/v1/stream"; const TOKEN_ROUTE = "/market-token"; // your server's route around POST /v1/tokens export interface Bar { time: string; // bar open time, RFC3339 UTC open: string; high: string; low: string; close: string; volume: string; } export interface Quote { symbol: string; bid: string; ask: string; as_of: string; } export class Refused extends Error { constructor( readonly status: number, /** The part of `detail` before the first colon: branch on this. */ readonly code: string, ) { super(`${status} ${code}`); } } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** An answer rather than an accident: retrying will not change it. */ const isAnswer = (error: unknown) => error instanceof Refused && (error.status === 401 || error.status === 403); // ---------------------------------------------------------------- the token let token: { value: string; until: number } | null = null; async function accessToken(fresh = false): Promise { if (!fresh && token && token.until > Date.now()) return token.value; const response = await fetch(fresh ? `${TOKEN_ROUTE}?fresh=1` : TOKEN_ROUTE, { method: "POST", credentials: "same-origin" }); if (!response.ok) throw new Refused(response.status, "TOKEN_ROUTE_FAILED"); const body = (await response.json()) as { access_token: string; expires_in: number }; token = { value: body.access_token, until: Date.now() + (body.expires_in - 60) * 1000 }; return token.value; } // ---------------------------------------------------------------- REST /** One call to the API, retried when waiting helps and only then. */ async function call(path: string, init: RequestInit = {}, attempts = 6): Promise { let fresh = false; for (let attempt = 0; ; attempt++) { let status = 0; let code = "NETWORK"; let retryAfter = 0; try { const response = await fetch(`${API}${path}`, { ...init, headers: { Authorization: `Bearer ${await accessToken(fresh)}` }, }); if (response.ok) return (await response.json()) as T; status = response.status; const detail = String(((await response.json().catch(() => ({}))) as { detail?: unknown }).detail ?? ""); code = detail.split(":")[0].trim(); retryAfter = Number(response.headers.get("Retry-After")) || 0; } catch (error) { if (error instanceof Refused) throw error; // from your token route // Otherwise the network: retried below like a 5xx. } // An expired token, or one the key's rotation retired: a fresh one, once. if (status === 401 && (code === "INVALID_JWT" || code === "CUSTOMER_ACCESS_REVOKED") && !fresh) { fresh = true; continue; } fresh = false; // 429 clears by waiting, except the daily guard, which reopens the next UTC day. // 5xx and the network are temporary. Everything else is an answer: stop. const retryable = status === 0 || (status === 429 && code !== "CUSTOMER_DAILY_CAP") || status >= 500; if (!retryable || attempt === attempts - 1) throw new Refused(status, code); await sleep(1000 * (retryAfter > 0 ? retryAfter : 2 ** attempt)); } } interface CandlePage { candles: Bar[]; next_before: string | null; } /** The latest `bars` bars (1000 by default), oldest first. */ export async function history(symbol: string, timeframe: string, bars = 1000): Promise { const out: Bar[] = []; let before: string | null = null; while (out.length < bars) { const params = new URLSearchParams({ symbol, timeframe, limit: String(Math.min(5000, bars - out.length)) }); if (before) params.set("before", before); const page: CandlePage = await call(`/v1/candles?${params}`); out.unshift(...page.candles); before = page.next_before; if (!before) break; // nothing older } return out.slice(-bars); } // ---------------------------------------------------------------- the live stream export interface StreamHandlers { onQuote?: (quote: Quote) => void; /** A bar: the history sent on (re)subscribe, then each bar as it closes. */ onBar?: (channel: string, bar: Bar) => void; /** Called once when the stream gives up; the reason is a code to show or log. */ onStop?: (reason: string) => void; } type Frame = { type: string; payload: Record }; const OPEN = 1; // WebSocket.OPEN /** Channels are `quote:` and `candle::`, up to 64. */ export function openStream(channels: string[], handlers: StreamHandlers): { close(): void } { let socket: WebSocket | null = null; let stopped = false; let failures = 0; // consecutive, for the backoff let badTickets = 0; let revoked = 0; const stop = (reason: string) => { if (stopped) return; stopped = true; socket?.close(1000); handlers.onStop?.(reason); }; // Each (re)subscribe sends the channels' recent history again, which is metered: // it is what a reconnect costs, and why the backoff below is not a tight loop. const subscribe = (on: WebSocket) => { if (on === socket && on.readyState === OPEN) { on.send(JSON.stringify({ type: "subscribe", request_id: "sub", payload: { channels } })); } }; const onFrame = (on: WebSocket, { type, payload }: Frame) => { if (type === "subscribed") { failures = 0; badTickets = 0; revoked = 0; const rejected = (payload.rejected as string[] | undefined) ?? []; if (rejected.length) console.warn("not in your account's list:", rejected); } else if (type === "quote.update" || type === "quote.snapshot") { handlers.onQuote?.(payload as unknown as Quote); } else if (type === "candle.snapshot") { for (const bar of payload.candles as Bar[]) handlers.onBar?.(payload.channel as string, bar); } else if (type === "candle.bar") { // Live bars use short keys and carry no channel: rebuild both. const p = payload as Record; handlers.onBar?.(`candle:${p.symbol}:${p.tf}`, { time: String(p.ts), open: String(p.o), high: String(p.h), low: String(p.l), close: String(p.c), volume: String(p.v), }); } else if (type === "error") { // The plan's slices for this minute are spent and the subscribe was undone: // ask again once the minute has turned, on this same socket. if (payload.code === "CUSTOMER_MINUTE_CEILING") setTimeout(() => subscribe(on), 15_000); else if (payload.code === "CUSTOMER_DAILY_CAP") stop("CUSTOMER_DAILY_CAP"); else console.warn("stream error:", payload.code); } }; const reconnectLater = () => { if (stopped) return; const delay = Math.min(30_000, 1000 * 2 ** failures++) * (0.5 + Math.random() / 2); setTimeout(connect, delay); }; async function connect() { if (stopped) return; let ticket: string; try { ticket = (await call<{ ticket: string }>("/v1/ws-ticket", { method: "POST" })).ticket; } catch (error) { // Access cut, trial ended, signed out of your site: stop. Anything else // (a deploy, the network, a busy minute) passes: try again later. return isAnswer(error) ? stop((error as Refused).code) : reconnectLater(); } if (stopped) return; // closed while the ticket was on its way // Two subprotocols, the ticket as the second. Never in the URL: it is not read there. const on = new WebSocket(STREAM, ["adona.data.v1", `ticket.${ticket}`]); socket = on; on.onopen = () => subscribe(on); on.onmessage = (event) => onFrame(on, JSON.parse(event.data as string) as Frame); on.onclose = (event) => { if (stopped || on !== socket) return; if (event.code === 1008 && event.reason === "INVALID_TICKET" && ++badTickets < 2) return void connect(); // A key rotation closes every socket this way: the next ticket call gets a fresh // token from your server, once. Refused again, the access really is cut. After a // pause, not at once: every end user is closed at the same instant. if (event.code === 1008 && event.reason === "CUSTOMER_ACCESS_REVOKED" && ++revoked < 2) return reconnectLater(); if (event.code === 1008 && event.reason === "RATE_LIMITED") return reconnectLater(); // Any other 1008 means stop (revoked, trial ended, connection limit): reconnecting // in a loop would be refused the same way. 1009: a message over 16 KiB. if (event.code === 1008 || event.code === 1009) return stop(event.reason || String(event.code)); reconnectLater(); // 1011 is ours and temporary; 1006 and 1012 are the network or a deploy }; } void connect(); return { close: () => stop("closed") }; }