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
JavaScript
'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);