UNPKG

tradingview-api-adapter

Version:

Real-time market data from TradingView via WebSocket: quotes, candles, symbol info, streaming, groups.

1,653 lines (1,632 loc) 70.2 kB
'use strict'; var debug = require('debug'); function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } var debug__default = /*#__PURE__*/_interopDefault(debug); // src/utils/logger.ts var ROOT_NAMESPACE = "tradingview-adapter"; function createLogger(namespace) { return debug__default.default(`${ROOT_NAMESPACE}:${namespace}`); } // src/core/errors.ts var TvError = class extends Error { cause; constructor(message, options = {}) { super(message); this.name = this.constructor.name; if (options.cause !== void 0) { this.cause = options.cause; } } }; var TvConnectionError = class extends TvError { }; var TvProtocolError = class extends TvError { }; var TvSessionError = class extends TvError { }; var TvSymbolError = class extends TvError { constructor(symbol, message, options = {}) { super(`[${symbol}] ${message}`, options); this.symbol = symbol; } symbol; }; var TvTimeoutError = class extends TvError { constructor(message, timeoutMs, options = {}) { super(`${message} (timeout=${timeoutMs}ms)`, options); this.timeoutMs = timeoutMs; } timeoutMs; }; // src/core/protocol.ts var FRAME_MARKER = "~m~"; var HEARTBEAT_MARKER = "~h~"; function encodeFrame(payload) { const body = typeof payload === "string" ? payload : JSON.stringify(payload); return `${FRAME_MARKER}${body.length}${FRAME_MARKER}${body}`; } function encodeHeartbeat(id) { if (!Number.isFinite(id)) { throw new TvProtocolError(`Invalid heartbeat id: ${id}`); } return encodeFrame(`${HEARTBEAT_MARKER}${id}`); } function encodeCommand(method, params) { return encodeFrame({ m: method, p: params }); } function decodeFrames(raw) { if (raw.length === 0) return []; const messages = []; let cursor = 0; while (cursor < raw.length) { if (raw.slice(cursor, cursor + FRAME_MARKER.length) !== FRAME_MARKER) { throw new TvProtocolError( `Expected frame marker "~m~" at position ${cursor}, got "${raw.slice(cursor, cursor + 10)}"` ); } cursor += FRAME_MARKER.length; const lengthEnd = raw.indexOf(FRAME_MARKER, cursor); if (lengthEnd === -1) { throw new TvProtocolError(`Unterminated length marker starting at position ${cursor}`); } const lengthStr = raw.slice(cursor, lengthEnd); const length = Number.parseInt(lengthStr, 10); if (!Number.isFinite(length) || length < 0 || String(length) !== lengthStr) { throw new TvProtocolError(`Invalid frame length: "${lengthStr}"`); } cursor = lengthEnd + FRAME_MARKER.length; if (cursor + length > raw.length) { throw new TvProtocolError( `Truncated payload: declared ${length} chars but only ${raw.length - cursor} available` ); } const payload = raw.slice(cursor, cursor + length); cursor += length; const parsed = parsePayload(payload); if (parsed !== null) messages.push(parsed); } return messages; } function parsePayload(payload) { if (payload === "") return null; if (payload.startsWith(HEARTBEAT_MARKER)) { const id = Number.parseInt(payload.slice(HEARTBEAT_MARKER.length), 10); if (!Number.isFinite(id)) { throw new TvProtocolError(`Invalid heartbeat payload: "${payload}"`); } const msg = { type: "heartbeat", id }; return msg; } let data; try { data = JSON.parse(payload); } catch (err) { throw new TvProtocolError(`Invalid JSON payload: "${truncate(payload, 80)}"`, { cause: err }); } if (isCommandShape(data)) { const msg = { type: "message", method: data.m, params: Array.isArray(data.p) ? data.p : [] }; return msg; } const hello = { type: "hello", data }; return hello; } function isCommandShape(data) { return typeof data === "object" && data !== null && "m" in data && typeof data.m === "string"; } function truncate(s, max) { return s.length > max ? `${s.slice(0, max)}\u2026` : s; } // src/core/constants.ts var TV_WS_URL = "wss://widgetdata.tradingview.com/socket.io/websocket"; var TV_ORIGIN = "https://s.tradingview.com"; // src/core/rate-limiter.types.ts var DEFAULT_RATE_LIMIT = { batchWindowMs: 50, chunkSize: 50, chunkIntervalMs: 100 }; function resolveRateLimit(opts = {}) { return { batchWindowMs: opts.batchWindowMs ?? DEFAULT_RATE_LIMIT.batchWindowMs, chunkSize: opts.chunkSize ?? DEFAULT_RATE_LIMIT.chunkSize, chunkIntervalMs: opts.chunkIntervalMs ?? DEFAULT_RATE_LIMIT.chunkIntervalMs }; } // src/utils/backoff.ts var DEFAULT_BACKOFF = { initialDelayMs: 100, maxDelayMs: 3e4, factor: 2, jitter: 0.3 }; function calculateBackoff(attempt, opts = {}, random = Math.random) { if (attempt < 1) throw new Error(`attempt must be >= 1, got ${attempt}`); const initial = opts.initialDelayMs ?? DEFAULT_BACKOFF.initialDelayMs; const max = opts.maxDelayMs ?? DEFAULT_BACKOFF.maxDelayMs; const factor = opts.factor ?? DEFAULT_BACKOFF.factor; const jitter = clamp(opts.jitter ?? DEFAULT_BACKOFF.jitter, 0, 1); const base = Math.min(initial * Math.pow(factor, attempt - 1), max); const jitterAmount = base * jitter * (random() * 2 - 1); return Math.max(0, Math.round(base + jitterAmount)); } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } // src/core/transport.ts var log = createLogger("transport"); var DEFAULT_MAX_ATTEMPTS = 10; var cachedNodeWsModule = null; function loadNodeWs() { if (!cachedNodeWsModule) { cachedNodeWsModule = import('ws'); } return cachedNodeWsModule; } var Transport = class { constructor(opts) { this.opts = opts; if (opts.signal) { if (opts.signal.aborted) { this.state = "closed"; return; } opts.signal.addEventListener("abort", this.onAbort, { once: true }); } } opts; ws = null; state = "idle"; buffer = []; reconnectAttempt = 0; reconnectTimer = null; manualClose = false; onAbort = () => { log("aborted via signal"); this.destroy(); }; /** Current transport state. */ getState() { return this.state; } /** Number of messages currently held in the outbound buffer. */ getBufferedCount() { return this.buffer.length; } /** * Open the WebSocket connection. Resolves when the socket reaches the * `open` state, rejects if the initial handshake fails. * * Calling `connect()` on an already-open or already-connecting transport * is a no-op and resolves immediately. */ async connect() { if (this.state === "open" || this.state === "connecting") return; if (this.opts.signal?.aborted) { throw new TvConnectionError("Transport aborted before connect"); } this.clearReconnectTimer(); this.manualClose = false; this.state = "connecting"; log("connect() \u2192 %s", this.opts.url); let ws; try { ws = await this.createSocket(); } catch (err) { this.state = "closed"; throw new TvConnectionError("Failed to create WebSocket", { cause: err }); } this.ws = ws; await new Promise((resolve, reject) => { let settled = false; const handleOpen = () => { log("opened"); this.state = "open"; this.reconnectAttempt = 0; this.opts.onOpen?.(); this.flushBuffer(); if (!settled) { settled = true; resolve(); } }; const handleMessage = (data) => { const raw = extractMessageData(data); if (raw === null) return; this.opts.onMessage?.(raw); }; const handleError = (err) => { log("error: %s", err.message); this.opts.onError?.(err); if (!settled) { settled = true; this.state = "closed"; reject(err); } }; const handleClose = (info) => { log("closed: code=%d reason=%s wasClean=%s", info.code, info.reason, info.wasClean); this.ws = null; this.opts.onClose?.(info); if (this.manualClose) { this.state = "closed"; if (!settled) { settled = true; reject(new TvConnectionError("Closed before connection established")); } return; } if (this.shouldReconnect()) { this.scheduleReconnect(); } else { this.state = "closed"; } if (!settled) { settled = true; reject(new TvConnectionError(`Closed during connect: code=${info.code}`)); } }; attachListeners(ws, { handleOpen, handleMessage, handleError, handleClose }); }); } /** * Queue a message for sending. * * If the transport is not currently open, the message is buffered and * sent once the connection is established. The buffer has no size limit * — the caller is responsible for not flooding it in bad network * conditions. */ send(data) { const ws = this.ws; if (this.state !== "open" || !ws || ws.readyState !== 1) { log("send() buffered (state=%s, len=%d)", this.state, this.buffer.length + 1); this.buffer.push(data); return; } try { ws.send(data); } catch (err) { log("send() failed, re-buffering: %s", err.message); this.buffer.push(data); } } /** * Gracefully close the current connection. Disables reconnect — use * `connect()` again if you want to reopen. */ async close(code = 1e3, reason = "Normal closure") { if (this.state === "closed" || this.state === "idle") return; this.manualClose = true; this.clearReconnectTimer(); const ws = this.ws; if (!ws) { this.state = "closed"; return; } await new Promise((resolve) => { const done = () => { this.state = "closed"; resolve(); }; const timeout = setTimeout(done, 1e3); const onFinalClose = () => { clearTimeout(timeout); done(); }; if (isNodeSocket(ws)) { ws.once("close", onFinalClose); } else { ws.addEventListener("close", onFinalClose, { once: true }); } try { ws.close(code, reason); } catch (err) { log("close() threw: %s", err.message); clearTimeout(timeout); done(); } }); } /** * Forcefully tear down the transport. Cancels any pending reconnect, * drops the buffer, and releases event listeners. After `destroy()` * the transport cannot be reused. */ destroy() { log("destroy()"); this.manualClose = true; this.clearReconnectTimer(); this.buffer = []; this.opts.signal?.removeEventListener("abort", this.onAbort); if (this.ws) { try { this.ws.close(); } catch { } this.ws = null; } this.state = "closed"; } // ─── private ──────────────────────────────────────────────── /** * Pick the right WebSocket implementation for the current runtime. * * 1. Browser-like (`window` global + `WebSocket` global): use the * native `WebSocket` — `origin`/`headers`/`agent` cannot be set, * they're controlled by the browser. * 2. Node-like: dynamically import the `ws` package so that browser * bundlers can tree-shake it away. This gives us `origin`, * `headers`, and `agent` for proxies. * 3. Fallback: if `ws` can't be loaded but native `WebSocket` is * present (e.g. Bun, Deno, Cloudflare Workers, Node 22+), use * that — headers simply won't be set. */ async createSocket() { const globalWs = globalThis.WebSocket; const isBrowserLike = typeof globalThis.window !== "undefined"; if (isBrowserLike && globalWs) { log("createSocket: using native WebSocket (browser-like runtime)"); return new globalWs(this.opts.url); } try { const wsModule = await loadNodeWs(); log("createSocket: using ws package"); const wsOpts = {}; if (this.opts.origin) wsOpts.origin = this.opts.origin; if (this.opts.agent) wsOpts.agent = this.opts.agent; if (this.opts.headers) wsOpts.headers = this.opts.headers; return new wsModule.WebSocket(this.opts.url, wsOpts); } catch (err) { if (globalWs) { log("createSocket: ws unavailable, falling back to native WebSocket"); return new globalWs(this.opts.url); } throw new TvConnectionError("No WebSocket implementation available in this runtime", { cause: err }); } } flushBuffer() { if (this.buffer.length === 0 || !this.ws) return; const ws = this.ws; log("flushing %d buffered messages", this.buffer.length); const pending = this.buffer; this.buffer = []; for (const msg of pending) { try { ws.send(msg); } catch (err) { log("flush send failed, re-buffering: %s", err.message); this.buffer.push(msg); } } } shouldReconnect() { const r = this.opts.reconnect; if (r?.enabled === false) return false; const max = r?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; return this.reconnectAttempt < max; } scheduleReconnect() { this.reconnectAttempt += 1; const delayMs = calculateBackoff(this.reconnectAttempt, this.opts.reconnect ?? {}); log("reconnect scheduled: attempt=%d delay=%dms", this.reconnectAttempt, delayMs); this.state = "reconnecting"; this.opts.onReconnect?.({ attempt: this.reconnectAttempt, delayMs }); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect().catch((err) => { log("reconnect attempt failed: %s", err.message); }); }, delayMs); } clearReconnectTimer() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } }; function attachListeners(ws, { handleOpen, handleMessage, handleError, handleClose }) { if (isNodeSocket(ws)) { ws.on("open", handleOpen); ws.on("message", handleMessage); ws.on("error", (err) => handleError(err)); ws.on( "close", (code, reason) => handleClose({ code, reason: reason.toString("utf8"), wasClean: code === 1e3 }) ); } else { ws.onopen = () => handleOpen(); ws.onmessage = (ev) => handleMessage(ev.data); ws.onerror = () => handleError(new TvConnectionError("WebSocket error event")); ws.onclose = (ev) => handleClose({ code: ev.code, reason: ev.reason, wasClean: ev.wasClean }); } } function isNodeSocket(ws) { return typeof ws.on === "function"; } function extractMessageData(data) { if (typeof data === "string") return data; if (data instanceof Uint8Array) return new TextDecoder("utf-8").decode(data); if (data instanceof ArrayBuffer) return new TextDecoder("utf-8").decode(new Uint8Array(data)); if (Array.isArray(data)) { return data.map((part) => extractMessageData(part) ?? "").join(""); } return null; } // src/core/session-manager.ts var log2 = createLogger("session-manager"); var DEFAULT_AUTH_TOKEN = "unauthorized_user_token"; var DEFAULT_LOCALE = ["en", "US"]; var SessionManager = class { transport; rateLimit; sessions = /* @__PURE__ */ new Map(); authToken; locale; helloData = null; state = "idle"; readyWaiters = []; hasEverConnected = false; disposed = false; constructor(opts = {}) { this.rateLimit = resolveRateLimit(opts.rateLimit); this.authToken = opts.authToken ?? DEFAULT_AUTH_TOKEN; this.locale = opts.locale ?? DEFAULT_LOCALE; this.transport = new Transport({ url: opts.url ?? TV_WS_URL, origin: opts.origin ?? TV_ORIGIN, agent: opts.agent, headers: opts.headers, reconnect: opts.reconnect, signal: opts.signal, onOpen: () => this.handleTransportOpen(), onClose: (info) => this.handleTransportClose(info), onMessage: (raw) => this.handleRawMessage(raw), onReconnect: ({ attempt, delayMs }) => { log2("reconnect scheduled attempt=%d delay=%dms", attempt, delayMs); this.state = "reconnecting"; }, onError: (err) => log2("transport error: %s", err.message) }); } /** Current manager state. */ getState() { return this.state; } /** The TradingView hello packet from the most recent connection, if any. */ getHelloData() { return this.helloData; } /** Number of currently registered sessions. */ getSessionCount() { return this.sessions.size; } /** * Open the transport and wait until the TradingView hello packet is * received — only after that are session commands safe to send. */ async connect() { if (this.disposed) throw new Error("SessionManager has been disposed"); if (this.state === "ready") return; this.state = "connecting"; await this.transport.connect(); await this.waitForReady(); } /** * Gracefully close the transport and release all sessions. After * `disconnect()` the manager cannot be reused. */ async disconnect() { if (this.disposed) return; this.disposed = true; log2("disconnect(): %d active sessions", this.sessions.size); for (const session of this.sessions.values()) { session.handleDisconnect(); } this.sessions.clear(); this.rejectReadyWaiters(new Error("SessionManager disconnected")); await this.transport.close(); this.state = "closed"; } /** * Register a session so `SessionManager` can route inbound messages * to it and include it in replay after reconnect. */ registerSession(session) { if (this.disposed) throw new Error("SessionManager has been disposed"); if (this.sessions.has(session.id)) { throw new Error(`Session id collision: ${session.id}`); } this.sessions.set(session.id, session); log2("register: %s (total=%d)", session.id, this.sessions.size); } /** Remove a session from the routing table. */ unregisterSession(sessionId) { if (this.sessions.delete(sessionId)) { log2("unregister: %s (total=%d)", sessionId, this.sessions.size); } } /** Send a `{ m: method, p: params }` command over the transport. */ sendCommand(method, params) { this.transport.send(encodeCommand(method, params)); } // ─── transport wiring ─────────────────────────────────────── handleTransportOpen() { log2("transport open"); } handleTransportClose(info) { log2("transport close: code=%d", info.code); if (this.disposed) return; this.state = "reconnecting"; this.helloData = null; for (const session of this.sessions.values()) { try { session.handleDisconnect(); } catch (err) { log2("session.handleDisconnect threw: %s", err.message); } } } handleRawMessage(raw) { let frames; try { frames = decodeFrames(raw); } catch (err) { log2("decode error: %s", err.message); return; } for (const frame of frames) { switch (frame.type) { case "heartbeat": this.transport.send(encodeHeartbeat(frame.id)); break; case "hello": this.handleHello(frame.data); break; case "message": this.routeCommand(frame); break; } } } handleHello(data) { this.helloData = data; log2("hello received"); this.transport.send(encodeCommand("set_auth_token", [this.authToken])); this.transport.send(encodeCommand("set_locale", [...this.locale])); const wasReconnect = this.hasEverConnected; this.hasEverConnected = true; this.state = "ready"; if (wasReconnect) { log2("replaying %d sessions after reconnect", this.sessions.size); for (const session of this.sessions.values()) { try { session.replay(); } catch (err) { log2("session.replay threw: %s", err.message); } } } this.resolveReadyWaiters(); } routeCommand(frame) { const sessionId = frame.params[0]; if (typeof sessionId !== "string") { log2("unrouted (no session id): method=%s", frame.method); return; } const session = this.sessions.get(sessionId); if (!session) { log2("message for unknown session %s: method=%s", sessionId, frame.method); return; } try { session.handleMessage(frame.method, frame.params); } catch (err) { log2("session.handleMessage threw: %s", err.message); } } // ─── ready promise machinery ──────────────────────────────── waitForReady() { if (this.state === "ready") return Promise.resolve(); return new Promise((resolve, reject) => { this.readyWaiters.push({ resolve, reject }); }); } resolveReadyWaiters() { const waiters = this.readyWaiters; this.readyWaiters = []; for (const w of waiters) w.resolve(); } rejectReadyWaiters(err) { const waiters = this.readyWaiters; this.readyWaiters = []; for (const w of waiters) w.reject(err); } }; // src/core/rate-limiter.ts var log3 = createLogger("rate-limiter"); var SymbolBatcher = class { constructor(executor, opts = {}) { this.executor = executor; this.opts = resolveRateLimit(opts); } executor; opts; pendingAdd = /* @__PURE__ */ new Set(); pendingRemove = /* @__PURE__ */ new Set(); flushTimer = null; inFlight = null; disposed = false; /** * Queue a symbol for addition. If the same symbol has a pending * removal in the current window, the two cancel out and nothing is * dispatched. */ add(symbol) { if (this.disposed) return; if (this.pendingRemove.delete(symbol)) { log3("cancel: add(%s) cancelled pending remove", symbol); return; } this.pendingAdd.add(symbol); this.scheduleFlush(); } /** * Queue a symbol for removal. If the same symbol has a pending * addition in the current window, the two cancel out and nothing is * dispatched. */ remove(symbol) { if (this.disposed) return; if (this.pendingAdd.delete(symbol)) { log3("cancel: remove(%s) cancelled pending add", symbol); return; } this.pendingRemove.add(symbol); this.scheduleFlush(); } /** Number of pending operations (both adds and removes). */ get pendingCount() { return this.pendingAdd.size + this.pendingRemove.size; } /** * Force an immediate flush of any pending operations, bypassing the * batch window timer. Resolves once all resulting chunks have been * dispatched through the executor. */ async flushNow() { if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; } await this.flush(); } /** * Destroy the batcher. Any pending operations are dropped — the * caller is responsible for calling `flushNow()` first if that's * undesired. */ destroy() { if (this.disposed) return; this.disposed = true; if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; } this.pendingAdd.clear(); this.pendingRemove.clear(); } // ─── private ──────────────────────────────────────────────── scheduleFlush() { if (this.flushTimer || this.disposed) return; this.flushTimer = setTimeout(() => { this.flushTimer = null; void this.flush(); }, this.opts.batchWindowMs); } async flush() { if (this.inFlight) { await this.inFlight; if (!this.disposed && (this.pendingAdd.size > 0 || this.pendingRemove.size > 0)) { return this.flush(); } return; } const adds = Array.from(this.pendingAdd); const removes = Array.from(this.pendingRemove); this.pendingAdd.clear(); this.pendingRemove.clear(); if (adds.length === 0 && removes.length === 0) return; log3("flush: add=%d remove=%d", adds.length, removes.length); this.inFlight = (async () => { try { await this.dispatchChunks(adds, (chunk) => this.executor.add(chunk)); await this.dispatchChunks(removes, (chunk) => this.executor.remove(chunk)); } finally { this.inFlight = null; } })(); return this.inFlight; } async dispatchChunks(items, send) { if (items.length === 0) return; const { chunkSize, chunkIntervalMs } = this.opts; for (let i = 0; i < items.length; i += chunkSize) { if (this.disposed) return; const chunk = items.slice(i, i + chunkSize); send(chunk); if (i + chunkSize < items.length) { await sleep(chunkIntervalMs); } } } }; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // src/utils/random-id.ts var CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; function randomId(length = 12, random = Math.random) { let out = ""; for (let i = 0; i < length; i++) { out += CHARS.charAt(Math.floor(random() * CHARS.length)); } return out; } // src/sessions/quote-session.ts var log4 = createLogger("session:quote"); var QuoteSession = class { id; manager; batcher; state = /* @__PURE__ */ new Map(); fields = /* @__PURE__ */ new Set(); subscribedSymbols = /* @__PURE__ */ new Set(); loadedSymbols = /* @__PURE__ */ new Set(); disposed = false; created = false; onUpdateCb; onErrorCb; onCompleteCb; constructor(opts) { this.id = `qs_${randomId(12)}`; this.manager = opts.manager; this.onUpdateCb = opts.onUpdate; this.onErrorCb = opts.onError; this.onCompleteCb = opts.onComplete; this.batcher = new SymbolBatcher( { add: (symbols) => this.manager.sendCommand("quote_add_symbols", [this.id, ...symbols]), remove: (symbols) => this.manager.sendCommand("quote_remove_symbols", [this.id, ...symbols]) }, opts.rateLimit ?? opts.manager.rateLimit ); this.manager.registerSession(this); this.sendCreate(); } /** Replace the active field set and push the update to TradingView. */ setFields(fields) { if (this.disposed) return; this.fields.clear(); for (const f of fields) this.fields.add(f); if (this.created) { this.manager.sendCommand("quote_set_fields", [this.id, ...this.fields]); } } /** Queue one symbol for addition. */ addSymbol(symbol) { if (this.disposed || !symbol) return; if (this.subscribedSymbols.has(symbol)) return; this.subscribedSymbols.add(symbol); this.batcher.add(symbol); } /** Queue multiple symbols for addition. */ addSymbols(symbols) { for (const s of symbols) this.addSymbol(s); } /** Queue one symbol for removal. */ removeSymbol(symbol) { if (this.disposed || !symbol) return; if (!this.subscribedSymbols.delete(symbol)) return; this.loadedSymbols.delete(symbol); this.state.delete(symbol); this.batcher.remove(symbol); } /** Queue multiple symbols for removal. */ removeSymbols(symbols) { for (const s of symbols) this.removeSymbol(s); } /** All symbols currently subscribed on this session (local view). */ getSubscribedSymbols() { return Array.from(this.subscribedSymbols); } /** Accumulated quote state for a symbol, if any. */ getSnapshot(symbol) { return this.state.get(symbol); } /** Force-flush any pending add/remove operations. */ async flushPending() { await this.batcher.flushNow(); } /** * Delete the server-side session and release local resources. * Safe to call more than once. */ async delete() { if (this.disposed) return; this.disposed = true; try { await this.batcher.flushNow(); } catch { } this.batcher.destroy(); if (this.created) { this.manager.sendCommand("quote_delete_session", [this.id]); } this.manager.unregisterSession(this.id); this.state.clear(); this.subscribedSymbols.clear(); this.loadedSymbols.clear(); } // ─── Session interface ───────────────────────────────────── handleMessage(method, params) { switch (method) { case "qsd": this.handleQsd(params); break; case "quote_completed": this.handleQuoteCompleted(params); break; default: log4("ignoring unknown method %s", method); } } handleDisconnect() { this.loadedSymbols.clear(); this.created = false; } replay() { if (this.disposed) return; log4("replay %s: %d symbols", this.id, this.subscribedSymbols.size); this.sendCreate(); if (this.fields.size > 0) { this.manager.sendCommand("quote_set_fields", [this.id, ...this.fields]); } if (this.subscribedSymbols.size > 0) { this.manager.sendCommand("quote_add_symbols", [this.id, ...this.subscribedSymbols]); } } // ─── private ─────────────────────────────────────────────── sendCreate() { this.manager.sendCommand("quote_create_session", [this.id]); this.created = true; } handleQsd(params) { const payload = params[1]; if (!isQsdPayload(payload)) return; if (payload.s === "error") { this.onErrorCb?.({ symbol: payload.n, message: typeof payload.errmsg === "string" ? payload.errmsg : "Unknown error" }); return; } const prev = this.state.get(payload.n) ?? {}; const snapshot = { ...prev, ...payload.v }; this.state.set(payload.n, snapshot); const isFirstLoad = !this.loadedSymbols.has(payload.n); this.onUpdateCb?.({ symbol: payload.n, delta: payload.v, snapshot, isFirstLoad }); } handleQuoteCompleted(params) { const symbol = params[1]; if (typeof symbol !== "string") return; this.loadedSymbols.add(symbol); this.onCompleteCb?.(symbol); } }; function isQsdPayload(p) { if (typeof p !== "object" || p === null) return false; const obj = p; return typeof obj.n === "string" && (obj.s === "ok" || obj.s === "error") && typeof obj.v === "object" && obj.v !== null; } // src/types/candle.ts var TIMEFRAME_MAP = { // Raw timeframes (identity) "1": "1", "3": "3", "5": "5", "15": "15", "30": "30", "45": "45", "60": "60", "120": "120", "180": "180", "240": "240", "360": "360", "480": "480", "720": "720", "1D": "1D", "3D": "3D", "1W": "1W", "1M": "1M", "3M": "3M", "6M": "6M", "12M": "12M", // Human aliases "1m": "1", "3m": "3", "5m": "5", "15m": "15", "30m": "30", "45m": "45", "1h": "60", "2h": "120", "3h": "180", "4h": "240", "6h": "360", "8h": "480", "12h": "720", "1d": "1D", "3d": "3D", "1w": "1W" }; function normalizeTimeframe(tf) { const out = TIMEFRAME_MAP[tf]; if (!out) { throw new TvError(`Unknown timeframe: "${tf}"`); } return out; } var TIMEFRAME_ALIASES = Object.keys(TIMEFRAME_MAP); // src/sessions/chart-session.ts var log5 = createLogger("session:chart"); var ChartSession = class { id; manager; series = /* @__PURE__ */ new Map(); // by seriesId seriesBySymbolKey = /* @__PURE__ */ new Map(); // by symbolKey nextSymbolSeq = 1; nextSeriesSeq = 1; created = false; disposed = false; onCandlesCb; onTickCb; onErrorCb; // One-shot helpers — see `resolvePair()` and `fetchCandlesOnce()`. pendingResolves = /* @__PURE__ */ new Map(); // by symbolKey pendingCandles = /* @__PURE__ */ new Map(); // by seriesId constructor(opts) { this.id = `cs_${randomId(12)}`; this.manager = opts.manager; this.onCandlesCb = opts.onCandles; this.onTickCb = opts.onTick; this.onErrorCb = opts.onError; this.manager.registerSession(this); this.sendCreate(); } /** * Request a series of bars for a symbol. The `onCandles` callback is * invoked once the initial batch arrives; subsequent bar updates come * through `onTick`. * * Returns the generated `seriesId`, which can be used with * `requestMore()` to fetch additional historical bars. */ requestSeries(request) { if (this.disposed) throw new Error("ChartSession has been disposed"); const rawTimeframe = normalizeTimeframe(request.timeframe); const symbolKey = `sym_${this.nextSymbolSeq++}`; const seriesId = `sds_${this.nextSeriesSeq++}`; const internal = { seriesId, symbolKey, request, rawTimeframe, resolved: false, initialLoaded: false }; this.series.set(seriesId, internal); this.seriesBySymbolKey.set(symbolKey, internal); this.manager.sendCommand("resolve_symbol", [ this.id, symbolKey, `={"symbol":"${request.symbol}","adjustment":"splits"}` ]); this.manager.sendCommand("create_series", [ this.id, seriesId, seriesId, symbolKey, rawTimeframe, request.barCount, "" ]); log5("requestSeries %s \u2192 %s %s x%d", request.symbol, seriesId, rawTimeframe, request.barCount); return seriesId; } /** Request additional historical bars for an existing series. */ requestMore(seriesId, additionalBars) { if (this.disposed) return; const s = this.series.get(seriesId); if (!s) { log5("requestMore: unknown seriesId %s", seriesId); return; } this.manager.sendCommand("request_more_data", [this.id, seriesId, additionalBars]); } /** Remove a series (stop receiving updates for it). */ removeSeries(seriesId) { if (this.disposed) return; const s = this.series.get(seriesId); if (!s) return; this.manager.sendCommand("remove_series", [this.id, seriesId]); this.series.delete(seriesId); this.seriesBySymbolKey.delete(s.symbolKey); } /** All active series keyed by `seriesId`. */ getSeries() { const out = /* @__PURE__ */ new Map(); for (const [id, s] of this.series) { out.set(id, { symbol: s.request.symbol, timeframe: s.rawTimeframe }); } return out; } /** * Promise-based helper: resolve a symbol without creating a series. * Returns the raw `symbol_resolved` payload from TradingView. * * Useful for fetching symbol metadata (description, exchange, type, * session hours, etc.) without paying for a candle subscription. */ async resolvePair(pair, timeoutMs = 5e3) { if (this.disposed) throw new TvError("ChartSession has been disposed"); const symbolKey = `sym_${this.nextSymbolSeq++}`; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingResolves.delete(symbolKey); reject(new TvTimeoutError(`resolvePair(${pair})`, timeoutMs)); }, timeoutMs); this.pendingResolves.set(symbolKey, { pair, resolve: (info) => { clearTimeout(timer); resolve(info); }, reject: (err) => { clearTimeout(timer); reject(err); } }); this.manager.sendCommand("resolve_symbol", [ this.id, symbolKey, `={"symbol":"${pair}","adjustment":"splits"}` ]); }); } /** * Promise-based helper: fetch a historical candle window in one call. * Creates a temporary series, waits for the initial backfill, then * removes the series and returns the candles. */ async fetchCandlesOnce(symbol, opts, timeoutMs = 15e3) { if (this.disposed) throw new TvError("ChartSession has been disposed"); return new Promise((resolve, reject) => { let seriesId; const timer = setTimeout(() => { this.pendingCandles.delete(seriesId); reject(new TvTimeoutError(`fetchCandlesOnce(${symbol})`, timeoutMs)); }, timeoutMs); try { seriesId = this.requestSeries({ symbol, timeframe: opts.timeframe, barCount: opts.barCount }); } catch (err) { clearTimeout(timer); reject(err); return; } this.pendingCandles.set(seriesId, { symbol, resolve: (candles) => { clearTimeout(timer); this.removeSeries(seriesId); resolve(candles); }, reject: (err) => { clearTimeout(timer); this.removeSeries(seriesId); reject(err); } }); }); } /** Close the chart session on the server and release local state. */ async delete() { if (this.disposed) return; this.disposed = true; for (const p of this.pendingResolves.values()) { p.reject(new TvError("ChartSession deleted before resolve")); } this.pendingResolves.clear(); for (const p of this.pendingCandles.values()) { p.reject(new TvError("ChartSession deleted before candles delivered")); } this.pendingCandles.clear(); if (this.created) { this.manager.sendCommand("chart_delete_session", [this.id]); } this.manager.unregisterSession(this.id); this.series.clear(); this.seriesBySymbolKey.clear(); } // ─── Session interface ───────────────────────────────────── handleMessage(method, params) { switch (method) { case "symbol_resolved": this.handleSymbolResolved(params); break; case "symbol_error": this.handleSymbolError(params); break; case "series_loading": break; case "series_completed": break; case "series_error": this.handleSeriesError(params); break; case "timescale_update": case "du": this.handleSeriesData(method, params); break; default: log5("ignoring unknown method %s", method); } } handleDisconnect() { this.created = false; for (const s of this.series.values()) { s.resolved = false; s.initialLoaded = false; } } replay() { if (this.disposed) return; log5("replay %s: %d series", this.id, this.series.size); this.sendCreate(); for (const s of this.series.values()) { this.manager.sendCommand("resolve_symbol", [ this.id, s.symbolKey, `={"symbol":"${s.request.symbol}","adjustment":"splits"}` ]); this.manager.sendCommand("create_series", [ this.id, s.seriesId, s.seriesId, s.symbolKey, s.rawTimeframe, s.request.barCount, "" ]); } } // ─── private ─────────────────────────────────────────────── sendCreate() { this.manager.sendCommand("chart_create_session", [this.id]); this.created = true; } handleSymbolResolved(params) { const symbolKey = params[1]; if (typeof symbolKey !== "string") return; const pending = this.pendingResolves.get(symbolKey); if (pending) { this.pendingResolves.delete(symbolKey); const info = params[2]; if (typeof info === "object" && info !== null) { pending.resolve(info); } else { pending.reject(new TvError(`Invalid symbol_resolved payload for ${pending.pair}`)); } return; } const series = this.seriesBySymbolKey.get(symbolKey); if (!series) return; series.resolved = true; } handleSymbolError(params) { const symbolKey = params[1]; if (typeof symbolKey !== "string") return; const reason = typeof params[2] === "string" ? params[2] : "symbol_error"; const pending = this.pendingResolves.get(symbolKey); if (pending) { this.pendingResolves.delete(symbolKey); pending.reject(new TvSymbolError(pending.pair, reason)); return; } const series = this.seriesBySymbolKey.get(symbolKey); if (!series) return; this.onErrorCb?.(new TvSymbolError(series.request.symbol, reason)); const pendingCandles = this.pendingCandles.get(series.seriesId); if (pendingCandles) { this.pendingCandles.delete(series.seriesId); pendingCandles.reject(new TvSymbolError(series.request.symbol, reason)); } this.series.delete(series.seriesId); this.seriesBySymbolKey.delete(symbolKey); } handleSeriesError(params) { const seriesId = params[1]; if (typeof seriesId !== "string") return; const series = this.series.get(seriesId); if (!series) return; const reason = typeof params[2] === "string" ? params[2] : "series_error"; this.onErrorCb?.(new TvSymbolError(series.request.symbol, reason)); this.series.delete(seriesId); this.seriesBySymbolKey.delete(series.symbolKey); } /** * Handle `timescale_update` and `du` (data update). Both carry a * payload of the form: * * { sds_1: { s: [ { i, v: [time, open, high, low, close, volume] }, ... ] } } * * `timescale_update` is typically the initial historical dump; * subsequent `du` updates usually contain only the last tick. */ handleSeriesData(method, params) { const payload = params[1]; if (typeof payload !== "object" || payload === null) return; for (const [seriesId, value] of Object.entries(payload)) { const series = this.series.get(seriesId); if (!series) continue; const candles = extractCandles(value); if (candles.length === 0) continue; const isBackfill = method === "timescale_update" || candles.length > 1; if (isBackfill) { const pending = this.pendingCandles.get(seriesId); if (pending) { this.pendingCandles.delete(seriesId); pending.resolve(candles); continue; } } if (isBackfill && !series.initialLoaded) { series.initialLoaded = true; this.onCandlesCb?.({ seriesId, symbol: series.request.symbol, candles }); } else { for (const candle of candles) { this.onTickCb?.({ seriesId, symbol: series.request.symbol, candle }); } } } } }; function extractCandles(value) { if (typeof value !== "object" || value === null) return []; const s = value.s; if (!Array.isArray(s)) return []; const out = []; for (const entry of s) { if (typeof entry !== "object" || entry === null) continue; const v = entry.v; if (!Array.isArray(v) || v.length < 6) continue; const [time, open, high, low, close, volume] = v; if (typeof time !== "number" || typeof open !== "number" || typeof high !== "number" || typeof low !== "number" || typeof close !== "number" || typeof volume !== "number") { continue; } out.push({ time, open, high, low, close, volume }); } return out; } // src/utils/kebab-to-camel.ts function kebabToCamel(key) { if (key.length === 0) return key; return key.replace(/[-_]+([a-zA-Z0-9])/g, (_, char) => char.toUpperCase()); } function transformKeys(obj) { const out = {}; for (const [key, value] of Object.entries(obj)) { out[kebabToCamel(key)] = value; } return out; } // src/types/symbol-info.ts function symbolInfoFromRaw(raw) { return transformKeys(raw); } // src/api/stream.ts var log6 = createLogger("stream"); var Stream = class { constructor(tvSymbol, fields) { this.tvSymbol = tvSymbol; this.fields = fields; } tvSymbol; fields; listeners = { update: /* @__PURE__ */ new Set(), price: /* @__PURE__ */ new Set(), change: /* @__PURE__ */ new Set(), bar: /* @__PURE__ */ new Set(), error: /* @__PURE__ */ new Set() }; closed = false; iteratorQueue = []; iteratorResolve = null; /** Register a listener for a specific stream event. */ on(event, handler) { this.listeners[event].add(handler); return this; } /** Remove a previously registered listener. */ off(event, handler) { this.listeners[event].delete(handler); return this; } /** Close the stream and release its resources. */ close() { if (this.closed) return; this.closed = true; this.tvSymbol._removeStream(this); for (const event of Object.keys(this.listeners)) { this.listeners[event].clear(); } this.flushIteratorOnClose(); } [Symbol.dispose]() { this.close(); } /** * Async iterator interface — iterate over `update` events with * `for await`. Breaking out of the loop automatically closes the * stream. */ [Symbol.asyncIterator]() { return { next: () => { if (this.closed && this.iteratorQueue.length === 0) { return Promise.resolve({ value: void 0, done: true }); } const queued = this.iteratorQueue.shift(); if (queued !== void 0) { return Promise.resolve({ value: queued, done: false }); } return new Promise((resolve) => { this.iteratorResolve = resolve; }); }, return: () => { this.close(); return Promise.resolve({ value: void 0, done: true }); } }; } // ─── Internal dispatch API (called by TvSymbol) ───────────── /** @internal */ _dispatch(u, snapshot) { if (this.closed) return; const updateEvent = { symbol: u.symbol, data: { ...snapshot } }; this.emit("update", updateEvent); this.pushToIterator(updateEvent); const delta = u.delta; if ("lp" in delta && typeof delta.lp === "number") { this.emit("price", { price: delta.lp }); } if ("ch" in delta || "chp" in delta) { const snap = snapshot; if (typeof snap.ch === "number" && typeof snap.chp === "number") { this.emit("change", { value: snap.ch, percent: snap.chp }); } } if ("minute-bar" in delta) { this.emit("bar", { bar: delta["minute-bar"] }); } else if ("daily-bar" in delta) { this.emit("bar", { bar: delta["daily-bar"] }); } else if ("trade" in delta) { this.emit("bar", { bar: delta.trade }); } } /** @internal */ _dispatchError(err) { if (this.closed) return; this.emit("error", err); } /** @internal — used by TvSymbol when the parent client shuts down. */ _closeFromSymbol() { if (this.closed) return; this.closed = true; for (const event of Object.keys(this.listeners)) { this.listeners[event].clear(); } this.flushIteratorOnClose(); } // ─── private ──────────────────────────────────────────────── emit(event, data) { for (const listener of this.listeners[event]) { try { listener(data); } catch (err) { log6("listener for %s threw: %s", event, err.message); } } } pushToIterator(event) { if (this.iteratorResolve) { const resolve = this.iteratorResolve; this.iteratorResolve = null; resolve({ value: event, done: false }); } else { this.iteratorQueue.push(event); } } flushIteratorOnClose() { if (this.iteratorResolve) { const resolve = this.iteratorResolve; this.iteratorResolve = null; resolve({ value: void 0, done: true }); } } }; // src/api/symbol.ts var log7 = createLogger("symbol"); var DEFAULT_STREAM_FIELDS = [ "lp", "bid", "ask", "ch", "chp", "volume" ]; var LOAD_TIMEOUT_MS = 1e4; var TvSymbol = class { constructor(client, pair) { this.client = client; this.pair = pair; } client; pair; streams = /* @__PURE__ */ new Set(); snapshotState = {}; fields = /* @__PURE__ */ new Set(); loaded = false; loadWaiters = []; subscribed = false; disposed = false; /** The last traded price, resolving as soon as it is available. */ async price() { this.assertAlive(); if (typeof this.snapshotState.lp === "number") { return this.snapshotState.lp; } const snap = await this.snapshot(["lp"]); if (snap.lp === void 0 || snap.lp === null) { throw new TvError(`No price available for ${this.pair}`); } return snap.lp; } async snapshot(fields) { this.assertAlive(); const targetFields = fields ?? DEFAULT_STREAM_FIELDS; await this.ensureSubscribed(targetFields); await this.waitForLoad(); if (!fields) { return { ...this.snapshotState }; } const out = {}; for (const f of fields) { if (this.snapshotState[f] !== void 0) { out[f] = this.snapshotState[f]; } } return out; } /** Fetch full symbol metadata via a one-shot chart session resolve. */ async info() { this.assertAlive(); await this.client.manager.connect(); const cs = new ChartSession({ manager: this.client.manager }); try { const raw = await cs.resolvePair(this.pair); return symbolInfoFromRaw(raw); } finally { try { await cs.delete(); } catch (err) { log7("chart session delete failed: %s", err.message); } } } /** Fetch historical candles via a one-shot chart session. */ async candles(opts) { this.assertAlive(); if (opts.count <= 0) { throw new TvError("candles: count must be > 0"); } await this.client.manager.connect(); const cs = new ChartSession({ manager: this.client.manager }); try { return await cs.fetchCandlesOnce(this.pair, { timeframe: opts.timeframe, barCount: opts.count }); } finally { try { await cs.delete(); } catch (err) { log7("chart session delete failed: %s", err.message); } } } /** Open a live quote stream for this symbol. */ stream(fields = DEFAULT_STREAM_FIELDS) { this.assertAlive(); const stream = new Stream(this, fields); this.streams.add(stream); void this.ensureSubscribed(fields).catch((err) => { stream._dispatchError(err); }); return stream; } /** Current list of fields this symbol is subscribed to. */ get subscribedFields() { return Array.from(this.fields); } // ─── Internal API used by Client and Stream ───────────────── /** @internal */ _onUpdate(update) { Object.assign(this.snapshotState, update.delta); for (const stream of this.streams) { stream._dispatch(update, this.snapshotState); } } /** @internal */ _onComplete() { this.loaded = true; const waiters = this.loadWaiters; this.loadWaiters = []; for (const w of waiters) w(); } /** @internal */ _onError(info) { const err = new TvSymbolError(info.symbol, info.message); for (const stream of this.streams) { stream._dispatchError(err); } const waiters = this.loadWaiters; this.loadWaiters = []; for (const w of waiters) w(err); } /** @internal */ _removeStream(stream) { this.streams.delete(stream);