From 1452de5fb24f2a1c6d08db28c36a259a54ebcd64 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Thu, 7 Oct 2021 21:40:44 +0200 Subject: [PATCH 01/16] Starting dev + Testing things --- .eslintrc.js | 2 + OLD.js | 505 ++++++++++++++++++++++++ main.js | 508 +------------------------ package.json | 1 + src/client.js | 274 +++++++++++++ miscRequests.js => src/miscRequests.js | 0 src/quote/market.js | 124 ++++++ src/quote/session.js | 115 ++++++ src/types.js | 5 + src/utils.js | 8 + test.js | 34 ++ 11 files changed, 1073 insertions(+), 503 deletions(-) create mode 100644 OLD.js create mode 100644 src/client.js rename miscRequests.js => src/miscRequests.js (100%) create mode 100644 src/quote/market.js create mode 100644 src/quote/session.js create mode 100644 src/types.js create mode 100644 src/utils.js create mode 100644 test.js diff --git a/.eslintrc.js b/.eslintrc.js index 79fa088..1544d05 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,8 +7,10 @@ module.exports = { extends: [ 'airbnb-base', ], + parser: '@babel/eslint-parser', parserOptions: { ecmaVersion: 12, + requireConfigFile: false, }, rules: { 'no-console': 'off', diff --git a/OLD.js b/OLD.js new file mode 100644 index 0000000..7921bcf --- /dev/null +++ b/OLD.js @@ -0,0 +1,505 @@ +const WebSocket = require('ws'); +const JSZip = require('jszip'); + +const { + search, getScreener, getTA, getIndicator, + getUser, getChartToken, getDrawings, searchIndicator, +} = require('./src/miscRequests'); + +let onPacket = () => null; + +function parse(str) { + const packets = str.replace(/~h~/g, '').split(/~m~[0-9]{1,}~m~/g).map((p) => { + if (!p) return false; + try { + return JSON.parse(p); + } catch (error) { + console.warn('Cant parse', p); + return false; + } + }).filter((p) => p); + + packets.forEach((packet) => { + if (packet.m === 'protocol_error') { + return onPacket({ + type: 'error', + syntax: packet.p[0], + }); + } + + if (packet.m && packet.p) { + return onPacket({ + type: packet.m, + session: packet.p[0], + data: packet.p[1], + }); + } + + if (typeof packet === 'number') return onPacket({ type: 'ping', ping: packet }); + + return onPacket({ type: 'info', ...packet }); + }); +} + +function genSession() { + let r = ''; + const c = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 12; i += 1) r += c.charAt(Math.floor(Math.random() * c.length)); + return `qs_${r}`; +} + +module.exports = (autoInit = true) => { + const callbacks = { + connected: [], + disconnected: [], + logged: [], + subscribed: [], + ping: [], + price: [], + data: [], + + error: [], + event: [], + }; + + const chartEventNames = [ + 'du', 'timescale_update', + 'series_loading', 'series_completed', 'series_error', + 'symbol_resolved', 'symbol_error', + 'study_loading', 'study_error', + ]; + const chartCBs = {}; + + function handleEvent(ev, ...data) { + callbacks[ev].forEach((e) => e(...data)); + callbacks.event.forEach((e) => e(ev, ...data)); + } + + function handleError(...msgs) { + if (callbacks.error.length === 0) console.error(...msgs); + else handleEvent('error', ...msgs); + } + + const ws = new WebSocket('wss://widgetdata.tradingview.com/socket.io/websocket', { + origin: 'https://s.tradingview.com', + }); + + let logged = false; + /** ID of the quote session */ + let sessionId = ''; + + /** List of subscribed symbols */ + let subscribed = []; + + /** Websockets status */ + let isEnded = false; + + /** + * Send a custom packet + * @param {string} t Packet type + * @param {string[]} p Packet data + * @example + * // Subscribe manualy to BTCEUR + * send('quote_add_symbols', [sessionId, 'BTCEUR']); + */ + function send(t, p = []) { + if (!sessionId) return; + + const msg = JSON.stringify({ m: t, p }); + ws.send(`~m~${msg.length}~m~${msg}`); + } + + ws.on('open', () => { + sessionId = genSession(); + handleEvent('connected'); + }); + + ws.on('close', () => { + logged = false; + sessionId = ''; + handleEvent('disconnected'); + }); + + ws.on('message', parse); + + onPacket = (packet) => { + if (packet.type === 'ping') { + const pingStr = `~h~${packet.ping}`; + ws.send(`~m~${pingStr.length}~m~${pingStr}`); + handleEvent('ping', packet.ping); + return; + } + + if (packet.type === 'quote_completed' && packet.data) { + handleEvent('subscribed', packet.data); + return; + } + + if (packet.type === 'qsd' && packet.data.n && packet.data.v.lp) { + handleEvent('price', { + symbol: packet.data.n, + price: packet.data.v.lp, + }); + + return; + } + + if (chartEventNames.includes(packet.type) && chartCBs[packet.session]) { + chartCBs[packet.session](packet); + return; + } + + if (!logged && packet.type === 'info') { + if (autoInit) { + send('set_auth_token', ['unauthorized_user_token']); + send('quote_create_session', [sessionId]); + send('quote_set_fields', [sessionId, 'lp']); + + subscribed.forEach((symbol) => send('quote_add_symbols', [sessionId, symbol])); + } + + handleEvent('logged', packet); + return; + } + + if (packet.type === 'error') { + handleError(`Market API critical error: ${packet.syntax}`); + ws.close(); + return; + } + + handleEvent('data', packet); + }; + + return { + /** Event listener + * @param { 'connected' | 'disconnected' | 'logged' + * | 'subscribed' | 'price' | 'data' | 'error' | 'ping' } event Event + * @param {(...data: object) => null} cb Callback + */ + on(event, cb) { + if (!callbacks[event]) { + console.log('Wrong event:', event); + console.log('Available events:', Object.keys(callbacks)); + return; + } + + callbacks[event].push(cb); + }, + + /** + * Close the websocket connection + * @return {Promise} When websocket is closed + */ + end() { + return new Promise((cb) => { + isEnded = true; + ws.close(); + cb(); + }); + }, + + search, + getScreener, + getTA, + subscribed, + searchIndicator, + getUser, + getChartToken, + getDrawings, + + /** + * Unsubscribe to a market + * @param {string} symbol Market symbol (Example: BTCEUR or COINBASE:BTCEUR) + */ + subscribe(symbol) { + if (subscribed.includes(symbol)) return; + send('quote_add_symbols', [sessionId, symbol]); + subscribed.push(symbol); + }, + + /** + * Unsubscribe from a market + * @param {string} symbol Market symbol (Example: BTCEUR or COINBASE:BTCEUR) + */ + unsubscribe(symbol) { + if (!subscribed.includes(symbol)) return; + send('quote_remove_symbols', [sessionId, symbol]); + subscribed = subscribed.filter((s) => s !== symbol); + }, + + /** + * @typedef {Object} IndicatorInfos Indicator infos + * @property {string} id ID of the indicator (Like: XXX;XXXXXXXXXXXXXXXXXXXXX) + * @property {string} [name] Name of the indicator + * @property {'last' | string} [version] Wanted version of the indicator + * @property {(string | number | boolean | null)[]} [settings] Indicator settings value + * @property {'study' | 'strategy'} [type] Script type + * + * @typedef {Object} ChartInfos + * @property {string} [session] User 'sessionid' cookie + * @property {string} symbol Market symbol (Example: BTCEUR or COINBASE:BTCEUR) + * @property { '1' | '3' | '5' | '15' | '30' | '45' + * | '60' | '120' | '180' | '240' + * | '1D' | '1W' | '1M' + * } [period] Period + * @property {number} [range] Number of loaded periods + * @property {string} [timezone] Timezone in 'Europe/Paris' format + * @property {IndicatorInfos[]} [indicators] List of indicators + */ + + /** + * @typedef {Object} Period List of prices / indicator values + * @property {number} $time + * @property {{ + * time: number, open: number, close: number, + * max: number, min: number, change: number, + * }} $prices + */ + + /** + * Init a chart instance + * @param {ChartInfos} chart + * @param {{(prices: Period[], strategies: Object): null}} onUpdate + */ + async initChart(chart, onUpdate) { + const chartSession = genSession(); + const periods = []; + + /** + * @typedef {Object} RelAbsValue Relative and Absolute values + * @property {number} v Absolute value + * @property {number} p Relative value + */ + + /** + * @typedef {Object} TradeReport Trade report + + * @property {Object} entry Trade entry + * @property {string} entry.name Trade name + * @property {'long' | 'short'} entry.type Entry type (long/short) + * @property {number} entry.value Entry price value + * @property {number} entry.time Entry timestamp + + * @property {Object} exit Trade exit + * @property {'' | string} exit.name Trade name ('' if false exit) + * @property {number} exit.value Exit price value + * @property {number} exit.time Exit timestamp + + * @property {number} quantity Trade quantity + * @property {RelAbsValue} profit Trade profit + * @property {RelAbsValue} cumulative Trade cummulative profit + * @property {RelAbsValue} runup Trade run-up + * @property {RelAbsValue} drawdown Trade drawdown + */ + + /** + * @typedef {Object} PerfReport + * @property {number} avgBarsInTrade Average bars in trade + * @property {number} avgBarsInWinTrade Average bars in winning trade + * @property {number} avgBarsInLossTrade Average bars in losing trade + * @property {number} avgTrade Average trade gain + * @property {number} avgTradePercent Average trade performace + * @property {number} avgLosTrade Average losing trade gain + * @property {number} avgLosTradePercent Average losing trade performace + * @property {number} avgWinTrade Average winning trade gain + * @property {number} avgWinTradePercent Average winning trade performace + * @property {number} commissionPaid Commission paid + * @property {number} grossLoss Gross loss value + * @property {number} grossLossPercent Gross loss percent + * @property {number} grossProfit Gross profit + * @property {number} grossProfitPercent Gross profit percent + * @property {number} largestLosTrade Largest losing trade gain + * @property {number} largestLosTradePercent Largent losing trade performance + * @property {number} largestWinTrade Largest winning trade gain + * @property {number} largestWinTradePercent Largest winning trade performance + * @property {number} marginCalls Margin calls + * @property {number} maxContractsHeld Max Contracts Held + * @property {number} netProfit Net profit + * @property {number} netProfitPercent Net performance + * @property {number} numberOfLosingTrades Number of losing trades + * @property {number} numberOfWiningTrades Number of winning trades + * @property {number} percentProfitable Strategy winrate + * @property {number} profitFactor Profit factor + * @property {number} ratioAvgWinAvgLoss Ratio Average Win / Average Loss + * @property {number} totalOpenTrades Total open trades + * @property {number} totalTrades Total trades + */ + + /** + * @typedef {Object} StrategyReport + * @property {'EUR' | 'USD' | 'JPY' | '' | 'CHF'} currency Selected currency + * @property {TradeReport[]} trades Trade list + * @property {Object} history History Chart value + * @property {number[]} history.buyHold Buy hold values + * @property {number[]} history.buyHoldPercent Buy hold percent values + * @property {number[]} history.drawDown Drawdown values + * @property {number[]} history.drawDownPercent Drawdown percent values + * @property {number[]} history.equity Equity values + * @property {number[]} history.equityPercent Equity percent values + * @property {Object} performance Strategy performance + * @property {PerfReport} performance.all Strategy long/short performances + * @property {PerfReport} performance.long Strategy long performances + * @property {PerfReport} performance.short Strategy short performances + * @property {number} performance.buyHoldReturn Strategy Buy & Hold Return + * @property {number} performance.buyHoldReturnPercent Strategy Buy & Hold Return percent + * @property {number} performance.maxDrawDown Strategy max drawdown + * @property {number} performance.maxDrawDownPercent Strategy max drawdown percent + * @property {number} performance.openPL Strategy Open P&L (Profit And Loss) + * @property {number} performance.openPLPercent Strategy Open P&L (Profit And Loss) percent + * @property {number} performance.sharpeRatio Strategy Sharpe Ratio + * @property {number} performance.sortinoRatio Strategy Sortino Ratio + */ + + /** @type {Object} Strategies */ + const strategies = {}; + + const indicators = await Promise.all( + (chart.indicators || []).map((i) => getIndicator(i.id, i.version, i.settings, i.type)), + ); + + async function updatePeriods(packet) { + const newData = packet.data; + + await Promise.all(Object.keys(newData).map(async (type) => { + const std = chart.indicators[parseInt(type, 10)] || {}; + + if (newData[type].ns && newData[type].ns.d) { + const stratData = JSON.parse(newData[type].ns.d); + + if (stratData.dataCompressed) { + const zip = new JSZip(); + const data = JSON.parse( + await ( + await zip.loadAsync(stratData.dataCompressed, { base64: true }) + ).file('').async('text'), + ); + + strategies[std.name || type] = { + currency: data.report.currency, + + trades: data.report.trades.map((t) => ({ + entry: { + name: t.e.c, + type: (t.e.tp[0] === 's' ? 'short' : 'long'), + value: t.e.p, + time: t.e.tm, + }, + exit: { + name: t.x.c, + value: t.x.p, + time: t.x.tm, + }, + quantity: t.q, + profit: t.tp, + cumulative: t.cp, + runup: t.rn, + drawdown: t.dd, + })), + + history: { + buyHold: data.report.buyHold, + buyHoldPercent: data.report.buyHoldPercent, + drawDown: data.report.drawDown, + drawDownPercent: data.report.drawDownPercent, + equity: data.report.equity, + equityPercent: data.report.equityPercent, + }, + + performance: data.report.performance, + }; + return; + } + + if (stratData.data && stratData.data.report && stratData.data.report.performance) { + if (!strategies[std.name || type]) strategies[std.name || type] = { performance: {} }; + strategies[std.name || type].performance = stratData.data.report.performance; + return; + } + + return; + } + + (newData[type].s || newData[type].st || []).forEach((p) => { + if (!periods[p.i]) periods[p.i] = {}; + + if (newData[type].s) { + [periods[p.i].$time] = p.v; + + periods[p.i][type] = { + open: p.v[1], + close: p.v[4], + max: p.v[2], + min: p.v[3], + change: Math.round(p.v[5] * 100) / 100, + }; + } + + if (newData[type].st) { + const period = {}; + const indicator = indicators[parseInt(type, 10)]; + + p.v.forEach((val, i) => { + if (i === 0) return; + if (indicator.plots[`plot_${i - 1}`] && !period[indicator.plots[`plot_${i - 1}`]]) { + period[indicator.plots[`plot_${i - 1}`]] = val; + } else period[`_plot_${i - 1}`] = val; + }); + periods[p.i][chart.indicators[parseInt(type, 10)].name || `st${type}`] = period; + } + }); + })); + } + + chartCBs[chartSession] = async (packet) => { + if (isEnded) return; + + if (['timescale_update', 'du'].includes(packet.type)) { + await updatePeriods(packet); + if (!isEnded) onUpdate([...periods].reverse(), strategies); + return; + } + + if (packet.type.endsWith('_error')) { + handleError(`Error on '${chart.symbol}' (${chartSession}) chart: "${packet.type}":`, packet); + } + }; + + if (chart.session) send('set_auth_token', [(await getUser(chart.session)).authToken]); + send('chart_create_session', [chartSession, '']); + if (chart.timezone) send('switch_timezone', [chartSession, chart.timezone]); + send('resolve_symbol', [chartSession, 'sds_sym_1', `={"symbol":"${chart.symbol || 'BTCEUR'}","adjustment":"splits"}`]); + send('create_series', [chartSession, '$prices', 's1', 'sds_sym_1', (chart.period || '240'), (chart.range || 100), '']); + + indicators.forEach(async (indicator, i) => { + const pineInfos = { + pineId: indicator.pineId, + pineVersion: indicator.pineVersion, + text: indicator.script, + }; + + Object.keys(indicator.inputs).forEach((inputID, inp) => { + const input = indicator.inputs[inputID]; + if (input.type === 'bool' && typeof input.value !== 'boolean') handleError(`Input '${input.name}' (${inp}) must be a boolean !`); + if (input.type === 'integer' && typeof input.value !== 'number') handleError(`Input '${input.name}' (${inp}) must be a number !`); + if (input.type === 'float' && typeof input.value !== 'number') handleError(`Input '${input.name}' (${inp}) must be a number !`); + if (input.type === 'text' && typeof input.value !== 'string') handleError(`Input '${input.name}' (${inp}) must be a string !`); + if (input.options && !input.options.includes(input.value)) { + handleError(`Input '${input.name}' (${inp}) must be one of these values:`, input.options); + } + + pineInfos[inputID] = { + v: input.value, + f: input.isFake, + t: input.type, + }; + }); + + send('create_study', [chartSession, `${i}`, 'st1', '$prices', indicator.typeID, pineInfos]); + }); + }, + + send, + sessionId, + }; +}; diff --git a/main.js b/main.js index 80de04c..5dc95a5 100644 --- a/main.js +++ b/main.js @@ -1,505 +1,7 @@ -const WebSocket = require('ws'); -const JSZip = require('jszip'); +const miscRequests = require('./src/miscRequests'); +const Client = require('./src/client'); -const { - search, getScreener, getTA, getIndicator, - getUser, getChartToken, getDrawings, searchIndicator, -} = require('./miscRequests'); - -let onPacket = () => null; - -function parse(str) { - const packets = str.replace(/~h~/g, '').split(/~m~[0-9]{1,}~m~/g).map((p) => { - if (!p) return false; - try { - return JSON.parse(p); - } catch (error) { - console.warn('Cant parse', p); - return false; - } - }).filter((p) => p); - - packets.forEach((packet) => { - if (packet.m === 'protocol_error') { - return onPacket({ - type: 'error', - syntax: packet.p[0], - }); - } - - if (packet.m && packet.p) { - return onPacket({ - type: packet.m, - session: packet.p[0], - data: packet.p[1], - }); - } - - if (typeof packet === 'number') return onPacket({ type: 'ping', ping: packet }); - - return onPacket({ type: 'info', ...packet }); - }); -} - -function genSession() { - let r = ''; - const c = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - for (let i = 0; i < 12; i += 1) r += c.charAt(Math.floor(Math.random() * c.length)); - return `qs_${r}`; -} - -module.exports = (autoInit = true) => { - const callbacks = { - connected: [], - disconnected: [], - logged: [], - subscribed: [], - ping: [], - price: [], - data: [], - - error: [], - event: [], - }; - - const chartEventNames = [ - 'du', 'timescale_update', - 'series_loading', 'series_completed', 'series_error', - 'symbol_resolved', 'symbol_error', - 'study_loading', 'study_error', - ]; - const chartCBs = {}; - - function handleEvent(ev, ...data) { - callbacks[ev].forEach((e) => e(...data)); - callbacks.event.forEach((e) => e(ev, ...data)); - } - - function handleError(...msgs) { - if (callbacks.error.length === 0) console.error(...msgs); - else handleEvent('error', ...msgs); - } - - const ws = new WebSocket('wss://widgetdata.tradingview.com/socket.io/websocket', { - origin: 'https://s.tradingview.com', - }); - - let logged = false; - /** ID of the quote session */ - let sessionId = ''; - - /** List of subscribed symbols */ - let subscribed = []; - - /** Websockets status */ - let isEnded = false; - - /** - * Send a custom packet - * @param {string} t Packet type - * @param {string[]} p Packet data - * @example - * // Subscribe manualy to BTCEUR - * send('quote_add_symbols', [sessionId, 'BTCEUR']); - */ - function send(t, p = []) { - if (!sessionId) return; - - const msg = JSON.stringify({ m: t, p }); - ws.send(`~m~${msg.length}~m~${msg}`); - } - - ws.on('open', () => { - sessionId = genSession(); - handleEvent('connected'); - }); - - ws.on('close', () => { - logged = false; - sessionId = ''; - handleEvent('disconnected'); - }); - - ws.on('message', parse); - - onPacket = (packet) => { - if (packet.type === 'ping') { - const pingStr = `~h~${packet.ping}`; - ws.send(`~m~${pingStr.length}~m~${pingStr}`); - handleEvent('ping', packet.ping); - return; - } - - if (packet.type === 'quote_completed' && packet.data) { - handleEvent('subscribed', packet.data); - return; - } - - if (packet.type === 'qsd' && packet.data.n && packet.data.v.lp) { - handleEvent('price', { - symbol: packet.data.n, - price: packet.data.v.lp, - }); - - return; - } - - if (chartEventNames.includes(packet.type) && chartCBs[packet.session]) { - chartCBs[packet.session](packet); - return; - } - - if (!logged && packet.type === 'info') { - if (autoInit) { - send('set_auth_token', ['unauthorized_user_token']); - send('quote_create_session', [sessionId]); - send('quote_set_fields', [sessionId, 'lp']); - - subscribed.forEach((symbol) => send('quote_add_symbols', [sessionId, symbol])); - } - - handleEvent('logged', packet); - return; - } - - if (packet.type === 'error') { - handleError(`Market API critical error: ${packet.syntax}`); - ws.close(); - return; - } - - handleEvent('data', packet); - }; - - return { - /** Event listener - * @param { 'connected' | 'disconnected' | 'logged' - * | 'subscribed' | 'price' | 'data' | 'error' | 'ping' } event Event - * @param {(...data: object) => null} cb Callback - */ - on(event, cb) { - if (!callbacks[event]) { - console.log('Wrong event:', event); - console.log('Available events:', Object.keys(callbacks)); - return; - } - - callbacks[event].push(cb); - }, - - /** - * Close the websocket connection - * @return {Promise} When websocket is closed - */ - end() { - return new Promise((cb) => { - isEnded = true; - ws.close(); - cb(); - }); - }, - - search, - getScreener, - getTA, - subscribed, - searchIndicator, - getUser, - getChartToken, - getDrawings, - - /** - * Unsubscribe to a market - * @param {string} symbol Market symbol (Example: BTCEUR or COINBASE:BTCEUR) - */ - subscribe(symbol) { - if (subscribed.includes(symbol)) return; - send('quote_add_symbols', [sessionId, symbol]); - subscribed.push(symbol); - }, - - /** - * Unsubscribe from a market - * @param {string} symbol Market symbol (Example: BTCEUR or COINBASE:BTCEUR) - */ - unsubscribe(symbol) { - if (!subscribed.includes(symbol)) return; - send('quote_remove_symbols', [sessionId, symbol]); - subscribed = subscribed.filter((s) => s !== symbol); - }, - - /** - * @typedef {Object} IndicatorInfos Indicator infos - * @property {string} id ID of the indicator (Like: XXX;XXXXXXXXXXXXXXXXXXXXX) - * @property {string} [name] Name of the indicator - * @property {'last' | string} [version] Wanted version of the indicator - * @property {(string | number | boolean | null)[]} [settings] Indicator settings value - * @property {'study' | 'strategy'} [type] Script type - * - * @typedef {Object} ChartInfos - * @property {string} [session] User 'sessionid' cookie - * @property {string} symbol Market symbol (Example: BTCEUR or COINBASE:BTCEUR) - * @property { '1' | '3' | '5' | '15' | '30' | '45' - * | '60' | '120' | '180' | '240' - * | '1D' | '1W' | '1M' - * } [period] Period - * @property {number} [range] Number of loaded periods - * @property {string} [timezone] Timezone in 'Europe/Paris' format - * @property {IndicatorInfos[]} [indicators] List of indicators - */ - - /** - * @typedef {Object} Period List of prices / indicator values - * @property {number} $time - * @property {{ - * time: number, open: number, close: number, - * max: number, min: number, change: number, - * }} $prices - */ - - /** - * Init a chart instance - * @param {ChartInfos} chart - * @param {{(prices: Period[], strategies: Object): null}} onUpdate - */ - async initChart(chart, onUpdate) { - const chartSession = genSession(); - const periods = []; - - /** - * @typedef {Object} RelAbsValue Relative and Absolute values - * @property {number} v Absolute value - * @property {number} p Relative value - */ - - /** - * @typedef {Object} TradeReport Trade report - - * @property {Object} entry Trade entry - * @property {string} entry.name Trade name - * @property {'long' | 'short'} entry.type Entry type (long/short) - * @property {number} entry.value Entry price value - * @property {number} entry.time Entry timestamp - - * @property {Object} exit Trade exit - * @property {'' | string} exit.name Trade name ('' if false exit) - * @property {number} exit.value Exit price value - * @property {number} exit.time Exit timestamp - - * @property {number} quantity Trade quantity - * @property {RelAbsValue} profit Trade profit - * @property {RelAbsValue} cumulative Trade cummulative profit - * @property {RelAbsValue} runup Trade run-up - * @property {RelAbsValue} drawdown Trade drawdown - */ - - /** - * @typedef {Object} PerfReport - * @property {number} avgBarsInTrade Average bars in trade - * @property {number} avgBarsInWinTrade Average bars in winning trade - * @property {number} avgBarsInLossTrade Average bars in losing trade - * @property {number} avgTrade Average trade gain - * @property {number} avgTradePercent Average trade performace - * @property {number} avgLosTrade Average losing trade gain - * @property {number} avgLosTradePercent Average losing trade performace - * @property {number} avgWinTrade Average winning trade gain - * @property {number} avgWinTradePercent Average winning trade performace - * @property {number} commissionPaid Commission paid - * @property {number} grossLoss Gross loss value - * @property {number} grossLossPercent Gross loss percent - * @property {number} grossProfit Gross profit - * @property {number} grossProfitPercent Gross profit percent - * @property {number} largestLosTrade Largest losing trade gain - * @property {number} largestLosTradePercent Largent losing trade performance - * @property {number} largestWinTrade Largest winning trade gain - * @property {number} largestWinTradePercent Largest winning trade performance - * @property {number} marginCalls Margin calls - * @property {number} maxContractsHeld Max Contracts Held - * @property {number} netProfit Net profit - * @property {number} netProfitPercent Net performance - * @property {number} numberOfLosingTrades Number of losing trades - * @property {number} numberOfWiningTrades Number of winning trades - * @property {number} percentProfitable Strategy winrate - * @property {number} profitFactor Profit factor - * @property {number} ratioAvgWinAvgLoss Ratio Average Win / Average Loss - * @property {number} totalOpenTrades Total open trades - * @property {number} totalTrades Total trades - */ - - /** - * @typedef {Object} StrategyReport - * @property {'EUR' | 'USD' | 'JPY' | '' | 'CHF'} currency Selected currency - * @property {TradeReport[]} trades Trade list - * @property {Object} history History Chart value - * @property {number[]} history.buyHold Buy hold values - * @property {number[]} history.buyHoldPercent Buy hold percent values - * @property {number[]} history.drawDown Drawdown values - * @property {number[]} history.drawDownPercent Drawdown percent values - * @property {number[]} history.equity Equity values - * @property {number[]} history.equityPercent Equity percent values - * @property {Object} performance Strategy performance - * @property {PerfReport} performance.all Strategy long/short performances - * @property {PerfReport} performance.long Strategy long performances - * @property {PerfReport} performance.short Strategy short performances - * @property {number} performance.buyHoldReturn Strategy Buy & Hold Return - * @property {number} performance.buyHoldReturnPercent Strategy Buy & Hold Return percent - * @property {number} performance.maxDrawDown Strategy max drawdown - * @property {number} performance.maxDrawDownPercent Strategy max drawdown percent - * @property {number} performance.openPL Strategy Open P&L (Profit And Loss) - * @property {number} performance.openPLPercent Strategy Open P&L (Profit And Loss) percent - * @property {number} performance.sharpeRatio Strategy Sharpe Ratio - * @property {number} performance.sortinoRatio Strategy Sortino Ratio - */ - - /** @type {Object} Strategies */ - const strategies = {}; - - const indicators = await Promise.all( - (chart.indicators || []).map((i) => getIndicator(i.id, i.version, i.settings, i.type)), - ); - - async function updatePeriods(packet) { - const newData = packet.data; - - await Promise.all(Object.keys(newData).map(async (type) => { - const std = chart.indicators[parseInt(type, 10)] || {}; - - if (newData[type].ns && newData[type].ns.d) { - const stratData = JSON.parse(newData[type].ns.d); - - if (stratData.dataCompressed) { - const zip = new JSZip(); - const data = JSON.parse( - await ( - await zip.loadAsync(stratData.dataCompressed, { base64: true }) - ).file('').async('text'), - ); - - strategies[std.name || type] = { - currency: data.report.currency, - - trades: data.report.trades.map((t) => ({ - entry: { - name: t.e.c, - type: (t.e.tp[0] === 's' ? 'short' : 'long'), - value: t.e.p, - time: t.e.tm, - }, - exit: { - name: t.x.c, - value: t.x.p, - time: t.x.tm, - }, - quantity: t.q, - profit: t.tp, - cumulative: t.cp, - runup: t.rn, - drawdown: t.dd, - })), - - history: { - buyHold: data.report.buyHold, - buyHoldPercent: data.report.buyHoldPercent, - drawDown: data.report.drawDown, - drawDownPercent: data.report.drawDownPercent, - equity: data.report.equity, - equityPercent: data.report.equityPercent, - }, - - performance: data.report.performance, - }; - return; - } - - if (stratData.data && stratData.data.report && stratData.data.report.performance) { - if (!strategies[std.name || type]) strategies[std.name || type] = { performance: {} }; - strategies[std.name || type].performance = stratData.data.report.performance; - return; - } - - return; - } - - (newData[type].s || newData[type].st || []).forEach((p) => { - if (!periods[p.i]) periods[p.i] = {}; - - if (newData[type].s) { - [periods[p.i].$time] = p.v; - - periods[p.i][type] = { - open: p.v[1], - close: p.v[4], - max: p.v[2], - min: p.v[3], - change: Math.round(p.v[5] * 100) / 100, - }; - } - - if (newData[type].st) { - const period = {}; - const indicator = indicators[parseInt(type, 10)]; - - p.v.forEach((val, i) => { - if (i === 0) return; - if (indicator.plots[`plot_${i - 1}`] && !period[indicator.plots[`plot_${i - 1}`]]) { - period[indicator.plots[`plot_${i - 1}`]] = val; - } else period[`_plot_${i - 1}`] = val; - }); - periods[p.i][chart.indicators[parseInt(type, 10)].name || `st${type}`] = period; - } - }); - })); - } - - chartCBs[chartSession] = async (packet) => { - if (isEnded) return; - - if (['timescale_update', 'du'].includes(packet.type)) { - await updatePeriods(packet); - if (!isEnded) onUpdate([...periods].reverse(), strategies); - return; - } - - if (packet.type.endsWith('_error')) { - handleError(`Error on '${chart.symbol}' (${chartSession}) chart: "${packet.type}":`, packet); - } - }; - - if (chart.session) send('set_auth_token', [(await getUser(chart.session)).authToken]); - send('chart_create_session', [chartSession, '']); - if (chart.timezone) send('switch_timezone', [chartSession, chart.timezone]); - send('resolve_symbol', [chartSession, 'sds_sym_1', `={"symbol":"${chart.symbol || 'BTCEUR'}","adjustment":"splits"}`]); - send('create_series', [chartSession, '$prices', 's1', 'sds_sym_1', (chart.period || '240'), (chart.range || 100), '']); - - indicators.forEach(async (indicator, i) => { - const pineInfos = { - pineId: indicator.pineId, - pineVersion: indicator.pineVersion, - text: indicator.script, - }; - - Object.keys(indicator.inputs).forEach((inputID, inp) => { - const input = indicator.inputs[inputID]; - if (input.type === 'bool' && typeof input.value !== 'boolean') handleError(`Input '${input.name}' (${inp}) must be a boolean !`); - if (input.type === 'integer' && typeof input.value !== 'number') handleError(`Input '${input.name}' (${inp}) must be a number !`); - if (input.type === 'float' && typeof input.value !== 'number') handleError(`Input '${input.name}' (${inp}) must be a number !`); - if (input.type === 'text' && typeof input.value !== 'string') handleError(`Input '${input.name}' (${inp}) must be a string !`); - if (input.options && !input.options.includes(input.value)) { - handleError(`Input '${input.name}' (${inp}) must be one of these values:`, input.options); - } - - pineInfos[inputID] = { - v: input.value, - f: input.isFake, - t: input.type, - }; - }); - - send('create_study', [chartSession, `${i}`, 'st1', '$prices', indicator.typeID, pineInfos]); - }); - }, - - send, - sessionId, - }; +module.exports = { + ...miscRequests, + Client, }; diff --git a/package.json b/package.json index dfd687c..19c386b 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "ws": "^7.4.3" }, "devDependencies": { + "@babel/eslint-parser": "^7.15.7", "eslint": "^7.25.0", "eslint-config-airbnb-base": "^14.2.1", "eslint-plugin-import": "^2.22.1" diff --git a/src/client.js b/src/client.js new file mode 100644 index 0000000..d204888 --- /dev/null +++ b/src/client.js @@ -0,0 +1,274 @@ +const WebSocket = require('ws'); + +const misc = require('./miscRequests'); + +const quoteSessionGenerator = require('./quote/session'); +const chartSessionGenerator = require('./quote/session'); + +/** + * @typedef {Object} Session + * @prop {'quote' | 'chart' | 'replay'} type Session type + * @prop {(data: {}) => null} onData When there is a data + */ + +/** @typedef {Object} SessionList Session list */ + +/** + * @typedef {(t: string, p: []) => null} SendPacket Send a custom packet + * @param {string} t Packet type + * @param {string[]} p Packet data +*/ + +/** + * @typedef {Object} ClientBridge + * @prop {SessionList} sessions + * @prop {SendPacket} send + */ + +/** + * @typedef { 'connected' | 'disconnected' + * | 'logged' | 'ping' | 'data' + * | 'error' | 'event' + * } ClientEvent + */ + +module.exports = class Client { + #ws; + + #logged = false; + + /** If the client is logged in */ + get logged() { + return this.#logged; + } + + /** If the cient was closed */ + get isOpen() { + return this.#ws.readyState === this.#ws.OPEN; + } + + /** @type {SessionList} */ + #sessions = {}; + + #callbacks = { + connected: [], + disconnected: [], + logged: [], + ping: [], + data: [], + + error: [], + event: [], + }; + + /** + * @param {ClientEvent} ev Client event + * @param {...{}} data Packet data + */ + #handleEvent(ev, ...data) { + this.#callbacks[ev].forEach((e) => e(...data)); + this.#callbacks.event.forEach((e) => e(ev, ...data)); + } + + #handleError(...msgs) { + if (this.#callbacks.error.length === 0) console.error(...msgs); + else this.#handleEvent('error', ...msgs); + } + + /** + * When client is connected + * @param {() => void} cb Callback + * @event + */ + onConnected(cb) { + this.#callbacks.connected.push(cb); + } + + /** + * When client is disconnected + * @param {() => void} cb Callback + * @event + */ + onDisconnected(cb) { + this.#callbacks.disconnected.push(cb); + } + + /** + * @typedef {Object} SocketSession + * @prop {string} session_id Socket session ID + * @prop {number} timestamp Session start timestamp + * @prop {number} timestampMs Session start milliseconds timestamp + * @prop {string} release Release + * @prop {string} studies_metadata_hash Studies metadata hash + * @prop {'json' | string} protocol Used protocol + * @prop {string} javastudies Javastudies + * @prop {number} auth_scheme_vsn Auth scheme type + * @prop {string} via Socket IP + + * When client is logged + * @param {(SocketSession: SocketSession) => void} cb Callback + * @event + */ + onLogged(cb) { + this.#callbacks.logged.push(cb); + } + + /** + * When server is pinging the client + * @param {(i: number) => void} cb Callback + * @event + */ + onPing(cb) { + this.#callbacks.ping.push(cb); + } + + /** + * When unparsed data is received + * @param {(...{}) => void} cb Callback + * @event + */ + onData(cb) { + this.#callbacks.data.push(cb); + } + + /** + * When a client error happens + * @param {(...{}) => void} cb Callback + * @event + */ + onError(cb) { + this.#callbacks.error.push(cb); + } + + /** + * When a client event happens + * @param {(...{}) => void} cb Callback + * @event + */ + onEvent(cb) { + this.#callbacks.event.push(cb); + } + + #parsePacket(str) { + if (!this.isOpen) return; + + str.replace(/~h~/g, '').split(/~m~[0-9]{1,}~m~/g) + .map((p) => { + if (!p) return false; + try { + return JSON.parse(p); + } catch (error) { + console.warn('Cant parse', p); + return false; + } + }) + .filter((p) => p) + .forEach((packet) => { + console.log('[CLIENT] PACKET', packet); + if (typeof packet === 'number') { // Ping + const pingStr = `~h~${packet}`; + this.#ws.send(`~m~${pingStr.length}~m~${pingStr}`); + this.#handleEvent('ping', packet); + return; + } + + if (packet.m === 'protocol_error') { // Error + this.#handleError('Client critical error:', packet.p); + this.#ws.close(); + return; + } + + if (packet.m && packet.p) { // Normal packet + const parsed = { + type: packet.m, + session: packet.p[0], + data: packet.p[1], + }; + + if (parsed.session && this.#sessions[parsed.session]) { + this.#sessions[parsed.session].onData(parsed); + return; + } + } + + if (!this.#logged) { + this.#handleEvent('logged', packet); + return; + } + + this.#handleEvent('data', packet); + }); + } + + #sendQueue = []; + + /** @type {SendPacket} Send a custom packet */ + send(t, p = []) { + const msg = JSON.stringify({ m: t, p }); + this.#sendQueue.push(`~m~${msg.length}~m~${msg}`); + this.sendQueue(); + } + + /** Send all waiting packets */ + sendQueue() { + while (this.isOpen && this.#sendQueue.length > 0) { + const packet = this.#sendQueue.shift(); + this.#ws.send(packet); + } + } + + /** + * @typedef {Object} ClientOptions + * @prop {string} [token] User auth token (in 'sessionid' cookie) + */ + + /** Client object + * @param {ClientOptions} clientOptions TradingView client options + */ + constructor(clientOptions = {}) { + this.#ws = new WebSocket('wss://widgetdata.tradingview.com/socket.io/websocket', { + origin: 'https://s.tradingview.com', + }); + + if (clientOptions.token) { + misc.getUser(clientOptions.token).then((user) => { + this.send('set_auth_token', [user.authToken]); + }); + } else this.send('set_auth_token', ['unauthorized_user_token']); + + this.#ws.on('open', () => { + this.#handleEvent('connected'); + this.sendQueue(); + }); + + this.#ws.on('close', () => { + this.#logged = false; + this.#handleEvent('disconnected'); + }); + + this.#ws.on('message', (data) => this.#parsePacket(data)); + } + + /** @type {ClientBridge} */ + #clientBridge = { + sessions: this.#sessions, + send: (t, p) => this.send(t, p), + }; + + /** @namespace Session */ + Session = { + Quote: quoteSessionGenerator(this.#clientBridge), + Chart: chartSessionGenerator(this.#clientBridge), + }; + + /** + * Close the websocket connection + * @return {Promise} When websocket is closed + */ + end() { + return new Promise((cb) => { + this.#ws.close(); + cb(); + }); + } +}; diff --git a/miscRequests.js b/src/miscRequests.js similarity index 100% rename from miscRequests.js rename to src/miscRequests.js diff --git a/src/quote/market.js b/src/quote/market.js new file mode 100644 index 0000000..ff781e9 --- /dev/null +++ b/src/quote/market.js @@ -0,0 +1,124 @@ +/** + * @typedef { 'loaded' | 'data' | 'error' } MarketEvent + */ + +/** + * @param {import('./session').QuoteSessionBridge} quoteSession + */ +module.exports = (quoteSession) => class QuoteMarket { + #symbolList = quoteSession.symbols; + + #symbol; + + #symbolListenerID = 0; + + #callbacks = { + loaded: [], + data: [], + + event: [], + error: [], + }; + + #lastData = {}; + + /** + * @param {MarketEvent} ev Client event + * @param {...{}} data Packet data + */ + #handleEvent(ev, ...data) { + this.#callbacks[ev].forEach((e) => e(...data)); + this.#callbacks.event.forEach((e) => e(ev, ...data)); + } + + #handleError(...msgs) { + if (this.#callbacks.error.length === 0) console.error(...msgs); + else this.#handleEvent('error', ...msgs); + } + + /** + * @param {string} symbol Market symbol (like: 'BTCEUR' or 'KRAKEN:BTCEUR') + */ + constructor(symbol) { + this.#symbol = symbol; + + if (!this.#symbolList[symbol]) { + this.#symbolList[symbol] = []; + quoteSession.send('quote_add_symbols', [ + quoteSession.sessionID, + symbol, + ]); + } + this.#symbolListenerID = this.#symbolList[symbol].length; + + this.#symbolList[symbol][this.#symbolListenerID] = (packet) => { + console.log('[MARKET] DATA', packet); + + if (packet.type === 'qsd' && packet.data.s === 'ok') { + this.#lastData = { + ...this.#lastData, + ...packet.data.v, + }; + this.#handleEvent('data', this.#lastData); + return; + } + + if (packet.type === 'qsd' && packet.data.s === 'error') { + this.#handleError('Market error', packet.data); + return; + } + + if (packet.type === 'quote_completed') { + this.#handleEvent('loaded'); + return; + } + }; + } + + /** + * When quote market is loaded + * @param {() => void} cb Callback + * @event + */ + onLoaded(cb) { + this.#callbacks.loaded.push(cb); + } + + /** + * When quote data is received + * @param {(data: {}) => void} cb Callback + * @event + */ + onData(cb) { + this.#callbacks.data.push(cb); + } + + /** + * When quote event happens + * @param {(...any) => void} cb Callback + * @event + */ + onEvent(cb) { + this.#callbacks.event.push(cb); + } + + /** + * When quote error happens + * @param {(...any) => void} cb Callback + * @event + */ + onError(cb) { + this.#callbacks.error.push(cb); + } + + /** Close this listener */ + close() { + if (this.#symbolList[this.#symbol].length <= 1) { + quoteSession.send('quote_remove_symbols', [ + quoteSession.sessionID, + this.#symbol, + ]); + } + delete this.#symbolList[this.#symbol][this.#symbolListenerID]; + } +}; diff --git a/src/quote/session.js b/src/quote/session.js new file mode 100644 index 0000000..b176ca6 --- /dev/null +++ b/src/quote/session.js @@ -0,0 +1,115 @@ +const { genSessionID } = require('../utils'); + +const quoteMarketConstructor = require('./market'); + +/** @typedef {Object} SymbolList */ + +/** + * @typedef {Object} QuoteSessionBridge + * @prop {string} sessionID + * @prop {SymbolList} symbols + * @prop {import('../client').SendPacket} send +*/ + +/** + * @typedef {'base-currency-logoid' + * | 'ch' | 'chp' | 'currency-logoid' | 'provider_id' + * | 'currency_code' | 'current_session' | 'description' + * | 'exchange' | 'format' | 'fractional' | 'is_tradable' + * | 'language' | 'local_description' | 'logoid' | 'lp' + * | 'lp_time' | 'minmov' | 'minmove2' | 'original_name' + * | 'pricescale' | 'pro_name' | 'short_name' | 'type' + * | 'update_mode' | 'volume' | 'ask' | 'bid' | 'fundamentals' + * | 'high_price' | 'low_price' | 'open_price' | 'prev_close_price' + * | 'rch' | 'rchp' | 'rtc' | 'rtc_time' | 'status' | 'industry' + * | 'basic_eps_net_income' | 'beta_1_year' | 'market_cap_basic' + * | 'earnings_per_share_basic_ttm' | 'price_earnings_ttm' + * | 'sector' | 'dividends_yield' | 'timezone' | 'country_code' + * } quoteField Quote data field + */ + +/** @param {'all' | 'price'} fieldsType */ +function getQuoteFields(fieldsType) { + if (fieldsType === 'price') { + return ['lp']; + } + + return [ + 'base-currency-logoid', 'ch', 'chp', 'currency-logoid', + 'currency_code', 'current_session', 'description', + 'exchange', 'format', 'fractional', 'is_tradable', + 'language', 'local_description', 'logoid', 'lp', + 'lp_time', 'minmov', 'minmove2', 'original_name', + 'pricescale', 'pro_name', 'short_name', 'type', + 'update_mode', 'volume', 'ask', 'bid', 'fundamentals', + 'high_price', 'low_price', 'open_price', 'prev_close_price', + 'rch', 'rchp', 'rtc', 'rtc_time', 'status', 'industry', + 'basic_eps_net_income', 'beta_1_year', 'market_cap_basic', + 'earnings_per_share_basic_ttm', 'price_earnings_ttm', + 'sector', 'dividends_yield', 'timezone', 'country_code', + 'provider_id', + ]; +} + +/** + * @typedef {Object} quoteSessionOptions Quote Session options + * @prop {'all' | 'price'} [fields] Asked quote fields + * @prop {quoteField[]} [customFields] List of asked quote fields + */ + +/** + * @param {import('../client').ClientBridge} client + */ +module.exports = (client) => class QuoteSession { + #sessionID = genSessionID('qs'); + + #client = client; + + /** @type {SymbolList} */ + #symbols = {}; + + /** + * @param {quoteSessionOptions} options Quote settings options + */ + constructor(options) { + this.#client.sessions[this.#sessionID] = { + type: 'quote', + onData: (packet) => { + console.log('[QUOTE SESSION] DATA', packet); + + if (packet.type === 'quote_completed' && this.#symbols[packet.data]) { + this.#symbols[packet.data].forEach((h) => h(packet)); + } + + if (packet.type === 'qsd' && this.#symbols[packet.data.n]) { + this.#symbols[packet.data.n].forEach((h) => h(packet)); + } + }, + }; + + const fields = (options.customFields && options.customFields.length > 0 + ? options.customFields + : getQuoteFields(options.fields) + ); + + this.#client.send('quote_create_session', [this.#sessionID]); + this.#client.send('quote_set_fields', [this.#sessionID, ...fields]); + } + + /** @type {QuoteSessionBridge} */ + #quoteSession = { + sessionID: this.#sessionID, + symbols: this.#symbols, + send: (t, p) => this.#client.send(t, p), + }; + + /** @constructor */ + Market = quoteMarketConstructor(this.#quoteSession); + + /** Delete the quote session */ + delete() { + this.#quoteSession.send('quote_delete_session', [ + this.#quoteSession.sessionID, + ]); + } +}; diff --git a/src/types.js b/src/types.js new file mode 100644 index 0000000..5ddba77 --- /dev/null +++ b/src/types.js @@ -0,0 +1,5 @@ +/** + * @typedef {string} MarketSymbol Market symbol (like: 'BTCEUR' or 'KRAKEN:BTCEUR') + */ + +module.exports = {}; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..1b79c48 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,8 @@ +module.exports = { + genSessionID(type = 'xs') { + let r = ''; + const c = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 12; i += 1) r += c.charAt(Math.floor(Math.random() * c.length)); + return `${type}_${r}`; + }, +}; diff --git a/test.js b/test.js new file mode 100644 index 0000000..e983806 --- /dev/null +++ b/test.js @@ -0,0 +1,34 @@ +const TradingView = require('./main'); + +const client = new TradingView.Client({ + token: null, +}); + +client.onEvent((event, data) => { + console.log('[TEST] EVENT:', event, data); +}); + +const quoteSession = new client.Session.Quote({ + fields: 'price', +}); + +// const quoteSession2 = new client.Session.Quote({ +// fields: 'price', +// }); + +const BTC = new quoteSession.Market('BTCEUR'); +BTC.onData((data) => { + console.log('[TEST] BTCEUR DATA', data); +}); +BTC.onError((...err) => { + console.log('[TEST] BTCEUR ERROR', err); +}); + +// const BTC = new quoteSession.Symbol('BTCEUR'); +// BTC.onData((symbol) => { +// console.log(symbol); +// }); + +// setTimeout(() => { +// BTC.close(); +// }, 5000); From 17f817d9225e0efc1fcddacb50994c2366507952 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Mon, 18 Oct 2021 00:51:04 +0200 Subject: [PATCH 02/16] Add charts and studies --- package.json | 1 + src/chart/session.js | 288 ++++++++++++++++++++++++++++++ src/chart/study.js | 327 +++++++++++++++++++++++++++++++++++ src/classes/PineIndicator.js | 112 ++++++++++++ src/client.js | 79 ++++----- src/miscRequests.js | 70 +++----- src/protocol.js | 54 ++++++ src/quote/market.js | 35 ++-- src/quote/session.js | 51 +++--- src/types.js | 27 +++ test.js | 154 +++++++++++++++-- 11 files changed, 1051 insertions(+), 147 deletions(-) create mode 100644 src/chart/session.js create mode 100644 src/chart/study.js create mode 100644 src/classes/PineIndicator.js create mode 100644 src/protocol.js diff --git a/package.json b/package.json index 19c386b..7433eda 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@babel/eslint-parser": "^7.15.7", + "@mathieuc/console": "^1.0.1", "eslint": "^7.25.0", "eslint-config-airbnb-base": "^14.2.1", "eslint-plugin-import": "^2.22.1" diff --git a/src/chart/session.js b/src/chart/session.js new file mode 100644 index 0000000..ed55189 --- /dev/null +++ b/src/chart/session.js @@ -0,0 +1,288 @@ +const { genSessionID } = require('../utils'); + +const studyConstructor = require('./study'); + +/** @typedef {Object} StudyListeners */ + +/** + * @typedef {Object} ChartSessionBridge + * @prop {string} sessionID + * @prop {StudyListeners} studyListeners + * @prop {import('../client').SendPacket} send +*/ + +/** + * @typedef {'seriesLoaded' | 'symbolLoaded' | 'update' | 'error'} ChartEvent + */ + +/** + * @typedef {Object} PricePeriod + * @prop {number} time Period timestamp + * @prop {number} open Period open value + * @prop {number} close Period close value + * @prop {number} max Period max value + * @prop {number} min Period min value + * @prop {number} change Period change absolute value + */ + +/** + * @typedef {Object} Subsession + * @prop {string} id Subsession ID (ex: 'regular') + * @prop {string} description Subsession description (ex: 'Regular') + * @prop {string} privat If private + * @prop {string} session Session (ex: '24x7') + * @prop {string} session-display Session display (ex: '24x7') + * + * @typedef {Object} MarketInfos + * @prop {string} series_id Used series (ex: 'sds_sym_1') + * @prop {string} base_currency Base currency (ex: 'BTC') + * @prop {string} base_currency_id Base currency ID (ex: 'XTVCBTC') + * @prop {string} name Market short name (ex: 'BTCEUR') + * @prop {string} full_name Market full name (ex: 'COINBASE:BTCEUR') + * @prop {string} pro_name Market pro name (ex: 'COINBASE:BTCEUR') + * @prop {string} description Market symbol description (ex: 'BTC/EUR') + * @prop {string} short_description Market symbol short description (ex: 'BTC/EUR') + * @prop {string} exchange Market exchange (ex: 'COINBASE') + * @prop {string} listed_exchange Market exchange (ex: 'COINBASE') + * @prop {string} provider_id Values provider ID (ex: 'coinbase') + * @prop {string} currency_id Used currency ID (ex: 'EUR') + * @prop {string} currency_code Used currency code (ex: 'EUR') + * @prop {string} variable_tick_size Variable tick size + * @prop {number} pricescale Price scale + * @prop {number} pointvalue Point value + * @prop {string} session Session (ex: '24x7') + * @prop {string} session_display Session display (ex: '24x7') + * @prop {string} type Market type (ex: 'crypto') + * @prop {boolean} has_intraday If intraday values are available + * @prop {boolean} fractional If market is fractional + * @prop {boolean} is_tradable If the market is curently tradable + * @prop {number} minmov Minimum move value + * @prop {number} minmove2 Minimum move value 2 + * @prop {string} timezone Used timezone + * @prop {boolean} is_replayable If the replay mode is available + * @prop {boolean} has_adjustment If the adjustment mode is enabled ???? + * @prop {boolean} has_extended_hours Has extended hours + * @prop {string} bar_source Bar source + * @prop {string} bar_transform Bar transform + * @prop {boolean} bar_fillgaps Bar fill gaps + * @prop {string} allowed_adjustment Allowed adjustment (ex: 'none') + * @prop {string} subsession_id Subsession ID (ex: 'regular') + * @prop {string} pro_perm Pro permission (ex: '') + * @prop {[]} base_name Base name (ex: ['COINBASE:BTCEUR']) + * @prop {[]} legs Legs (ex: ['COINBASE:BTCEUR']) + * @prop {Subsession[]} subsessions Sub sessions + * @prop {[]} typespecs Typespecs (ex: []) + * @prop {[]} resolutions Resolutions (ex: []) + * @prop {[]} aliases Aliases (ex: []) + * @prop {[]} alternatives Alternatives (ex: []) + */ + +/** + * @param {import('../client').ClientBridge} client + */ +module.exports = (client) => class ChartSession { + #sessionID = genSessionID('cs'); + + /** Parent client */ + #client = client; + + /** @type {StudyListeners} */ + #studyListeners = {}; + + /** + * Table of periods values indexed by timestamp + * @type {Object} + */ + #periods = {}; + + /** @return {PricePeriod[]} List of periods values */ + get periods() { + return Object.values(this.#periods).sort((a, b) => b.time - a.time); + } + + #callbacks = { + seriesLoaded: [], + symbolLoaded: [], + update: [], + + event: [], + error: [], + }; + + /** + * @param {ChartEvent} ev Client event + * @param {...{}} data Packet data + */ + #handleEvent(ev, ...data) { + this.#callbacks[ev].forEach((e) => e(...data)); + this.#callbacks.event.forEach((e) => e(ev, ...data)); + } + + #handleError(...msgs) { + if (this.#callbacks.error.length === 0) console.error(...msgs); + else this.#handleEvent('error', ...msgs); + } + + constructor() { + this.#client.sessions[this.#sessionID] = { + type: 'chart', + onData: (packet) => { + console.log('§90§30§106 CHART SESSION §0 DATA', packet); + + if (typeof packet.data[1] === 'string' && this.#studyListeners[packet.data[1]]) { + this.#studyListeners[packet.data[1]](packet); + return; + } + + if (packet.type === 'symbol_resolved') { + this.#handleEvent('symbolLoaded', { + series_id: packet.data[1], + ...packet.data[2], + }); + return; + } + + if (['timescale_update', 'du'].includes(packet.type)) { + Object.keys(packet.data[1]).forEach((k) => { + if (k === '$prices') { + const periods = packet.data[1].$prices; + if (!periods || !periods.s) return; + + periods.s.forEach((p) => { + this.#periods[p.v[0]] = { + time: p.v[0], + open: p.v[1], + close: p.v[4], + max: p.v[2], + min: p.v[3], + change: Math.round(p.v[5] * 100) / 100, + }; + }); + + return; + } + + if (this.#studyListeners[k]) this.#studyListeners[k](packet); + }); + + this.#handleEvent('update'); + return; + } + + if (packet.type === 'symbol_error') { + this.#handleError(`(${packet.data[1]}) Symbol error:`, packet.data[2]); + return; + } + + if (packet.type === 'series_error') { + this.#handleError('Series error:', packet.data); + return; + } + + if (packet.type === 'critical_error') { + const [, name, description] = packet.data; + this.#handleError('Critical error:', name, description); + } + }, + }; + + this.#client.send('chart_create_session', [this.#sessionID]); + } + + #series = [] + + /** + * @param {import('../types').TimeFrame} timeframe Chart period timeframe + * @param {number} [range] Number of loaded periods/candles (Default: 100) + * @param {string} [ID] Series ID (Default: 'sds_sym_1') + */ + setSeries(timeframe = '240', range = 100, ID = 'sds_sym_1') { + this.#periods = {}; + this.#client.send(`${this.#series.includes(ID) ? 'modify' : 'create'}_series`, [ + this.#sessionID, + '$prices', + 's1', + ID, + timeframe, + !this.#series.includes(ID) ? range : '', + ]); + + if (!this.#series.includes(ID)) this.#series.push(ID); + } + + /** + * Set the chart market + * @param {string} symbol Market symbol + * @param {Object} [options] Market options + * @param {'splits' | 'dividends'} [options.adjustment] Market adjustment + * @param {string} [options.series] Series ID (Default: 'sds_sym_1') + */ + setMarket(symbol, options = {}) { + this.#periods = {}; + + this.#client.send('resolve_symbol', [ + this.#sessionID, + options.series || 'sds_sym_1', + `={"symbol":"${symbol || 'BTCEUR'}","adjustment":"${options.adjustment || 'splits'}","session":"regular"}`, + ]); + } + + /** + * Set the chart timezone + * @param {import('../types').Timezone} timezone New timezone + */ + setTimezone(timezone) { + this.#periods = {}; + this.#client.send('switch_timezone', [this.#sessionID, timezone]); + } + + /** + * Fetch x additional previous periods/candles values + * @param {number} number Number of additional periods/candles you want to fetch + */ + fetchMore(number = 1) { + this.#client.send('request_more_data', [this.#sessionID, '$prices', number]); + } + + /** + * When a symbol is loaded + * @param {(marketInfos: MarketInfos) => void} cb + * @event + */ + onSymbolLoaded(cb) { + this.#callbacks.symbolLoaded.push(cb); + } + + /** + * When a chart update happens + * @param {() => void} cb + * @event + */ + onUpdate(cb) { + this.#callbacks.update.push(cb); + } + + /** + * When chart error happens + * @param {(...any) => void} cb Callback + * @event + */ + onError(cb) { + this.#callbacks.error.push(cb); + } + + /** @type {ChartSessionBridge} */ + #chartSession = { + sessionID: this.#sessionID, + studyListeners: this.#studyListeners, + send: (t, p) => this.#client.send(t, p), + }; + + Study = studyConstructor(this.#chartSession); + + /** Delete the chart session */ + delete() { + this.#client.send('quote_delete_session', [this.#sessionID]); + delete this.#client.sessions[this.#sessionID]; + } +}; diff --git a/src/chart/study.js b/src/chart/study.js new file mode 100644 index 0000000..510d7bd --- /dev/null +++ b/src/chart/study.js @@ -0,0 +1,327 @@ +const { genSessionID } = require('../utils'); +const { parseCompressed } = require('../protocol'); + +const PineIndicator = require('../classes/PineIndicator'); + +/** + * Get pine inputs + * @param {PineIndicator} options + */ +function getPineInputs(options) { + const pineInputs = { text: options.script }; + + if (options.pineId) pineInputs.pineId = options.pineId; + if (options.pineVersion) pineInputs.pineVersion = options.pineVersion; + + Object.keys(options.inputs).forEach((inputID) => { + const input = options.inputs[inputID]; + + pineInputs[inputID] = { + v: input.value, + f: input.isFake, + t: input.type, + }; + }); + + return pineInputs; +} + +/** + * @typedef {Object} TradeReport Trade report + + * @property {Object} entry Trade entry + * @property {string} entry.name Trade name + * @property {'long' | 'short'} entry.type Entry type (long/short) + * @property {number} entry.value Entry price value + * @property {number} entry.time Entry timestamp + + * @property {Object} exit Trade exit + * @property {'' | string} exit.name Trade name ('' if false exit) + * @property {number} exit.value Exit price value + * @property {number} exit.time Exit timestamp + + * @property {number} quantity Trade quantity + * @property {RelAbsValue} profit Trade profit + * @property {RelAbsValue} cumulative Trade cummulative profit + * @property {RelAbsValue} runup Trade run-up + * @property {RelAbsValue} drawdown Trade drawdown + */ + +/** + * @typedef {Object} PerfReport + * @property {number} avgBarsInTrade Average bars in trade + * @property {number} avgBarsInWinTrade Average bars in winning trade + * @property {number} avgBarsInLossTrade Average bars in losing trade + * @property {number} avgTrade Average trade gain + * @property {number} avgTradePercent Average trade performace + * @property {number} avgLosTrade Average losing trade gain + * @property {number} avgLosTradePercent Average losing trade performace + * @property {number} avgWinTrade Average winning trade gain + * @property {number} avgWinTradePercent Average winning trade performace + * @property {number} commissionPaid Commission paid + * @property {number} grossLoss Gross loss value + * @property {number} grossLossPercent Gross loss percent + * @property {number} grossProfit Gross profit + * @property {number} grossProfitPercent Gross profit percent + * @property {number} largestLosTrade Largest losing trade gain + * @property {number} largestLosTradePercent Largent losing trade performance (percentage) + * @property {number} largestWinTrade Largest winning trade gain + * @property {number} largestWinTradePercent Largest winning trade performance (percentage) + * @property {number} marginCalls Margin calls + * @property {number} maxContractsHeld Max Contracts Held + * @property {number} netProfit Net profit + * @property {number} netProfitPercent Net performance (percentage) + * @property {number} numberOfLosingTrades Number of losing trades + * @property {number} numberOfWiningTrades Number of winning trades + * @property {number} percentProfitable Strategy winrate + * @property {number} profitFactor Profit factor + * @property {number} ratioAvgWinAvgLoss Ratio Average Win / Average Loss + * @property {number} totalOpenTrades Total open trades + * @property {number} totalTrades Total trades +*/ + +/** + * @typedef {Object} StrategyReport + * @property {'EUR' | 'USD' | 'JPY' | '' | 'CHF'} [currency] Selected currency + * @property {TradeReport[]} trades Trade list starting by the last + * @property {Object} history History Chart value + * @property {number[]} [history.buyHold] Buy hold values + * @property {number[]} [history.buyHoldPercent] Buy hold percent values + * @property {number[]} [history.drawDown] Drawdown values + * @property {number[]} [history.drawDownPercent] Drawdown percent values + * @property {number[]} [history.equity] Equity values + * @property {number[]} [history.equityPercent] Equity percent values + * @property {Object} performance Strategy performance + * @property {PerfReport} [performance.all] Strategy long/short performances + * @property {PerfReport} [performance.long] Strategy long performances + * @property {PerfReport} [performance.short] Strategy short performances + * @property {number} [performance.buyHoldReturn] Strategy Buy & Hold Return + * @property {number} [performance.buyHoldReturnPercent] Strategy Buy & Hold Return percent + * @property {number} [performance.maxDrawDown] Strategy max drawdown + * @property {number} [performance.maxDrawDownPercent] Strategy max drawdown percent + * @property {number} [performance.openPL] Strategy Open P&L (Profit And Loss) + * @property {number} [performance.openPLPercent] Strategy Open P&L (Profit And Loss) percent + * @property {number} [performance.sharpeRatio] Strategy Sharpe Ratio + * @property {number} [performance.sortinoRatio] Strategy Sortino Ratio + */ + +/** + * @param {import('./session').ChartSessionBridge} chartSession + */ +module.exports = (chartSession) => class ChartStudy { + #studID = genSessionID('st'); + + #studyListeners = chartSession.studyListeners; + + /** + * Table of periods values indexed by timestamp + * @type {Object} + */ + #periods = {}; + + /** @return {{}[]} List of periods values */ + get periods() { + return Object.values(this.#periods).sort((a, b) => b.time - a.time); + } + + /** @type {StrategyReport} */ + #strategyReport = { + trades: [], + history: {}, + performance: {}, + }; + + /** @return {StrategyReport} Get the strategy report if available */ + get strategyReport() { + return this.#strategyReport; + } + + #callbacks = { + studyCompleted: [], + update: [], + + event: [], + error: [], + }; + + /** + * @param {ChartEvent} ev Client event + * @param {...{}} data Packet data + */ + #handleEvent(ev, ...data) { + this.#callbacks[ev].forEach((e) => e(...data)); + this.#callbacks.event.forEach((e) => e(ev, ...data)); + } + + #handleError(...msgs) { + if (this.#callbacks.error.length === 0) console.error(...msgs); + else this.#handleEvent('error', ...msgs); + } + + /** + * @param {PineIndicator} options Indicator options + * @param {'Script@tv-scripting-101!' + * | 'StrategyScript@tv-scripting-101!'} [type] Indicator custom type + */ + constructor(options, type = 'Script@tv-scripting-101!') { + if (!(options instanceof PineIndicator)) { + throw new Error(`Study options must be an instance of PineIndicator. + Please use 'TradingView.getIndicator(...)' function.`); + } + + /** @type {PineIndicator} Indicator options */ + this.options = options; + + this.#studyListeners[this.#studID] = async (packet) => { + console.log('§90§30§105 STUDY §0 DATA', packet); + + if (packet.type === 'study_completed') { + this.#handleEvent('studyCompleted'); + return; + } + + if (['timescale_update', 'du'].includes(packet.type)) { + const changes = []; + const data = packet.data[1][this.#studID]; + + if (data && data.st && data.st[0]) { + data.st.forEach((p) => { + const period = {}; + + p.v.forEach((plot, i) => { + const plotName = (i === 0 ? '$time' : this.options.plots[`plot_${i - 1}`]); + if (!period[plotName]) period[plotName] = plot; + else period[`plot_${i - 1}`] = plot; + }); + + this.#periods[p.v[0]] = period; + }); + + changes.push('plots'); + } + + if (data.ns && data.ns.d) { + const parsed = JSON.parse(data.ns.d); + + if (parsed.data && parsed.data.report && parsed.data.report.performance) { + this.#strategyReport.performance = parsed.data.report.performance; + changes.push('perfReport'); + } + + if (parsed.dataCompressed) { + const parsedC = await parseCompressed(parsed.dataCompressed); + + this.#strategyReport = { + currency: parsedC.report.currency, + + trades: parsedC.report.trades.reverse().map((t) => ({ + entry: { + name: t.e.c, + type: (t.e.tp[0] === 's' ? 'short' : 'long'), + value: t.e.p, + time: t.e.tm, + }, + exit: { + name: t.x.c, + value: t.x.p, + time: t.x.tm, + }, + quantity: t.q, + profit: t.tp, + cumulative: t.cp, + runup: t.rn, + drawdown: t.dd, + })), + + history: { + buyHold: parsedC.report.buyHold, + buyHoldPercent: parsedC.report.buyHoldPercent, + drawDown: parsedC.report.drawDown, + drawDownPercent: parsedC.report.drawDownPercent, + equity: parsedC.report.equity, + equityPercent: parsedC.report.equityPercent, + }, + + performance: parsedC.report.performance, + }; + + changes.push('fullReport'); + } + } + + this.#handleEvent('update', changes); + return; + } + + if (packet.type === 'study_error') { + this.#handleError(packet.data[3], packet.data[4]); + } + }; + + chartSession.send('create_study', [ + chartSession.sessionID, + `${this.#studID}`, + 'st1', + '$prices', + type, + getPineInputs(this.options), + ]); + } + + /** + * @param {PineIndicator} options Indicator options + */ + setIndicator(options) { + if (!(options instanceof PineIndicator)) { + throw new Error(`Study options must be an instance of PineIndicator. + Please use 'TradingView.getIndicator(...)' function.`); + } + + this.options = options; + + chartSession.send('modify_study', [ + chartSession.sessionID, + `${this.#studID}`, + 'st1', + getPineInputs(this.options), + ]); + } + + /** + * When the indicator is ready + * @param {() => void} cb + * @event + */ + onReady(cb) { + this.#callbacks.studyCompleted.push(cb); + } + + /** @typedef {'plots' | 'perfReport' | 'fullReport'} UpdateChangeType */ + + /** + * When an indicator update happens + * @param {(changes: UpdateChangeType[]) => void} cb + * @event + */ + onUpdate(cb) { + this.#callbacks.update.push(cb); + } + + /** + * When indicator error happens + * @param {(...any) => void} cb Callback + * @event + */ + onError(cb) { + this.#callbacks.error.push(cb); + } + + /** Remove the study */ + remove() { + chartSession.send('remove_study', [ + chartSession.sessionID, + this.#studID, + ]); + delete this.#studyListeners[this.#studID]; + } +}; diff --git a/src/classes/PineIndicator.js b/src/classes/PineIndicator.js new file mode 100644 index 0000000..b2e6e6f --- /dev/null +++ b/src/classes/PineIndicator.js @@ -0,0 +1,112 @@ +/** + * @typedef {Object} IndicatorInput + * @property {string} name Input name + * @property {string} inline Input inline name + * @property {string} [internalID] Input internal ID + * @property {string} [tooltip] Input tooltip + * @property {'text' | 'source' | 'integer' | 'float' | 'resolution' | 'bool'} type Input type + * @property {string | number | boolean} value Input default value + * @property {boolean} isHidden If the input is hidden + * @property {boolean} isFake If the input is fake + * @property {string[]} [options] Input options if the input is a select + */ + +/** + * @typedef {Object} Indicator + * @property {string} pineId Indicator ID + * @property {string} pineVersion Indicator version + * @property {string} description Indicator description + * @property {string} shortDescription Indicator short description + * @property {Object} inputs Indicator inputs + * @property {Object} plots Indicator plots + * @property {string} script Indicator script + */ + +module.exports = class PineIndicator { + #options; + + /** @param {Indicator} options Indicator */ + constructor(options) { + this.#options = options; + } + + /** @return {string} Indicator ID */ + get pineId() { + return this.#options.pineId; + } + + /** @return {string} Indicator version */ + get pineVersion() { + return this.#options.pineVersion; + } + + /** @return {string} Indicator description */ + get description() { + return this.#options.description; + } + + /** @return {string} Indicator short description */ + get shortDescription() { + return this.#options.shortDescription; + } + + /** @return {Object} Indicator inputs */ + get inputs() { + return this.#options.inputs; + } + + /** @return {Object} Indicator plots */ + get plots() { + return this.#options.plots; + } + + /** @return {string} Indicator script */ + get script() { + return this.#options.script; + } + + /** + * Set an option + * @param {number | string} key The key can be ID of the property (`in_{ID}`), + * the inline name or the internalID. + * @param {*} value The new value of the property + */ + setOption(key, value) { + let propI = ''; + + if (this.#options.inputs[`in_${key}`]) propI = `in_${key}`; + else if (this.#options.inputs[key]) propI = key; + else { + propI = Object.keys(this.#options.inputs).find((I) => ( + this.#options.inputs[I].inline === key + || this.#options.inputs[I].internalID === key + )); + } + + if (propI && this.#options.inputs[propI]) { + const input = this.#options.inputs[propI]; + + const types = { + bool: 'Boolean', + integer: 'Number', + float: 'Number', + text: 'String', + }; + + if ( + (input.type === 'bool' && typeof value !== 'boolean') + || (input.type === 'integer' && typeof value !== 'number') + || (input.type === 'float' && typeof value !== 'number') + || (input.type === 'text' && typeof value !== 'string') + ) { + throw new Error(`Input '${input.name}' (${propI}) must be a ${types[input.type]} !`); + } + + if (input.options && !input.options.includes(value)) { + throw new Error(`Input '${input.name}' (${propI}) must be one of these values:`, input.options); + } + + input.value = value; + } else throw new Error(`Input '${key}' not found (${propI}).`); + } +}; diff --git a/src/client.js b/src/client.js index d204888..d75e8aa 100644 --- a/src/client.js +++ b/src/client.js @@ -1,9 +1,10 @@ const WebSocket = require('ws'); const misc = require('./miscRequests'); +const protocol = require('./protocol'); const quoteSessionGenerator = require('./quote/session'); -const chartSessionGenerator = require('./quote/session'); +const chartSessionGenerator = require('./chart/session'); /** * @typedef {Object} Session @@ -152,60 +153,48 @@ module.exports = class Client { #parsePacket(str) { if (!this.isOpen) return; - str.replace(/~h~/g, '').split(/~m~[0-9]{1,}~m~/g) - .map((p) => { - if (!p) return false; - try { - return JSON.parse(p); - } catch (error) { - console.warn('Cant parse', p); - return false; - } - }) - .filter((p) => p) - .forEach((packet) => { - console.log('[CLIENT] PACKET', packet); - if (typeof packet === 'number') { // Ping - const pingStr = `~h~${packet}`; - this.#ws.send(`~m~${pingStr.length}~m~${pingStr}`); - this.#handleEvent('ping', packet); + protocol.parseWSPacket(str).forEach((packet) => { + console.log('§90§30§107 CLIENT §0 PACKET', packet); + if (typeof packet === 'number') { // Ping + this.#ws.send(protocol.formatWSPacket(`~h~${packet}`)); + this.#handleEvent('ping', packet); + return; + } + + if (packet.m === 'protocol_error') { // Error + this.#handleError('Client critical error:', packet.p); + this.#ws.close(); + return; + } + + if (packet.m && packet.p) { // Normal packet + const parsed = { + type: packet.m, + data: packet.p, + }; + + const session = packet.p[0]; + + if (session && this.#sessions[session]) { + this.#sessions[session].onData(parsed); return; } + } - if (packet.m === 'protocol_error') { // Error - this.#handleError('Client critical error:', packet.p); - this.#ws.close(); - return; - } - - if (packet.m && packet.p) { // Normal packet - const parsed = { - type: packet.m, - session: packet.p[0], - data: packet.p[1], - }; - - if (parsed.session && this.#sessions[parsed.session]) { - this.#sessions[parsed.session].onData(parsed); - return; - } - } - - if (!this.#logged) { - this.#handleEvent('logged', packet); - return; - } + if (!this.#logged) { + this.#handleEvent('logged', packet); + return; + } - this.#handleEvent('data', packet); - }); + this.#handleEvent('data', packet); + }); } #sendQueue = []; /** @type {SendPacket} Send a custom packet */ send(t, p = []) { - const msg = JSON.stringify({ m: t, p }); - this.#sendQueue.push(`~m~${msg.length}~m~${msg}`); + this.#sendQueue.push(protocol.formatWSPacket({ m: t, p })); this.sendQueue(); } diff --git a/src/miscRequests.js b/src/miscRequests.js index e3cc6be..ffb596b 100644 --- a/src/miscRequests.js +++ b/src/miscRequests.js @@ -1,5 +1,7 @@ const https = require('https'); +const PineIndicator = require('./classes/PineIndicator'); + const indicators = ['Recommend.Other', 'Recommend.All', 'Recommend.MA']; const indicList = []; @@ -132,7 +134,7 @@ module.exports = { }, /** - * @typedef {Object} SearchResult + * @typedef {Object} SearchMarketResult * @property {string} id * @property {string} exchange * @property {string} fullExchange @@ -147,9 +149,9 @@ module.exports = { * Find a symbol * @param {string} search Keywords * @param {'stock' | 'futures' | 'forex' | 'cfd' | 'crypto' | 'index' | 'economic'} [filter] - * @returns {Promise} Search results + * @returns {Promise} Search results */ - async search(search, filter = '') { + async searchMarket(search, filter = '') { const data = await request({ host: 'symbol-search.tradingview.com', path: `/symbol_search/?text=${search.replace(/ /g, '%20')}&type=${filter}`, @@ -179,7 +181,7 @@ module.exports = { }, /** - * @typedef {Object} IndicatorResult + * @typedef {Object} SearchIndicatorResult * @property {string} id Script ID * @property {string} version Script version * @property {string} name Script complete name @@ -188,12 +190,13 @@ module.exports = { * @property {string | ''} source Script source (if available) * @property {'study' | 'strategy'} type Script type (study / strategy) * @property {'open_source' | 'closed_source' | 'invite_only' | 'other'} access Script access type + * @property {() => Promise} get Get the full indicator informations */ /** * Find an indicator * @param {string} search Keywords - * @returns {Promise} Search results + * @returns {Promise} Search results */ async searchIndicator(search = '') { if (!indicList.length) { @@ -230,6 +233,9 @@ module.exports = { access: 'closed_source', source: '', type: (ind.extra && ind.extra.kind) ? ind.extra.kind : 'study', + get() { + return module.exports.getIndicator(ind.scriptIdPart, ind.version); + }, })), ...data.results.map((ind) => ({ @@ -244,42 +250,20 @@ module.exports = { access: ['open_source', 'closed_source', 'invite_only'][ind.access - 1] || 'other', source: ind.scriptSource, type: (ind.extra && ind.extra.kind) ? ind.extra.kind : 'study', + get() { + return module.exports.getIndicator(ind.scriptIdPart, ind.version); + }, })), ]; }, - /** - * @typedef {Object} indicatorInput - * @property {string} name - * @property {string} inline Inline name - * @property { 'text' | 'source' | 'integer' | 'float' | 'resolution' | 'bool' } type - * @property {string | number | boolean} value - * @property {string | number | boolean} defVal - * @property {boolean} hidden - * @property {boolean} isFake - * @property {string[]} [options] - */ - - /** - * @typedef {Object} Indicator - * @property {string} pineId Indicator ID - * @property {string} pineVersion Indicator version - * @property {string} description Indicator description - * @property {string} shortDescription Indicator short description - * @property {string} typeID Indicator script type ID - * @property {Object} inputs Indicator inputs - * @property {Object} plots Indicator plots - * @property {string} script Indicator script - */ - /** * Get an indicator * @param {string} id Indicator ID (Like: PUB;XXXXXXXXXXXXXXXXXXXXX) * @param {'last' | string} [version] Wanted version of the indicator - * @param {'study' | 'strategy'} [type] Script type - * @returns {Promise} Indicator + * @returns {Promise} Indicator */ - async getIndicator(id, version = 'last', settings = [], type = 'study') { + async getIndicator(id, version = 'last') { const indicID = id.replace(/ |%/g, '%25'); let data = await request({ @@ -294,7 +278,6 @@ module.exports = { } if (!data.success || !data.result.metaInfo || !data.result.metaInfo.inputs) { - console.error(data); throw new Error('Inexistent or unsupported indicator'); } @@ -302,18 +285,17 @@ module.exports = { data.result.metaInfo.inputs.forEach((input) => { if (['text', 'pineId', 'pineVersion'].includes(input.id)) return; - const inline = input.inline || input.name.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, ''); - - const i = parseInt(input.id.replace(/[^0-9]/g, ''), 10); inputs[input.id] = { name: input.name, + inline: input.inline || input.name.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, ''), + internalID: input.internalID, + tooltip: input.tooltip, + type: input.type, - value: settings[input.internalID] ?? settings[i] ?? input.defval, - defVal: input.defval, - hidden: !!input.isHidden, + value: input.defval, + isHidden: !!input.isHidden, isFake: !!input.isFake, - inline, }; if (input.options) inputs[input.id].options = input.options; @@ -325,19 +307,15 @@ module.exports = { plots[plotId] = data.result.metaInfo.styles[plotId].title.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, ''); }); - return { + return new PineIndicator({ pineId: indicID, pineVersion: version, description: data.result.metaInfo.description, shortDescription: data.result.metaInfo.shortDescription, - typeID: { - study: 'Script@tv-scripting-101!', - strategy: 'StrategyScript@tv-scripting-101!', - }[type] || type, inputs, plots, script: data.result.ilTemplate, - }; + }); }, /** diff --git a/src/protocol.js b/src/protocol.js new file mode 100644 index 0000000..a57d469 --- /dev/null +++ b/src/protocol.js @@ -0,0 +1,54 @@ +const JSZip = require('jszip'); + +/** + * @typedef {Object} TWPacket + * @prop {string} [m] Packet type + * @prop {[session: string, {}]} [p] Packet data + */ + +module.exports = { + /** + * Parse websocket packet + * @param {string} str Websocket raw data + * @returns {TWPacket[]} TradingView packets + */ + parseWSPacket(str) { + return str.replace(/~h~/g, '').split(/~m~[0-9]{1,}~m~/g) + .map((p) => { + if (!p) return false; + try { + return JSON.parse(p); + } catch (error) { + console.warn('Cant parse', p); + return false; + } + }) + .filter((p) => p); + }, + + /** + * Format websocket packet + * @param {TWPacket} packet TradingView packet + * @returns {string} Websocket raw data + */ + formatWSPacket(packet) { + const msg = typeof packet === 'object' + ? JSON.stringify(packet) + : packet; + return `~m~${msg.length}~m~${msg}`; + }, + + /** + * Parse compressed data + * @param {string} data Compressed data + * @returns {Promise<{}>} Parsed data + */ + async parseCompressed(data) { + const zip = new JSZip(); + return JSON.parse( + await ( + await zip.loadAsync(data, { base64: true }) + ).file('').async('text'), + ); + }, +}; diff --git a/src/quote/market.js b/src/quote/market.js index ff781e9..eb1d552 100644 --- a/src/quote/market.js +++ b/src/quote/market.js @@ -1,17 +1,19 @@ /** - * @typedef { 'loaded' | 'data' | 'error' } MarketEvent + * @typedef {'loaded' | 'data' | 'error'} MarketEvent */ /** * @param {import('./session').QuoteSessionBridge} quoteSession */ module.exports = (quoteSession) => class QuoteMarket { - #symbolList = quoteSession.symbols; + #symbolListeners = quoteSession.symbolListeners; #symbol; #symbolListenerID = 0; + #lastData = {}; + #callbacks = { loaded: [], data: [], @@ -20,8 +22,6 @@ module.exports = (quoteSession) => class QuoteMarket { error: [], }; - #lastData = {}; - /** * @param {MarketEvent} ev Client event * @param {...{}} data Packet data @@ -42,36 +42,35 @@ module.exports = (quoteSession) => class QuoteMarket { constructor(symbol) { this.#symbol = symbol; - if (!this.#symbolList[symbol]) { - this.#symbolList[symbol] = []; + if (!this.#symbolListeners[symbol]) { + this.#symbolListeners[symbol] = []; quoteSession.send('quote_add_symbols', [ quoteSession.sessionID, symbol, ]); } - this.#symbolListenerID = this.#symbolList[symbol].length; + this.#symbolListenerID = this.#symbolListeners[symbol].length; - this.#symbolList[symbol][this.#symbolListenerID] = (packet) => { - console.log('[MARKET] DATA', packet); + this.#symbolListeners[symbol][this.#symbolListenerID] = (packet) => { + console.log('§90§30§105 MARKET §0 DATA', packet); - if (packet.type === 'qsd' && packet.data.s === 'ok') { + if (packet.type === 'qsd' && packet.data[1].s === 'ok') { this.#lastData = { ...this.#lastData, - ...packet.data.v, + ...packet.data[1].v, }; this.#handleEvent('data', this.#lastData); return; } - if (packet.type === 'qsd' && packet.data.s === 'error') { - this.#handleError('Market error', packet.data); - return; - } - if (packet.type === 'quote_completed') { this.#handleEvent('loaded'); return; } + + if (packet.type === 'qsd' && packet.data[1].s === 'error') { + this.#handleError('Market error', packet.data); + } }; } @@ -113,12 +112,12 @@ module.exports = (quoteSession) => class QuoteMarket { /** Close this listener */ close() { - if (this.#symbolList[this.#symbol].length <= 1) { + if (this.#symbolListeners[this.#symbol].length <= 1) { quoteSession.send('quote_remove_symbols', [ quoteSession.sessionID, this.#symbol, ]); } - delete this.#symbolList[this.#symbol][this.#symbolListenerID]; + delete this.#symbolListeners[this.#symbol][this.#symbolListenerID]; } }; diff --git a/src/quote/session.js b/src/quote/session.js index b176ca6..1cc647e 100644 --- a/src/quote/session.js +++ b/src/quote/session.js @@ -2,12 +2,12 @@ const { genSessionID } = require('../utils'); const quoteMarketConstructor = require('./market'); -/** @typedef {Object} SymbolList */ +/** @typedef {Object} SymbolListeners */ /** * @typedef {Object} QuoteSessionBridge * @prop {string} sessionID - * @prop {SymbolList} symbols + * @prop {SymbolListeners} symbolListeners * @prop {import('../client').SendPacket} send */ @@ -51,38 +51,49 @@ function getQuoteFields(fieldsType) { ]; } -/** - * @typedef {Object} quoteSessionOptions Quote Session options - * @prop {'all' | 'price'} [fields] Asked quote fields - * @prop {quoteField[]} [customFields] List of asked quote fields - */ - /** * @param {import('../client').ClientBridge} client */ module.exports = (client) => class QuoteSession { #sessionID = genSessionID('qs'); + /** Parent client */ #client = client; - /** @type {SymbolList} */ - #symbols = {}; + /** @type {SymbolListeners} */ + #symbolListeners = {}; + + /** + * @typedef {Object} quoteSessionOptions Quote Session options + * @prop {'all' | 'price'} [fields] Asked quote fields + * @prop {quoteField[]} [customFields] List of asked quote fields + */ /** * @param {quoteSessionOptions} options Quote settings options */ - constructor(options) { + constructor(options = {}) { this.#client.sessions[this.#sessionID] = { type: 'quote', onData: (packet) => { - console.log('[QUOTE SESSION] DATA', packet); - - if (packet.type === 'quote_completed' && this.#symbols[packet.data]) { - this.#symbols[packet.data].forEach((h) => h(packet)); + console.log('§90§30§102 QUOTE SESSION §0 DATA', packet); + + if (packet.type === 'quote_completed') { + const symbol = packet.data[1]; + if (!this.#symbolListeners[symbol]) { + this.#client.send('quote_remove_symbols', [this.#sessionID, symbol]); + return; + } + this.#symbolListeners[symbol].forEach((h) => h(packet)); } - if (packet.type === 'qsd' && this.#symbols[packet.data.n]) { - this.#symbols[packet.data.n].forEach((h) => h(packet)); + if (packet.type === 'qsd') { + const symbol = packet.data[1].n; + if (!this.#symbolListeners[symbol]) { + this.#client.send('quote_remove_symbols', [this.#sessionID, symbol]); + return; + } + this.#symbolListeners[symbol].forEach((h) => h(packet)); } }, }; @@ -99,7 +110,7 @@ module.exports = (client) => class QuoteSession { /** @type {QuoteSessionBridge} */ #quoteSession = { sessionID: this.#sessionID, - symbols: this.#symbols, + symbolListeners: this.#symbolListeners, send: (t, p) => this.#client.send(t, p), }; @@ -108,8 +119,6 @@ module.exports = (client) => class QuoteSession { /** Delete the quote session */ delete() { - this.#quoteSession.send('quote_delete_session', [ - this.#quoteSession.sessionID, - ]); + this.#client.send('quote_delete_session', [this.#sessionID]); } }; diff --git a/src/types.js b/src/types.js index 5ddba77..cb63c95 100644 --- a/src/types.js +++ b/src/types.js @@ -1,5 +1,32 @@ /** * @typedef {string} MarketSymbol Market symbol (like: 'BTCEUR' or 'KRAKEN:BTCEUR') + * + * @typedef {'Etc/UTC' | 'exchange' + * | 'Pacific/Honolulu' | 'America/Juneau' | 'America/Los_Angeles' + * | 'America/Phoenix' | 'America/Vancouver' | 'US/Mountain' + * | 'America/El_Salvador' | 'America/Bogota' | 'America/Chicago' + * | 'America/Lima' | 'America/Mexico_City' | 'America/Caracas' + * | 'America/New_York' | 'America/Toronto' | 'America/Argentina/Buenos_Aires' + * | 'America/Santiago' | 'America/Sao_Paulo' | 'Atlantic/Reykjavik' + * | 'Europe/Dublin' | 'Africa/Lagos' | 'Europe/Lisbon' | 'Europe/London' + * | 'Europe/Amsterdam' | 'Europe/Belgrade' | 'Europe/Berlin' + * | 'Europe/Brussels' | 'Europe/Copenhagen' | 'Africa/Johannesburg' + * | 'Africa/Cairo' | 'Europe/Luxembourg' | 'Europe/Madrid' | 'Europe/Malta' + * | 'Europe/Oslo' | 'Europe/Paris' | 'Europe/Rome' | 'Europe/Stockholm' + * | 'Europe/Warsaw' | 'Europe/Zurich' | 'Europe/Athens' | 'Asia/Bahrain' + * | 'Europe/Helsinki' | 'Europe/Istanbul' | 'Asia/Jerusalem' | 'Asia/Kuwait' + * | 'Europe/Moscow' | 'Asia/Qatar' | 'Europe/Riga' | 'Asia/Riyadh' + * | 'Europe/Tallinn' | 'Europe/Vilnius' | 'Asia/Tehran' | 'Asia/Dubai' + * | 'Asia/Muscat' | 'Asia/Ashkhabad' | 'Asia/Kolkata' | 'Asia/Almaty' + * | 'Asia/Bangkok' | 'Asia/Jakarta' | 'Asia/Ho_Chi_Minh' | 'Asia/Chongqing' + * | 'Asia/Hong_Kong' | 'Australia/Perth' | 'Asia/Shanghai' | 'Asia/Singapore' + * | 'Asia/Taipei' | 'Asia/Seoul' | 'Asia/Tokyo' | 'Australia/Brisbane' + * | 'Australia/Adelaide' | 'Australia/Sydney' | 'Pacific/Norfolk' + * | 'Pacific/Auckland' | 'Pacific/Fakaofo' | 'Pacific/Chatham'} Timezone (Chart) timezone + * + * @typedef {'1' | '3' | '5' | '15' | '30' + * | '45' | '60' | '120' | '180' | '240' + * | '1D' | '1W' | '1M'} TimeFrame */ module.exports = {}; diff --git a/test.js b/test.js index e983806..73e44c8 100644 --- a/test.js +++ b/test.js @@ -1,34 +1,154 @@ +require('@mathieuc/console')( + '§', // Character you want to use (defaut: '§') + true, // Active timestamp (defaut: false) +); + const TradingView = require('./main'); -const client = new TradingView.Client({ - token: null, -}); +const log = (...msg) => console.log('§90§30§103 TEST §0', ...msg); + +const client = new TradingView.Client(); client.onEvent((event, data) => { - console.log('[TEST] EVENT:', event, data); + log('EVENT:', event, data); }); -const quoteSession = new client.Session.Quote({ - fields: 'price', +client.onError((...error) => { + log(...error); }); -// const quoteSession2 = new client.Session.Quote({ +// const quoteSession = new client.Session.Quote({ // fields: 'price', // }); -const BTC = new quoteSession.Market('BTCEUR'); -BTC.onData((data) => { - console.log('[TEST] BTCEUR DATA', data); +// const BTC = new quoteSession.Market('BTCEUR'); + +// BTC.onLoaded(() => { +// log('BTCEUR LOADED !'); +// }); + +// BTC.onData((data) => { +// log('BTCEUR DATA:', data); +// }); + +// BTC.onError((...err) => { +// log('BTCEUR ERROR:', err); +// }); + +const chart = new client.Session.Chart(); +chart.setMarket('COINBASE:BTCEUR'); +chart.setSeries('60'); + +chart.onSymbolLoaded((market) => { + log('Market loaded:', market.full_name); }); -BTC.onError((...err) => { - console.log('[TEST] BTCEUR ERROR', err); + +chart.onError((...err) => { + log('CHART ERROR:', ...err); }); -// const BTC = new quoteSession.Symbol('BTCEUR'); -// BTC.onData((symbol) => { -// console.log(symbol); -// }); +chart.onUpdate(() => { + const last = chart.periods[0]; + log(`Market last period: ${last.close}`); +}); + +TradingView.getIndicator('STD;Supertrend%Strategy').then((indicator) => { + indicator.setOption('commission_type', 'percent'); + indicator.setOption('commission_value', 0); + indicator.setOption('initial_capital', 25000); + indicator.setOption('default_qty_value', 20); + indicator.setOption('default_qty_type', 'percent_of_equity'); + indicator.setOption('currency', 'EUR'); + indicator.setOption('pyramiding', 10); + + const SuperTrend = new chart.Study(indicator); + + SuperTrend.onReady(() => { + log('SuperTrend ready !'); + }); + + SuperTrend.onError((...err) => { + log('SuperTrend ERROR:', err[0]); + }); + + // let QTY = 10; + // setInterval(() => { + // QTY += 10; + // console.log('TRY WITH', QTY, '%'); + // indicator.setOption('default_qty_value', QTY); + + // SuperTrend.setIndicator(indicator); + // }, 5000); + + SuperTrend.onUpdate((changes) => { + // MarketCipher B is a strategy so it sends a strategy report + log('SuperTrend update:', changes); + + const perfReport = SuperTrend.strategyReport.performance; + + log('Performance report:', { + total: { + trades: perfReport.all.totalTrades, + perf: `${Math.round(perfReport.all.netProfitPercent * 10000) / 100} %`, + profit: `${Math.round(perfReport.all.netProfit * 1000) / 1000} €`, + }, + buy: { + trades: perfReport.long.totalTrades, + perf: `${Math.round(perfReport.long.netProfitPercent * 10000) / 100} %`, + profit: `${Math.round(perfReport.long.netProfit * 1000) / 1000} €`, + }, + sell: { + trades: perfReport.short.totalTrades, + perf: `${Math.round(perfReport.short.netProfitPercent * 10000) / 100} %`, + profit: `${Math.round(perfReport.short.netProfit * 1000) / 1000} €`, + }, + }); + + if (changes.includes('fullReport')) { + log('Last trade:', SuperTrend.strategyReport.trades[0]); + // Do something... + + // // Remove SuperTrend stratefgy from the chart + // SuperTrend.remove(); + } + }); +}); + +TradingView.getIndicator('PUB;uA35GeckoTA2EfgI63SD2WCSmca4njxp').then((indicator) => { + indicator.setOption('Show_WT_Hidden_Divergences', true); + indicator.setOption('Show_Stoch_Regular_Divergences', true); + indicator.setOption('Show_Stoch_Hidden_Divergences', true); + + const CipherB = new chart.Study(indicator); + + CipherB.onReady(() => { + log('MarketCipher B ready !'); + }); + + CipherB.onError((...err) => { + log('MarketCipher B ERROR:', err[0]); + }); + + CipherB.onUpdate(() => { + const last = CipherB.periods[0]; + // MarketCipher B is not a strategy so it only sends plots values + log('MarketCipher B last values:', { + VWAP: Math.round(last.VWAP * 1000) / 1000, + moneyFlow: (last.RSIMFIArea >= 0) ? 'POSITIVE' : 'NEGATIVE', + buyCircle: last.Buy_and_sell_circle && last.VWAP > 0, + sellCircle: last.Buy_and_sell_circle && last.VWAP < 0, + }); + + // Do something... + }); +}); // setTimeout(() => { -// BTC.close(); +// log('Set timeframe to 240...'); +// chart.setSeries('240'); // }, 5000); + +// setInterval(() => { +// log('Fetch 100 more periods...'); +// chart.fetchMore(100); +// }, 10000); From 763abcbb80779f1af7a2bd5b86726acbd4828ca7 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Mon, 25 Oct 2021 14:13:39 +0200 Subject: [PATCH 03/16] JSDoc fix --- src/miscRequests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/miscRequests.js b/src/miscRequests.js index ffb596b..fa2aa4f 100644 --- a/src/miscRequests.js +++ b/src/miscRequests.js @@ -190,7 +190,7 @@ module.exports = { * @property {string | ''} source Script source (if available) * @property {'study' | 'strategy'} type Script type (study / strategy) * @property {'open_source' | 'closed_source' | 'invite_only' | 'other'} access Script access type - * @property {() => Promise} get Get the full indicator informations + * @property {() => Promise} get Get the full indicator informations */ /** From 6b92ff00b71665e0a13296153a44181045549081 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Fri, 5 Nov 2021 16:47:00 +0100 Subject: [PATCH 04/16] Allow custom chart types Allow custom chart types such as : - Heikin Ashi - Renko - Line Break - Kagi - Point & Figure - Range --- src/chart/session.js | 53 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/src/chart/session.js b/src/chart/session.js index ed55189..c1669a5 100644 --- a/src/chart/session.js +++ b/src/chart/session.js @@ -2,6 +2,35 @@ const { genSessionID } = require('../utils'); const studyConstructor = require('./study'); +/** + * @typedef {'HeikinAshi' | 'Renko' | 'LineBreak' | 'Kagi'} ChartType Custom chart type + */ + +const ChartTypes = { + HeikinAshi: 'BarSetHeikenAshi@tv-basicstudies-60!', + Renko: 'BarSetRenko@tv-prostudies-40!', + LineBreak: 'BarSetPriceBreak@tv-prostudies-34!', + Kagi: 'BarSetKagi@tv-prostudies-34!', + PointAndFigure: 'BarSetPnF@tv-prostudies-34!', + Range: 'BarSetRange@tv-basicstudies-72!', +}; + +/** + * @typedef {Object} ChartInputs Custom chart type + * @prop {number} [atrLength] Renko/Kagi/PointAndFigure ATR length + * @prop {'open' | 'high' | 'low' | 'close' | 'hl2' + * | 'hlc3' | 'ohlc4'} [source] Renko/LineBreak/Kagi source + * @prop {'ATR' | string} [style] Renko/Kagi/PointAndFigure style + * @prop {number} [boxSize] Renko/PointAndFigure box size + * @prop {number} [reversalAmount] Kagi/PointAndFigure reversal amount + * @prop {'Close'} [sources] Renko/PointAndFigure sources + * @prop {boolean} [wicks] Renko wicks + * @prop {number} [lb] LineBreak Line break + * @prop {boolean} [oneStepBackBuilding] PointAndFigure oneStepBackBuilding + * @prop {boolean} [phantomBars] Range phantom bars + * @prop {boolean} [range] Range range + */ + /** @typedef {Object} StudyListeners */ /** @@ -213,17 +242,37 @@ module.exports = (client) => class ChartSession { /** * Set the chart market * @param {string} symbol Market symbol - * @param {Object} [options] Market options + * @param {Object} [options] Chart options * @param {'splits' | 'dividends'} [options.adjustment] Market adjustment + * @param {'regular' | 'extended'} [options.session] Chart session + * @param {'EUR' | 'USD' | string} [options.currency] Chart currency + * @param {ChartType} [options.type] Chart custom type + * @param {ChartInputs} [options.inputs] Chart custom inputs * @param {string} [options.series] Series ID (Default: 'sds_sym_1') */ setMarket(symbol, options = {}) { this.#periods = {}; + const symbolInit = { + symbol: symbol || 'BTCEUR', + adjustment: options.adjustment || 'splits', + session: options.session || 'regular', + }; + + if (options.currency) symbolInit['currency-id'] = options.currency; + + const chartInit = (options.type && ChartTypes[options.type]) ? {} : symbolInit; + + if (options.type && ChartTypes[options.type]) { + chartInit.symbol = symbolInit; + chartInit.type = ChartTypes[options.type]; + chartInit.inputs = { ...options.inputs }; + } + this.#client.send('resolve_symbol', [ this.#sessionID, options.series || 'sds_sym_1', - `={"symbol":"${symbol || 'BTCEUR'}","adjustment":"${options.adjustment || 'splits'}","session":"regular"}`, + `=${JSON.stringify(chartInit)}`, ]); } From 045dfdbe7387a377abac247cbd45eb53f01aba5e Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Fri, 5 Nov 2021 20:29:48 +0100 Subject: [PATCH 05/16] Add getPrivateIndicators function --- src/miscRequests.js | 54 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/src/miscRequests.js b/src/miscRequests.js index fa2aa4f..789e5e5 100644 --- a/src/miscRequests.js +++ b/src/miscRequests.js @@ -3,7 +3,7 @@ const https = require('https'); const PineIndicator = require('./classes/PineIndicator'); const indicators = ['Recommend.Other', 'Recommend.All', 'Recommend.MA']; -const indicList = []; +const builtInIndicList = []; /** * @param {https.RequestOptions} options HTTPS Request options @@ -189,7 +189,8 @@ module.exports = { * @property {string} image Image ID https://tradingview.com/i/${image} * @property {string | ''} source Script source (if available) * @property {'study' | 'strategy'} type Script type (study / strategy) - * @property {'open_source' | 'closed_source' | 'invite_only' | 'other'} access Script access type + * @property {'open_source' | 'closed_source' | 'invite_only' + * | 'private' | 'other'} access Script access type * @property {() => Promise} get Get the full indicator informations */ @@ -199,9 +200,9 @@ module.exports = { * @returns {Promise} Search results */ async searchIndicator(search = '') { - if (!indicList.length) { + if (!builtInIndicList.length) { await Promise.all(['standard', 'candlestick', 'fundamental'].map(async (type) => { - indicList.push(...await request({ + builtInIndicList.push(...await request({ host: 'pine-facade.tradingview.com', path: `/pine-facade/list/?filter=${type}`, })); @@ -218,7 +219,7 @@ module.exports = { } return [ - ...indicList.filter((i) => ( + ...builtInIndicList.filter((i) => ( norm(i.scriptName).includes(norm(search)) || norm(i.extra.shortDescription).includes(norm(search)) )).map((ind) => ({ @@ -382,6 +383,49 @@ module.exports = { }); }, + /** + * Get user's private indicators from a 'sessionid' cookie + * @param {string} session User 'sessionid' cookie + * @returns {Promise} Search results + */ + async getPrivateIndicators(session) { + return new Promise((cb, err) => { + https.get('https://pine-facade.tradingview.com/pine-facade/list?filter=saved', { + headers: { cookie: `sessionid=${session}` }, + }, (res) => { + let rs = ''; + res.on('data', (d) => { rs += d; }); + res.on('end', async () => { + try { + rs = JSON.parse(rs); + } catch (error) { + err(new Error('Can\'t parse private indicator list')); + return; + } + + cb(rs.map((ind) => ({ + id: ind.scriptIdPart, + version: ind.version, + name: ind.scriptName, + author: { + id: -1, + username: '@ME@', + }, + image: ind.imageUrl, + access: 'private', + source: ind.scriptSource, + type: (ind.extra && ind.extra.kind) ? ind.extra.kind : 'study', + get() { + return module.exports.getIndicator(ind.scriptIdPart, ind.version); + }, + }))); + }); + + res.on('error', err); + }).end(); + }); + }, + /** * User credentials * @typedef {Object} UserCredentials From 7d33cabf46607bb01bb565785b789878a85ced94 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Sun, 7 Nov 2021 22:46:41 +0100 Subject: [PATCH 06/16] Add examples + Series bug fix Better chart initialization: please check the examples --- examples/AllPrivateIndicators.js | 35 +++++++ examples/Errors.js | 153 +++++++++++++++++++++++++++++++ examples/SimpleChart.js | 63 +++++++++++++ src/chart/session.js | 61 ++++++++---- src/chart/study.js | 2 +- src/client.js | 7 +- src/quote/market.js | 2 +- src/quote/session.js | 2 +- src/types.js | 2 +- 9 files changed, 305 insertions(+), 22 deletions(-) create mode 100644 examples/AllPrivateIndicators.js create mode 100644 examples/Errors.js create mode 100644 examples/SimpleChart.js diff --git a/examples/AllPrivateIndicators.js b/examples/AllPrivateIndicators.js new file mode 100644 index 0000000..a7cca85 --- /dev/null +++ b/examples/AllPrivateIndicators.js @@ -0,0 +1,35 @@ +const TradingView = require('../main'); + +/* + This example creates a chart with + all user's private indicators +*/ + +const client = new TradingView.Client({ + /* Token is only needed if at least one indicator is + PRIVATE (if you have a paid TradingView account) */ + token: 'YOUR_SESSION_ID_COOKIE', +}); + +const chart = new client.Session.Chart(); +chart.setMarket('BINANCE:BTCEUR', { + timeframe: 'D', +}); + +TradingView.getPrivateIndicators('YOUR_SESSION_ID_COOKIE').then((indicList) => { + indicList.forEach(async (indic) => { + const privateIndic = await indic.get(); + console.log('Loading indicator', indic.name, '...'); + + const indicator = new chart.Study(privateIndic); + + indicator.onReady(() => { + console.log('Indicator', indic.name, 'loaded !'); + }); + + indicator.onUpdate(() => { + console.log('Plot values', indicator.periods); + console.log('Strategy report', indicator.strategyReport); + }); + }); +}); diff --git a/examples/Errors.js b/examples/Errors.js new file mode 100644 index 0000000..9b9bdd2 --- /dev/null +++ b/examples/Errors.js @@ -0,0 +1,153 @@ +const TradingView = require('../main'); + +/* + This example tests many types of errors +*/ + +const client = new TradingView.Client(); // Creates a websocket client + +const tests = [ + (next) => { /* Testing "Credentials error" */ + console.info('\nTesting "Credentials error" error:'); + + const client2 = new TradingView.Client({ + token: 'FAKE_CREDENTIALS', // Set wrong credentials + }); + + client2.onError((...err) => { + console.error(' => Client error:', err); + client2.end(); + next(); + }); + }, + + (next) => { /* Testing "Invalid symbol" */ + console.info('\nTesting "Invalid symbol" error:'); + + const chart = new client.Session.Chart(); + chart.onError((...err) => { // Listen for errors + console.error(' => Chart error:', err); + chart.delete(); + next(); + }); + + chart.setMarket('XXXXX'); // Set a wrong market + }, + + (next) => { /* Testing "Invalid timezone" */ + console.info('\nTesting "Invalid timezone" error:'); + + const chart = new client.Session.Chart(); + chart.onError((...err) => { // Listen for errors + console.error(' => Chart error:', err); + next(); + }); + + chart.setMarket('BINANCE:BTCEUR'); // Set a market + chart.setTimezone('Nowhere/Nowhere'); // Set a fake timezone + }, + + (next) => { /* Testing "Custom timeframe" */ + console.info('\nTesting "Custom timeframe" error:'); + + const chart = new client.Session.Chart(); + chart.onError((...err) => { // Listen for errors + console.error(' => Chart error:', err); + chart.delete(); + next(); + }); + + chart.setMarket('BINANCE:BTCEUR', { // Set a market + timeframe: '20', // Set a custom timeframe + /* + Timeframe '20' isn't available because we are + not logged in as a premium TradingView user + */ + }); + }, + + (next) => { /* Testing "Invalid timeframe" */ + console.info('\nTesting "Invalid timeframe" error:'); + + const chart = new client.Session.Chart(); + chart.onError((...err) => { // Listen for errors + console.error(' => Chart error:', err); + next(); + }); + + chart.setMarket('BINANCE:BTCEUR', { // Set a market + timeframe: 'XX', // Set a wrong timeframe + }); + }, + + (next) => { /* Testing "Study not auth" */ + console.info('\nTesting "Study not auth" error:'); + + const chart = new client.Session.Chart(); + chart.onError((...err) => { // Listen for errors + console.error(' => Chart error:', err); + next(); + }); + + chart.setMarket('BINANCE:BTCEUR', { // Set a market + timeframe: '15', + type: 'Renko', + }); + + chart.onUpdate(() => { + console.log('DATA', chart.periods[0]); + }); + }, + + (next) => { /* Testing "Set the market before" */ + console.info('\nTesting "Set the market before..." error:'); + + const chart = new client.Session.Chart(); + chart.onError((...err) => { // Listen for errors + console.error(' => Chart error:', err); + chart.delete(); + next(); + }); + + chart.setSeries('15'); // Set series before setting the market + }, + + (next) => { /* Testing "Inexistent indicator" */ + console.info('\nTesting "Inexistent indicator" error:'); + + TradingView.getIndicator('STD;XXXXXXX') + .catch((err) => { + console.error(' => API error:', [err.message]); + next(); + }); + }, + + async (next) => { /* Testing "Invalid value" */ + console.info('\nTesting "Invalid value" error:'); + + const chart = new client.Session.Chart(); + chart.setMarket('BINANCE:BTCEUR'); // Set a market + + const ST = await TradingView.getIndicator('STD;Supertrend'); + ST.setOption('Factor', -1); // This will cause an error + + const Supertrend = new chart.Study(ST); + Supertrend.onError((...err) => { + console.error(' => Study error:', err); + chart.delete(); + next(); + }); + }, +]; + +let i = 0; +const execTest = () => { + if (tests[i + 1]) { + tests[(i += 1) - 1](execTest); + } else { + tests[(i += 1) - 1](() => { + console.log(`\nTests ${i}/${i} done !`); + }); + } +}; +execTest(); diff --git a/examples/SimpleChart.js b/examples/SimpleChart.js new file mode 100644 index 0000000..26d15e6 --- /dev/null +++ b/examples/SimpleChart.js @@ -0,0 +1,63 @@ +const TradingView = require('../main'); + +/* + This example creates a BTCEUR daily chart +*/ + +const client = new TradingView.Client(); // Creates a websocket client + +const chart = new client.Session.Chart(); // Init a Chart session + +chart.setMarket('BINANCE:BTCEUR', { // Set the market + timeframe: 'D', +}); + +chart.onError((...err) => { // Listen for errors (can avoid crash) + console.error('Chart error:', ...err); + // Do something... +}); + +chart.onSymbolLoaded(() => { // When the symbol is successfully loaded + console.log(`Market "${chart.infos.description}" loaded !`); +}); + +chart.onUpdate(() => { // When price changes + if (!chart.periods[0]) return; + console.log(`[${chart.infos.description}]: ${chart.periods[0].close} ${chart.infos.currency_id}`); + // Do something... +}); + +// Wait 5 seconds and set the market to BINANCE:ETHEUR +setTimeout(() => { + console.log('\nSetting market to BINANCE:ETHEUR...'); + chart.setMarket('BINANCE:ETHEUR', { + timeframe: 'D', + }); +}, 5000); + +// Wait 10 seconds and set the timeframe to 15 minutes +setTimeout(() => { + console.log('\nSetting timeframe to 15 minutes...'); + chart.setSeries('15'); +}, 10000); + +// Wait 15 seconds and set the chart type to "Heikin Ashi" +setTimeout(() => { + console.log('\nSetting the chart type to "Heikin Ashi"s...'); + chart.setMarket('BINANCE:ETHEUR', { + timeframe: 'D', + type: 'HeikinAshi', + }); +}, 15000); + +// Wait 20 seconds and close the chart +setTimeout(() => { + console.log('\nClosing the chart...'); + chart.delete(); +}, 20000); + +// Wait 25 seconds and close the client +setTimeout(() => { + console.log('\nClosing the client...'); + client.end(); +}, 25000); diff --git a/src/chart/session.js b/src/chart/session.js index c1669a5..fa7b23b 100644 --- a/src/chart/session.js +++ b/src/chart/session.js @@ -3,7 +3,8 @@ const { genSessionID } = require('../utils'); const studyConstructor = require('./study'); /** - * @typedef {'HeikinAshi' | 'Renko' | 'LineBreak' | 'Kagi'} ChartType Custom chart type + * @typedef {'HeikinAshi' | 'Renko' | 'LineBreak' | 'Kagi' | 'PointAndFigure' + * | 'Range'} ChartType Custom chart type */ const ChartTypes = { @@ -63,7 +64,7 @@ const ChartTypes = { * @prop {string} session-display Session display (ex: '24x7') * * @typedef {Object} MarketInfos - * @prop {string} series_id Used series (ex: 'sds_sym_1') + * @prop {string} series_id Used series (ex: 'ser_1') * @prop {string} base_currency Base currency (ex: 'BTC') * @prop {string} base_currency_id Base currency ID (ex: 'XTVCBTC') * @prop {string} name Market short name (ex: 'BTCEUR') @@ -129,6 +130,17 @@ module.exports = (client) => class ChartSession { return Object.values(this.#periods).sort((a, b) => b.time - a.time); } + /** + * Current market infos + * @type {MarketInfos} + */ + #infos = {}; + + /** @return {MarketInfos} Current market infos */ + get infos() { + return this.#infos; + } + #callbacks = { seriesLoaded: [], symbolLoaded: [], @@ -156,7 +168,7 @@ module.exports = (client) => class ChartSession { this.#client.sessions[this.#sessionID] = { type: 'chart', onData: (packet) => { - console.log('§90§30§106 CHART SESSION §0 DATA', packet); + if (global.TW_DEBUG) console.log('§90§30§106 CHART SESSION §0 DATA', packet); if (typeof packet.data[1] === 'string' && this.#studyListeners[packet.data[1]]) { this.#studyListeners[packet.data[1]](packet); @@ -164,10 +176,12 @@ module.exports = (client) => class ChartSession { } if (packet.type === 'symbol_resolved') { - this.#handleEvent('symbolLoaded', { + this.#infos = { series_id: packet.data[1], ...packet.data[2], - }); + }; + + this.#handleEvent('symbolLoaded'); return; } @@ -204,7 +218,7 @@ module.exports = (client) => class ChartSession { } if (packet.type === 'series_error') { - this.#handleError('Series error:', packet.data); + this.#handleError('Series error:', packet.data[3]); return; } @@ -218,37 +232,45 @@ module.exports = (client) => class ChartSession { this.#client.send('chart_create_session', [this.#sessionID]); } - #series = [] + #seriesCreated = false; + + #currentSeries = 0; /** * @param {import('../types').TimeFrame} timeframe Chart period timeframe * @param {number} [range] Number of loaded periods/candles (Default: 100) - * @param {string} [ID] Series ID (Default: 'sds_sym_1') */ - setSeries(timeframe = '240', range = 100, ID = 'sds_sym_1') { + setSeries(timeframe = '240', range = 100) { + if (!this.#currentSeries) { + this.#handleError('Please set the market before setting series'); + return; + } + this.#periods = {}; - this.#client.send(`${this.#series.includes(ID) ? 'modify' : 'create'}_series`, [ + + this.#client.send(`${this.#seriesCreated ? 'modify' : 'create'}_series`, [ this.#sessionID, '$prices', 's1', - ID, + `ser_${this.#currentSeries}`, timeframe, - !this.#series.includes(ID) ? range : '', + !this.#seriesCreated ? range : '', ]); - if (!this.#series.includes(ID)) this.#series.push(ID); + this.#seriesCreated = true; } /** * Set the chart market * @param {string} symbol Market symbol * @param {Object} [options] Chart options + * @param {import('../types').TimeFrame} [options.timeframe] Chart period timeframe + * @param {number} [options.range] Number of loaded periods/candles (Default: 100) * @param {'splits' | 'dividends'} [options.adjustment] Market adjustment * @param {'regular' | 'extended'} [options.session] Chart session * @param {'EUR' | 'USD' | string} [options.currency] Chart currency * @param {ChartType} [options.type] Chart custom type * @param {ChartInputs} [options.inputs] Chart custom inputs - * @param {string} [options.series] Series ID (Default: 'sds_sym_1') */ setMarket(symbol, options = {}) { this.#periods = {}; @@ -256,9 +278,10 @@ module.exports = (client) => class ChartSession { const symbolInit = { symbol: symbol || 'BTCEUR', adjustment: options.adjustment || 'splits', - session: options.session || 'regular', }; + if (options.session) symbolInit.session = options.session; + if (options.currency) symbolInit['currency-id'] = options.currency; const chartInit = (options.type && ChartTypes[options.type]) ? {} : symbolInit; @@ -269,11 +292,15 @@ module.exports = (client) => class ChartSession { chartInit.inputs = { ...options.inputs }; } + this.#currentSeries += 1; + this.#client.send('resolve_symbol', [ this.#sessionID, - options.series || 'sds_sym_1', + `ser_${this.#currentSeries}`, `=${JSON.stringify(chartInit)}`, ]); + + this.setSeries(options.timeframe, options.range); } /** @@ -295,7 +322,7 @@ module.exports = (client) => class ChartSession { /** * When a symbol is loaded - * @param {(marketInfos: MarketInfos) => void} cb + * @param {() => void} cb * @event */ onSymbolLoaded(cb) { diff --git a/src/chart/study.js b/src/chart/study.js index 510d7bd..1dc55aa 100644 --- a/src/chart/study.js +++ b/src/chart/study.js @@ -173,7 +173,7 @@ module.exports = (chartSession) => class ChartStudy { this.options = options; this.#studyListeners[this.#studID] = async (packet) => { - console.log('§90§30§105 STUDY §0 DATA', packet); + if (global.TW_DEBUG) console.log('§90§30§105 STUDY §0 DATA', packet); if (packet.type === 'study_completed') { this.#handleEvent('studyCompleted'); diff --git a/src/client.js b/src/client.js index d75e8aa..8f23ae2 100644 --- a/src/client.js +++ b/src/client.js @@ -154,7 +154,7 @@ module.exports = class Client { if (!this.isOpen) return; protocol.parseWSPacket(str).forEach((packet) => { - console.log('§90§30§107 CLIENT §0 PACKET', packet); + if (global.TW_DEBUG) console.log('§90§30§107 CLIENT §0 PACKET', packet); if (typeof packet === 'number') { // Ping this.#ws.send(protocol.formatWSPacket(`~h~${packet}`)); this.#handleEvent('ping', packet); @@ -209,12 +209,15 @@ module.exports = class Client { /** * @typedef {Object} ClientOptions * @prop {string} [token] User auth token (in 'sessionid' cookie) + * @prop {boolean} [DEBUG] Enable debug mode */ /** Client object * @param {ClientOptions} clientOptions TradingView client options */ constructor(clientOptions = {}) { + if (clientOptions.DEBUG) global.TW_DEBUG = clientOptions.DEBUG; + this.#ws = new WebSocket('wss://widgetdata.tradingview.com/socket.io/websocket', { origin: 'https://s.tradingview.com', }); @@ -222,6 +225,8 @@ module.exports = class Client { if (clientOptions.token) { misc.getUser(clientOptions.token).then((user) => { this.send('set_auth_token', [user.authToken]); + }).catch((err) => { + this.#handleError('Credentials error:', err.message); }); } else this.send('set_auth_token', ['unauthorized_user_token']); diff --git a/src/quote/market.js b/src/quote/market.js index eb1d552..70b3460 100644 --- a/src/quote/market.js +++ b/src/quote/market.js @@ -52,7 +52,7 @@ module.exports = (quoteSession) => class QuoteMarket { this.#symbolListenerID = this.#symbolListeners[symbol].length; this.#symbolListeners[symbol][this.#symbolListenerID] = (packet) => { - console.log('§90§30§105 MARKET §0 DATA', packet); + if (global.TW_DEBUG) console.log('§90§30§105 MARKET §0 DATA', packet); if (packet.type === 'qsd' && packet.data[1].s === 'ok') { this.#lastData = { diff --git a/src/quote/session.js b/src/quote/session.js index 1cc647e..45aaa94 100644 --- a/src/quote/session.js +++ b/src/quote/session.js @@ -76,7 +76,7 @@ module.exports = (client) => class QuoteSession { this.#client.sessions[this.#sessionID] = { type: 'quote', onData: (packet) => { - console.log('§90§30§102 QUOTE SESSION §0 DATA', packet); + if (global.TW_DEBUG) console.log('§90§30§102 QUOTE SESSION §0 DATA', packet); if (packet.type === 'quote_completed') { const symbol = packet.data[1]; diff --git a/src/types.js b/src/types.js index cb63c95..96ef48f 100644 --- a/src/types.js +++ b/src/types.js @@ -26,7 +26,7 @@ * * @typedef {'1' | '3' | '5' | '15' | '30' * | '45' | '60' | '120' | '180' | '240' - * | '1D' | '1W' | '1M'} TimeFrame + * | '1D' | '1W' | '1M' | 'D' | 'W' | 'M'} TimeFrame */ module.exports = {}; From 768c8e91b966ea5bd43a3ed2a0d1f8e6a9e4edef Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Sun, 7 Nov 2021 23:33:14 +0100 Subject: [PATCH 07/16] Add CustomChartType example If you want to create HeikinAshi, Renko, etc.. charts --- examples/CustomChartType.js | 121 ++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 examples/CustomChartType.js diff --git a/examples/CustomChartType.js b/examples/CustomChartType.js new file mode 100644 index 0000000..ee292b7 --- /dev/null +++ b/examples/CustomChartType.js @@ -0,0 +1,121 @@ +const TradingView = require('../main'); + +/* + This example creates charts of custom + types such as 'HeikinAshi', 'Renko', + 'LineBreak', 'Kagi', 'PointAndFigure', + and 'Range' with default settings +*/ + +const client = new TradingView.Client({ + /* Token is only required if you want to use intraday + timeframes (if you have a paid TradingView account) */ + // token: 'YOUR_SESSION_TOKEN', +}); + +const chart = new client.Session.Chart(); + +chart.onError((...err) => { + console.log('Chart error:', ...err); +}); + +chart.onUpdate(() => { + if (!chart.periods[0]) return; + console.log('Last period', chart.periods[0]); +}); + +/* (0s) Heikin Ashi chart */ +setTimeout(() => { + console.log('\nSetting chart type to: HeikinAshi'); + + chart.setMarket('BINANCE:BTCEUR', { + type: 'HeikinAshi', + timeframe: 'D', + }); +}, 0); + +/* (5s) Renko chart */ +setTimeout(() => { + console.log('\nSetting chart type to: Renko'); + + chart.setMarket('BINANCE:BTCEUR', { + type: 'Renko', + timeframe: 'D', + inputs: { + source: 'close', + sources: 'Close', + boxSize: 3, + style: 'ATR', + atrLength: 14, + wicks: true, + }, + }); +}, 5000); + +/* (10s) Line Break chart */ +setTimeout(() => { + console.log('\nSetting chart type to: LineBreak'); + + chart.setMarket('BINANCE:BTCEUR', { + type: 'LineBreak', + timeframe: 'D', + inputs: { + source: 'close', + lb: 3, + }, + }); +}, 10000); + +/* (15s) Kagi chart */ +setTimeout(() => { + console.log('\nSetting chart type to: Kagi'); + + chart.setMarket('BINANCE:BTCEUR', { + type: 'Kagi', + timeframe: 'D', + inputs: { + source: 'close', + style: 'ATR', + atrLength: 14, + reversalAmount: 1, + }, + }); +}, 15000); + +/* (20s) Point & Figure chart */ +setTimeout(() => { + console.log('\nSetting chart type to: PointAndFigure'); + + chart.setMarket('BINANCE:BTCEUR', { + type: 'PointAndFigure', + timeframe: 'D', + inputs: { + sources: 'Close', + reversalAmount: 3, + boxSize: 1, + style: 'ATR', + atrLength: 14, + oneStepBackBuilding: false, + }, + }); +}, 20000); + +/* (25s) Range chart */ +setTimeout(() => { + console.log('\nSetting chart type to: Range'); + + chart.setMarket('BINANCE:BTCEUR', { + type: 'Range', + timeframe: 'D', + inputs: { + range: 1, + phantomBars: false, + }, + }); +}, 25000); + +/* (30s) Delete chart, close client */ +setTimeout(() => { + console.log('\nClosing client...'); + client.end(); +}, 30000); From f5278d3f922ec3afec08af172e50c7a8763251fd Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Mon, 8 Nov 2021 15:05:52 +0100 Subject: [PATCH 08/16] Bug fix ! --- src/chart/study.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chart/study.js b/src/chart/study.js index 1dc55aa..5a222cc 100644 --- a/src/chart/study.js +++ b/src/chart/study.js @@ -121,7 +121,7 @@ module.exports = (chartSession) => class ChartStudy { /** @return {{}[]} List of periods values */ get periods() { - return Object.values(this.#periods).sort((a, b) => b.time - a.time); + return Object.values(this.#periods).sort((a, b) => b.$time - a.$time); } /** @type {StrategyReport} */ From 529181a19f43cd303e0249a64f09bb38c189fa3f Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Sun, 14 Nov 2021 22:36:03 +0100 Subject: [PATCH 09/16] Bug fix --- src/chart/session.js | 2 +- src/chart/study.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/chart/session.js b/src/chart/session.js index fa7b23b..6fc439a 100644 --- a/src/chart/session.js +++ b/src/chart/session.js @@ -254,7 +254,7 @@ module.exports = (client) => class ChartSession { 's1', `ser_${this.#currentSeries}`, timeframe, - !this.#seriesCreated ? range : '', + this.#seriesCreated ? '' : range, ]); this.#seriesCreated = true; diff --git a/src/chart/study.js b/src/chart/study.js index 5a222cc..15763a3 100644 --- a/src/chart/study.js +++ b/src/chart/study.js @@ -190,7 +190,7 @@ module.exports = (chartSession) => class ChartStudy { p.v.forEach((plot, i) => { const plotName = (i === 0 ? '$time' : this.options.plots[`plot_${i - 1}`]); - if (!period[plotName]) period[plotName] = plot; + if (plotName && !period[plotName]) period[plotName] = plot; else period[`plot_${i - 1}`] = plot; }); From dddad2bae8a2613df96df8ba93469ddc49f996b1 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Sun, 14 Nov 2021 23:47:20 +0100 Subject: [PATCH 10/16] Bug fix + Support colorers --- src/miscRequests.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/miscRequests.js b/src/miscRequests.js index 789e5e5..01d430e 100644 --- a/src/miscRequests.js +++ b/src/miscRequests.js @@ -279,7 +279,7 @@ module.exports = { } if (!data.success || !data.result.metaInfo || !data.result.metaInfo.inputs) { - throw new Error('Inexistent or unsupported indicator'); + throw new Error(`Inexistent or unsupported indicator: "${data.reason}"`); } const inputs = {}; @@ -305,7 +305,26 @@ module.exports = { const plots = {}; Object.keys(data.result.metaInfo.styles).forEach((plotId) => { - plots[plotId] = data.result.metaInfo.styles[plotId].title.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, ''); + const plotTitle = data + .result + .metaInfo + .styles[plotId] + .title + .replace(/ /g, '_') + .replace(/[^a-zA-Z0-9_]/g, ''); + + const titles = Object.values(plots); + + if (titles.includes(plotTitle)) { + let i = 2; + while (titles.includes(`${plotTitle}_${i}`)) i += 1; + plots[plotId] = `${plotTitle}_${i}`; + } else plots[plotId] = plotTitle; + }); + + data.result.metaInfo.plots.forEach((plot) => { + if (!plot.target) return; + plots[plot.id] = `${plots[plot.target] ?? plot.target}_${plot.type}`; }); return new PineIndicator({ From 08efb508fcefefba7f1206d9230b795a788feb84 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Mon, 15 Nov 2021 02:14:40 +0100 Subject: [PATCH 11/16] Establishment of "graphicsCmds" parsing --- examples/SpecialIndicator.js | 43 ++++++++++++++++++++++ src/chart/graphicParser.js | 55 ++++++++++++++++++++++++++++ src/chart/session.js | 3 ++ src/chart/study.js | 69 ++++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 examples/SpecialIndicator.js create mode 100644 src/chart/graphicParser.js diff --git a/examples/SpecialIndicator.js b/examples/SpecialIndicator.js new file mode 100644 index 0000000..334f7a0 --- /dev/null +++ b/examples/SpecialIndicator.js @@ -0,0 +1,43 @@ +const TradingView = require('../main'); + +/* + This example tests an indicator that sends + its data in a particular way such as + 'lines', 'labels', 'boxes' and 'tables' +*/ + +const client = new TradingView.Client(); + +const chart = new client.Session.Chart(); +chart.setMarket('BINANCE:BTCEUR', { + timeframe: 'W', +}); + +// TradingView.getIndicator('PUB;5xi4DbWeuIQrU0Fx6ZKiI2odDvIW9q2j').then((indic) => { +TradingView.getIndicator('USER;8bbd8017fd3e4881bf91f4fea5e3d538').then((indic) => { + const STD = new chart.Study(indic); + + STD.onError((...err) => { + console.log('Chart error:', ...err); + }); + + STD.onReady(() => { + console.log('STD Loaded !'); + }); + + STD.onUpdate((changes) => { + STD.graphic; + console.log('Update:', changes); + }); +}); + +setInterval(() => { + chart.fetchMore(100); +}, 2000); + +setTimeout(() => { + console.log('Setting timeframe to: \'D\''); + chart.setSeries('D'); +}, 5000); + +chart.onUpdate(() => console.log(chart.periods.length)); diff --git a/src/chart/graphicParser.js b/src/chart/graphicParser.js new file mode 100644 index 0000000..df0287d --- /dev/null +++ b/src/chart/graphicParser.js @@ -0,0 +1,55 @@ +/** + * @typedef {Object} GraphicLabel + * @prop {number} id Drawing ID + */ + +/** + * @typedef {Object} GraphicLine + * @prop {number} id Drawing ID + */ + +/** + * @typedef {Object} GraphicBox + * @prop {number} id Drawing ID + */ + +/** + * @typedef {Object} GraphicTable + * @prop {number} id Drawing ID + */ + +/** + * @typedef {Object} GraphicData List of drawings indexed by type + * @prop {GraphicLabel[]} labels List of labels drawings + * @prop {GraphicLine[]} lines List of lines drawings + * @prop {GraphicBox[]} boxes List of boxes drawings + * @prop {GraphicTable[]} tables List of tables drawings + */ + +/** + * @param {Object} rawGraphic Raw graphic data + * @param {Object} indexes Drawings xPos indexes + * @returns {GraphicData} + */ +module.exports = function graphicParse(rawGraphic = {}, indexes = []) { + // console.log(rawGraphic, indexes); + return { + labels: Object.values(rawGraphic.dwglabels ?? {}).map((l) => ({ + ...l, + })), + + lines: Object.values(rawGraphic.dwglines ?? {}).map((l) => ({ + ...l, + })), + + boxes: Object.values(rawGraphic.dwgboxes ?? {}).map((b) => ({ + ...b, + })), + + tables: Object.values(rawGraphic.dwgtables ?? {}).map((t) => ({ + ...t, + })), + + raw: rawGraphic, + }; +}; diff --git a/src/chart/session.js b/src/chart/session.js index 6fc439a..60419b8 100644 --- a/src/chart/session.js +++ b/src/chart/session.js @@ -38,6 +38,7 @@ const ChartTypes = { * @typedef {Object} ChartSessionBridge * @prop {string} sessionID * @prop {StudyListeners} studyListeners + * @prop {Object} indexes * @prop {import('../client').SendPacket} send */ @@ -192,6 +193,7 @@ module.exports = (client) => class ChartSession { if (!periods || !periods.s) return; periods.s.forEach((p) => { + [this.#chartSession.indexes[p.i]] = p.v; this.#periods[p.v[0]] = { time: p.v[0], open: p.v[1], @@ -351,6 +353,7 @@ module.exports = (client) => class ChartSession { #chartSession = { sessionID: this.#sessionID, studyListeners: this.#studyListeners, + indexes: {}, send: (t, p) => this.#client.send(t, p), }; diff --git a/src/chart/study.js b/src/chart/study.js index 15763a3..0950a52 100644 --- a/src/chart/study.js +++ b/src/chart/study.js @@ -1,5 +1,6 @@ const { genSessionID } = require('../utils'); const { parseCompressed } = require('../protocol'); +const graphicParser = require('./graphicParser'); const PineIndicator = require('../classes/PineIndicator'); @@ -124,6 +125,33 @@ module.exports = (chartSession) => class ChartStudy { return Object.values(this.#periods).sort((a, b) => b.$time - a.$time); } + /** + * List of graphic xPos indexes + * @type {number[]} + */ + #indexes = []; + + /** + * Table of graphic drawings indexed by type and ID + * @type {Object>} + */ + #graphic = {}; + + /** + * Table of graphic drawings indexed by type + * @return {import('./graphicParser').GraphicData} + */ + get graphic() { + const translator = {}; + + Object.keys(chartSession.indexes) + .sort((a, b) => chartSession.indexes[b] - chartSession.indexes[a]) + .forEach((r, n) => { translator[r] = n; }); + + const indexes = this.#indexes.map((i) => translator[i]); + return graphicParser(this.#graphic, indexes); + } + /** @type {StrategyReport} */ #strategyReport = { trades: [], @@ -203,6 +231,43 @@ module.exports = (chartSession) => class ChartStudy { if (data.ns && data.ns.d) { const parsed = JSON.parse(data.ns.d); + if (parsed.graphicsCmds) { + if (parsed.graphicsCmds.erase) { + parsed.graphicsCmds.erase.forEach((instruction) => { + console.log('Erase', instruction); + if (instruction.action === 'all') { + if (!instruction.type) { + Object.keys(this.#graphic).forEach((drawType) => { + this.#graphic[drawType] = {}; + }); + } else this.#graphic[instruction.type] = {}; + return; + } + + if (instruction.action === 'one') { + this.#graphic[instruction.type][instruction.id] = {}; + } + // Can an 'instruction' contains other things ? + }); + } + + if (parsed.graphicsCmds.create) { + Object.keys(parsed.graphicsCmds.create).forEach((drawType) => { + if (!this.#graphic[drawType]) this.#graphic[drawType] = {}; + parsed.graphicsCmds.create[drawType].forEach((group) => { + group.data.forEach((item) => { + this.#graphic[drawType][item.id] = item; + }); + }); + }); + } + + console.log('graphicsCmds', Object.keys(parsed.graphicsCmds)); + // Can 'graphicsCmds' contains other things ? + + changes.push('graphic'); + } + if (parsed.data && parsed.data.report && parsed.data.report.performance) { this.#strategyReport.performance = parsed.data.report.performance; changes.push('perfReport'); @@ -249,6 +314,10 @@ module.exports = (chartSession) => class ChartStudy { } } + if (data.ns.indexes && typeof data.ns.indexes === 'object') { + this.#indexes = data.ns.indexes; + } + this.#handleEvent('update', changes); return; } From 7b89eb7a807a722ea5c7b7ce470305cf3f85ba9e Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Mon, 15 Nov 2021 02:15:09 +0100 Subject: [PATCH 12/16] Bug fix + Enable debug --- test.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test.js b/test.js index 73e44c8..b2ae70a 100644 --- a/test.js +++ b/test.js @@ -7,7 +7,7 @@ const TradingView = require('./main'); const log = (...msg) => console.log('§90§30§103 TEST §0', ...msg); -const client = new TradingView.Client(); +const client = new TradingView.Client({ DEBUG: true }); client.onEvent((event, data) => { log('EVENT:', event, data); @@ -39,8 +39,8 @@ const chart = new client.Session.Chart(); chart.setMarket('COINBASE:BTCEUR'); chart.setSeries('60'); -chart.onSymbolLoaded((market) => { - log('Market loaded:', market.full_name); +chart.onSymbolLoaded(() => { + log('Market loaded:', chart.infos.full_name); }); chart.onError((...err) => { @@ -49,6 +49,7 @@ chart.onError((...err) => { chart.onUpdate(() => { const last = chart.periods[0]; + if (!last) return; log(`Market last period: ${last.close}`); }); @@ -108,7 +109,7 @@ TradingView.getIndicator('STD;Supertrend%Strategy').then((indicator) => { log('Last trade:', SuperTrend.strategyReport.trades[0]); // Do something... - // // Remove SuperTrend stratefgy from the chart + // // Remove SuperTrend strategy from the chart // SuperTrend.remove(); } }); From 68642aad70f6d6545d40d9a0c96eb3b855490511 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Sat, 20 Nov 2021 22:02:36 +0100 Subject: [PATCH 13/16] Auth bug fix --- src/client.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/client.js b/src/client.js index 8f23ae2..10853eb 100644 --- a/src/client.js +++ b/src/client.js @@ -39,7 +39,7 @@ module.exports = class Client { #logged = false; /** If the client is logged in */ - get logged() { + get isLogged() { return this.#logged; } @@ -200,7 +200,7 @@ module.exports = class Client { /** Send all waiting packets */ sendQueue() { - while (this.isOpen && this.#sendQueue.length > 0) { + while (this.isOpen && this.#logged && this.#sendQueue.length > 0) { const packet = this.#sendQueue.shift(); this.#ws.send(packet); } @@ -224,11 +224,23 @@ module.exports = class Client { if (clientOptions.token) { misc.getUser(clientOptions.token).then((user) => { - this.send('set_auth_token', [user.authToken]); + this.#sendQueue.unshift(protocol.formatWSPacket({ + m: 'set_auth_token', + p: [user.authToken], + })); + this.#logged = true; + this.sendQueue(); }).catch((err) => { this.#handleError('Credentials error:', err.message); }); - } else this.send('set_auth_token', ['unauthorized_user_token']); + } else { + this.#sendQueue.unshift(protocol.formatWSPacket({ + m: 'set_auth_token', + p: ['unauthorized_user_token'], + })); + this.#logged = true; + this.sendQueue(); + } this.#ws.on('open', () => { this.#handleEvent('connected'); From f3070ef326ffbd2c9550681136a9dcb3f77c528a Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Tue, 7 Dec 2021 22:37:09 +0100 Subject: [PATCH 14/16] Init PineIndicator --- examples/BuiltInIndicator.js | 25 +++++ examples/CustomChartType.js | 2 +- examples/CustomTimeframe.js | 29 +++++ ...pecialIndicator.js => GraphicIndicator.js} | 6 +- examples/MultipleSyncFetch.js | 38 +++++++ examples/Search.js | 15 +++ main.js | 10 +- package.json | 5 +- src/chart/graphicParser.js | 15 +++ src/chart/study.js | 71 ++++++------ src/classes/BuiltInIndicator.js | 101 ++++++++++++++++++ src/classes/PineIndicator.js | 20 ++++ 12 files changed, 295 insertions(+), 42 deletions(-) create mode 100644 examples/BuiltInIndicator.js create mode 100644 examples/CustomTimeframe.js rename examples/{SpecialIndicator.js => GraphicIndicator.js} (89%) create mode 100644 examples/MultipleSyncFetch.js create mode 100644 examples/Search.js create mode 100644 src/classes/BuiltInIndicator.js diff --git a/examples/BuiltInIndicator.js b/examples/BuiltInIndicator.js new file mode 100644 index 0000000..b862cd0 --- /dev/null +++ b/examples/BuiltInIndicator.js @@ -0,0 +1,25 @@ +const TradingView = require('../main'); + +/* + This example tests built-in indicators + like volume-based indicators +*/ + +if (!process.argv[2]) throw Error('Please specify your \'sessionid\' cookie'); + +const client = new TradingView.Client({ + token: process.argv[2], +}); + +const chart = new client.Session.Chart(); +chart.setMarket('BINANCE:BTCEUR', { + timeframe: '60', +}); + +const volumeProfile = new TradingView.BuiltInIndicator('VbPSessions@tv-volumebyprice-53'); + +const VOL = new chart.Study(volumeProfile); +VOL.onUpdate(() => { + console.log((VOL.graphic.horizlines)); + client.end(); +}); diff --git a/examples/CustomChartType.js b/examples/CustomChartType.js index ee292b7..9385c6b 100644 --- a/examples/CustomChartType.js +++ b/examples/CustomChartType.js @@ -10,7 +10,7 @@ const TradingView = require('../main'); const client = new TradingView.Client({ /* Token is only required if you want to use intraday timeframes (if you have a paid TradingView account) */ - // token: 'YOUR_SESSION_TOKEN', + token: process.argv[2], }); const chart = new client.Session.Chart(); diff --git a/examples/CustomTimeframe.js b/examples/CustomTimeframe.js new file mode 100644 index 0000000..da91436 --- /dev/null +++ b/examples/CustomTimeframe.js @@ -0,0 +1,29 @@ +const TradingView = require('../main'); + +/* + This example tests custom + timeframes like 1 second +*/ + +if (!process.argv[2]) throw Error('Please specify your \'sessionid\' cookie'); + +const client = new TradingView.Client({ + token: process.argv[2], +}); + +const chart = new client.Session.Chart(); +chart.setTimezone('Europe/Paris'); + +chart.setMarket('CAPITALCOM:US100', { + timeframe: '1S', + range: 10, +}); + +chart.onSymbolLoaded(() => { + console.log(chart.infos.name, 'loaded !'); +}); + +chart.onUpdate(() => { + console.log('OK', chart.periods); + client.end(); +}); diff --git a/examples/SpecialIndicator.js b/examples/GraphicIndicator.js similarity index 89% rename from examples/SpecialIndicator.js rename to examples/GraphicIndicator.js index 334f7a0..5e0c2f6 100644 --- a/examples/SpecialIndicator.js +++ b/examples/GraphicIndicator.js @@ -2,8 +2,8 @@ const TradingView = require('../main'); /* This example tests an indicator that sends - its data in a particular way such as - 'lines', 'labels', 'boxes' and 'tables' + graphic data such as 'lines', 'labels', + 'boxes', 'tables', 'polygons', etc... */ const client = new TradingView.Client(); @@ -26,7 +26,7 @@ TradingView.getIndicator('USER;8bbd8017fd3e4881bf91f4fea5e3d538').then((indic) = }); STD.onUpdate((changes) => { - STD.graphic; + // STD.graphic; console.log('Update:', changes); }); }); diff --git a/examples/MultipleSyncFetch.js b/examples/MultipleSyncFetch.js new file mode 100644 index 0000000..46c56c6 --- /dev/null +++ b/examples/MultipleSyncFetch.js @@ -0,0 +1,38 @@ +const TradingView = require('../main'); + +/* + This examples synchronously + fetches data from 3 indicators +*/ + +const client = new TradingView.Client(); +const chart = new client.Session.Chart(); +chart.setMarket('BINANCE:DOTUSDT'); + +function getIndicData(indicator) { + return new Promise((res) => { + const STD = new chart.Study(indicator); + + console.log(`Getting "${indicator.description}"...`); + + STD.onUpdate(() => { + res(STD.periods); + console.log(`"${indicator.description}" done !`); + }); + }); +} + +(async () => { + console.log('Getting all indicators...'); + + const indicData = await Promise.all([ + await TradingView.getIndicator('PUB;3lEKXjKWycY5fFZRYYujEy8fxzRRUyF3'), + await TradingView.getIndicator('PUB;5nawr3gCESvSHQfOhrLPqQqT4zM23w3X'), + await TradingView.getIndicator('PUB;vrOJcNRPULteowIsuP6iHn3GIxBJdXwT'), + ].map(getIndicData)); + + console.log(indicData); + console.log('All done !'); + + client.end(); +})(); diff --git a/examples/Search.js b/examples/Search.js new file mode 100644 index 0000000..4410258 --- /dev/null +++ b/examples/Search.js @@ -0,0 +1,15 @@ +const TradingView = require('../main'); + +/* + This example tests the searching + functions such as 'searchMarket' + and 'searchIndicator' +*/ + +TradingView.searchMarket('BINANCE:').then((rs) => { + console.log('Found Markets:', rs); +}); + +TradingView.searchIndicator('RSI').then((rs) => { + console.log('Found Indicators:', rs); +}); diff --git a/main.js b/main.js index 5dc95a5..2f0397e 100644 --- a/main.js +++ b/main.js @@ -1,7 +1,9 @@ const miscRequests = require('./src/miscRequests'); const Client = require('./src/client'); +const BuiltInIndicator = require('./src/classes/BuiltInIndicator'); +const PineIndicator = require('./src/classes/PineIndicator'); -module.exports = { - ...miscRequests, - Client, -}; +module.exports = { ...miscRequests }; +module.exports.Client = Client; +module.exports.BuiltInIndicator = BuiltInIndicator; +module.exports.PineIndicator = PineIndicator; diff --git a/package.json b/package.json index 7433eda..2ef8157 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mathieuc/tradingview", - "version": "2.0.91", + "version": "3.0.0", "description": "Tradingview instant stocks API, indicator alerts, trading bot, and more !", "main": "main.js", "scripts": { @@ -36,6 +36,7 @@ "@mathieuc/console": "^1.0.1", "eslint": "^7.25.0", "eslint-config-airbnb-base": "^14.2.1", - "eslint-plugin-import": "^2.22.1" + "eslint-plugin-import": "^2.22.1", + "jsdoc-to-markdown": "^7.1.0" } } diff --git a/src/chart/graphicParser.js b/src/chart/graphicParser.js index df0287d..f5a7ba7 100644 --- a/src/chart/graphicParser.js +++ b/src/chart/graphicParser.js @@ -24,6 +24,9 @@ * @prop {GraphicLine[]} lines List of lines drawings * @prop {GraphicBox[]} boxes List of boxes drawings * @prop {GraphicTable[]} tables List of tables drawings + * @prop {GraphicHorizline[]} horizlines List of horizontal line drawings + * @prop {GraphicPolygon[]} polygons List of polygon drawings + * @prop {GraphicHist[]} hist List of hist drawings */ /** @@ -50,6 +53,18 @@ module.exports = function graphicParse(rawGraphic = {}, indexes = []) { ...t, })), + horizlines: Object.values(rawGraphic.horizlines ?? {}).map((h) => ({ + ...h, + })), + + polygons: Object.values(rawGraphic.polygons ?? {}).map((p) => ({ + ...p, + })), + + hist: Object.values(rawGraphic.hhists ?? {}).map((h) => ({ + ...h, + })), + raw: rawGraphic, }; }; diff --git a/src/chart/study.js b/src/chart/study.js index 0950a52..8894c06 100644 --- a/src/chart/study.js +++ b/src/chart/study.js @@ -3,28 +3,33 @@ const { parseCompressed } = require('../protocol'); const graphicParser = require('./graphicParser'); const PineIndicator = require('../classes/PineIndicator'); +const BuiltInIndicator = require('../classes/BuiltInIndicator'); /** * Get pine inputs - * @param {PineIndicator} options + * @param {PineIndicator | BuiltInIndicator} options */ -function getPineInputs(options) { - const pineInputs = { text: options.script }; +function getInputs(options) { + if (options instanceof PineIndicator) { + const pineInputs = { text: options.script }; - if (options.pineId) pineInputs.pineId = options.pineId; - if (options.pineVersion) pineInputs.pineVersion = options.pineVersion; + if (options.pineId) pineInputs.pineId = options.pineId; + if (options.pineVersion) pineInputs.pineVersion = options.pineVersion; - Object.keys(options.inputs).forEach((inputID) => { - const input = options.inputs[inputID]; + Object.keys(options.inputs).forEach((inputID) => { + const input = options.inputs[inputID]; - pineInputs[inputID] = { - v: input.value, - f: input.isFake, - t: input.type, - }; - }); + pineInputs[inputID] = { + v: input.value, + f: input.isFake, + t: input.type, + }; + }); + + return pineInputs; + } - return pineInputs; + return options.options; } /** @@ -187,18 +192,16 @@ module.exports = (chartSession) => class ChartStudy { } /** - * @param {PineIndicator} options Indicator options - * @param {'Script@tv-scripting-101!' - * | 'StrategyScript@tv-scripting-101!'} [type] Indicator custom type + * @param {PineIndicator | BuiltInIndicator} indicator Indicator object instance */ - constructor(options, type = 'Script@tv-scripting-101!') { - if (!(options instanceof PineIndicator)) { - throw new Error(`Study options must be an instance of PineIndicator. + constructor(indicator) { + if (!(indicator instanceof PineIndicator) && !(indicator instanceof BuiltInIndicator)) { + throw new Error(`Indicator argument must be an instance of PineIndicator or BuiltInIndicator. Please use 'TradingView.getIndicator(...)' function.`); } - /** @type {PineIndicator} Indicator options */ - this.options = options; + /** @type {PineIndicator | BuiltInIndicator} Indicator instance */ + this.instance = indicator; this.#studyListeners[this.#studID] = async (packet) => { if (global.TW_DEBUG) console.log('§90§30§105 STUDY §0 DATA', packet); @@ -217,7 +220,11 @@ module.exports = (chartSession) => class ChartStudy { const period = {}; p.v.forEach((plot, i) => { - const plotName = (i === 0 ? '$time' : this.options.plots[`plot_${i - 1}`]); + if (!this.instance.plots) { + period[i === 0 ? '$time' : `plot_${i - 1}`] = plot; + return; + } + const plotName = (i === 0 ? '$time' : this.instance.plots[`plot_${i - 1}`]); if (plotName && !period[plotName]) period[plotName] = plot; else period[`plot_${i - 1}`] = plot; }); @@ -234,7 +241,7 @@ module.exports = (chartSession) => class ChartStudy { if (parsed.graphicsCmds) { if (parsed.graphicsCmds.erase) { parsed.graphicsCmds.erase.forEach((instruction) => { - console.log('Erase', instruction); + // console.log('Erase', instruction); if (instruction.action === 'all') { if (!instruction.type) { Object.keys(this.#graphic).forEach((drawType) => { @@ -332,27 +339,27 @@ module.exports = (chartSession) => class ChartStudy { `${this.#studID}`, 'st1', '$prices', - type, - getPineInputs(this.options), + this.instance.type, + getInputs(this.instance), ]); } /** - * @param {PineIndicator} options Indicator options + * @param {PineIndicator | BuiltInIndicator} indicator Indicator instance */ - setIndicator(options) { - if (!(options instanceof PineIndicator)) { - throw new Error(`Study options must be an instance of PineIndicator. + setIndicator(indicator) { + if (!(indicator instanceof PineIndicator) && !(indicator instanceof BuiltInIndicator)) { + throw new Error(`Indicator argument must be an instance of PineIndicator or BuiltInIndicator. Please use 'TradingView.getIndicator(...)' function.`); } - this.options = options; + this.instance = indicator; chartSession.send('modify_study', [ chartSession.sessionID, `${this.#studID}`, 'st1', - getPineInputs(this.options), + getInputs(this.instance), ]); } diff --git a/src/classes/BuiltInIndicator.js b/src/classes/BuiltInIndicator.js new file mode 100644 index 0000000..68bab12 --- /dev/null +++ b/src/classes/BuiltInIndicator.js @@ -0,0 +1,101 @@ +/** + * @typedef {'VbPFixed@tv-basicstudies-139!' + * | 'VbPFixed@tv-volumebyprice-53!' + * | 'VbPSessions@tv-volumebyprice-53' + * | 'VbPSessionsRough@tv-volumebyprice-53!' + * | 'VbPSessionsDetailed@tv-volumebyprice-53!' + * | 'VbPVisible@tv-volumebyprice-53'} BuiltInIndicatorType Built-in indicator type + */ + +/** + * @typedef {'rowsLayout' | 'rows' | 'volume' + * | 'vaVolume' | 'subscribeRealtime' + * | 'first_bar_time' | 'first_visible_bar_time' + * | 'last_bar_time' | 'last_visible_bar_time' + * | 'extendPocRight'} BuiltInIndicatorOption Built-in indicator Option + */ + +const defaultValues = { + 'VbPFixed@tv-basicstudies-139!': { + rowsLayout: 'Number Of Rows', + rows: 24, + volume: 'Up/Down', + vaVolume: 70, + subscribeRealtime: false, + // first_bar_time: 0000000000000, + // last_bar_time: 0000000000000, + }, + 'VbPFixed@tv-volumebyprice-53!': { + rowsLayout: 'Number Of Rows', + rows: 24, + volume: 'Up/Down', + vaVolume: 70, + subscribeRealtime: false, + // first_bar_time: 0000000000000, + // last_bar_time: 0000000000000, + }, + 'VbPSessions@tv-volumebyprice-53': { + rowsLayout: 'Number Of Rows', + rows: 24, + volume: 'Up/Down', + vaVolume: 70, + extendPocRight: false, + }, + 'VbPSessionsRough@tv-volumebyprice-53!': { + volume: 'Up/Down', + vaVolume: 70, + }, + 'VbPSessionsDetailed@tv-volumebyprice-53!': { + volume: 'Up/Down', + vaVolume: 70, + subscribeRealtime: false, + // first_visible_bar_time: 0000000000000, + // last_visible_bar_time: 0000000000000, + }, + 'VbPVisible@tv-volumebyprice-53': { + rowsLayout: 'Number Of Rows', + rows: 24, + volume: 'Up/Down', + vaVolume: 70, + subscribeRealtime: false, + // first_visible_bar_time: 0000000000000, + // last_visible_bar_time: 0000000000000, + }, +}; + +module.exports = class BuiltInIndicator { + /** @type {BuiltInIndicatorType} */ + #type; + + /** @return {BuiltInIndicatorType} Indicator script */ + get type() { + return this.#type; + } + + /** @type {Object} */ + #options = {}; + + /** @return {Object} Indicator script */ + get options() { + return this.#options; + } + + /** + * @param {BuiltInIndicatorType} type Buit-in indocator raw type + */ + constructor(type = '') { + if (!type) throw new Error(`Wrong buit-in indicator type "${type}".`); + + this.#type = type; + if (defaultValues[type]) this.#options = defaultValues[type]; + } + + /** + * Set an option + * @param {BuiltInIndicatorOption} key The option you want to change + * @param {*} value The new value of the property + */ + setOption(key, value) { + this.#options[key] = value; + } +}; diff --git a/src/classes/PineIndicator.js b/src/classes/PineIndicator.js index b2e6e6f..6a0cfdc 100644 --- a/src/classes/PineIndicator.js +++ b/src/classes/PineIndicator.js @@ -22,9 +22,16 @@ * @property {string} script Indicator script */ +/** + * @typedef {'Script@tv-scripting-101!' + * | 'StrategyScript@tv-scripting-101!'} IndicatorType Indicator type + */ module.exports = class PineIndicator { #options; + /** @type {IndicatorType} */ + #type = 'Script@tv-scripting-101!'; + /** @param {Indicator} options Indicator */ constructor(options) { this.#options = options; @@ -60,6 +67,19 @@ module.exports = class PineIndicator { return this.#options.plots; } + /** @return {IndicatorType} Indicator script */ + get type() { + return this.#type; + } + + /** + * Set the indicator type + * @param {IndicatorType} type Indicator type + */ + setType(type = 'Script@tv-scripting-101!') { + this.#type = type; + } + /** @return {string} Indicator script */ get script() { return this.#options.script; From abbd9412c954ade539e86da09429c1dee8d82b6a Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Tue, 7 Dec 2021 22:49:45 +0100 Subject: [PATCH 15/16] Update graphicParser.js --- src/chart/graphicParser.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/chart/graphicParser.js b/src/chart/graphicParser.js index f5a7ba7..58c10dd 100644 --- a/src/chart/graphicParser.js +++ b/src/chart/graphicParser.js @@ -26,7 +26,7 @@ * @prop {GraphicTable[]} tables List of tables drawings * @prop {GraphicHorizline[]} horizlines List of horizontal line drawings * @prop {GraphicPolygon[]} polygons List of polygon drawings - * @prop {GraphicHist[]} hist List of hist drawings + * @prop {GraphicHist[]} hists List of hist drawings */ /** @@ -61,7 +61,7 @@ module.exports = function graphicParse(rawGraphic = {}, indexes = []) { ...p, })), - hist: Object.values(rawGraphic.hhists ?? {}).map((h) => ({ + hists: Object.values(rawGraphic.hhists ?? {}).map((h) => ({ ...h, })), From 663a6c38c71ac4d87291fe33a6ffc427499bbb94 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon Date: Sat, 11 Dec 2021 20:32:51 +0100 Subject: [PATCH 16/16] Support of volume-based built-in indicators --- README.md | 100 ++--------------------------------- examples/BuiltInIndicator.js | 18 ++++++- src/chart/graphicParser.js | 42 ++++++++++++++- src/chart/study.js | 6 +-- src/miscRequests.js | 4 +- 5 files changed, 67 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 2ef8b8a..bf3545a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ Get realtime market prices and indicator values from Tradingview ! ## Features +- [x] Premium features - [x] Automatically backtest many strategies and try many settings in a very little time - [x] Get drawings you made on your chart - [x] Works with invite-only indicators @@ -14,7 +15,7 @@ Get realtime market prices and indicator values from Tradingview ! - [ ] Get Screener top values - [ ] Get Hotlists - [ ] Get Calendar -- IF YOU WANT A FEATURE, ASK ME !! +- IF YOU WANT A FEATURE, ASK ME ! ## Possibilities - Trading bot @@ -30,102 +31,9 @@ ___ npm i @mathieuc/tradingview ``` -## Examples (./tests/) +## Examples +You can find all the examples and snippets in `./examples` folder. -```javascript -/* - ./tests/prices.js - Search for Bitcoin and Ethereum and get real time prices -*/ - -const marketAPI = require('tradingview'); - -(async () => { - const market = marketAPI(); - - market.on('logged', async () => { - console.log('API LOGGED'); - - const searchBTC = (await market.search('bitcoin euro', 'crypto'))[0]; - console.log('Found Bitcoin / Euro:', searchBTC); - market.subscribe(searchBTC.id); - }); - - market.on('price', (data) => { - console.log(data.symbol, '=>', data.price); - }); - - const searchETH = (await market.search('ethereum euro', 'crypto'))[0]; - console.log('Found Ethereum / Euro:', searchETH); - - setTimeout(() => { - console.log('Subscribe to', searchETH.id); - market.subscribe(searchETH.id); - }, 10000); - - setTimeout(() => { - console.log('Unsubscribe from', searchETH.id); - market.unsubscribe(searchETH.id); - }, 20000); -})(); -``` - -```javascript -/* - ./tests/analysis.js - Search for Bitcoin and get the Technical Analysis in all timeframes -*/ - -const marketAPI = require('tradingview'); - -(async () => { - const market = marketAPI(false); - - const searchBTC = (await market.search('bitcoin euro', 'crypto'))[0]; - console.log('Found Bitcoin / Euro:', searchBTC); - - const TA = await searchBTC.getTA(); - console.log('Full technical analysis for Bitcoin:', TA); - - // You can also use this way: await market.getTA('crypto', 'BINANCE:BTCEUR'); -})(); -``` - -```javascript -/* - ./tests/indicator.js - Get indicator values -*/ - -const marketAPI = require('tradingview'); - -const market = marketAPI(false); // 'false' for chart-only mode - -market.on('logged', () => { - market.initChart({ - symbol: 'COINBASE:BTCEUR', - period: '240', - range: 50, - indicators: [ - { name: 'ACCU_DISTRIB', id: 'STD;Accumulation_Distribution', version: '25' }, - { name: 'CIPHER_A', id: 'PUB;vrOJcNRPULteowIsuP6iHn3GIxBJdXwT', version: '1.0' }, - { name: 'CIPHER_B', id: 'PUB;uA35GeckoTA2EfgI63SD2WCSmca4njxp', version: '15.0' }, - // Color Changing moving average - { name: 'CCMA', id: 'PUB;5nawr3gCESvSHQfOhrLPqQqT4zM23w3X', version: '6.0' }, - ], - }, (periods) => { - if (!periods[0].CIPHER_B) return; - if (!periods[0].CCMA) return; - - console.log('Last period:', { - price: periods[0].$prices.close, - moneyFlow: (periods[0].CIPHER_B.RSIMFIArea >= 0) ? 'POSITIVE' : 'NEGATIVE', - VWAP: periods[0].CIPHER_B.VWAP, - MA: (periods[0].CCMA.Plot <= periods[0].$prices.close) ? 'ABOVE' : 'UNDER', - }); - }); -}); -``` ___ ## Problems If you have errors in console or unwanted behavior, diff --git a/examples/BuiltInIndicator.js b/examples/BuiltInIndicator.js index b862cd0..75819d6 100644 --- a/examples/BuiltInIndicator.js +++ b/examples/BuiltInIndicator.js @@ -14,12 +14,28 @@ const client = new TradingView.Client({ const chart = new client.Session.Chart(); chart.setMarket('BINANCE:BTCEUR', { timeframe: '60', + range: 1, }); const volumeProfile = new TradingView.BuiltInIndicator('VbPSessions@tv-volumebyprice-53'); +/* Required for other volume-based built-in indicators */ +// volumeProfile.setOption('first_bar_time', 1639080000000); +// volumeProfile.setOption('last_bar_time', 1639328400000); +// volumeProfile.setOption('first_visible_bar_time', 1639080000000); +// volumeProfile.setOption('last_visible_bar_time', 1639328400000); + const VOL = new chart.Study(volumeProfile); VOL.onUpdate(() => { - console.log((VOL.graphic.horizlines)); + VOL.graphic.hists + .filter((h) => h.lastBarTime === 0) // We only keep recent volume infos + .sort((a, b) => b.priceHigh - a.priceHigh) + .forEach((h) => { + console.log( + `~ ${Math.round((h.priceHigh + h.priceLow) / 2)} € :`, + `${'_'.repeat(h.rate[0] / 3)}${'_'.repeat(h.rate[1] / 3)}`, + ); + }); + client.end(); }); diff --git a/src/chart/graphicParser.js b/src/chart/graphicParser.js index 58c10dd..7a09e31 100644 --- a/src/chart/graphicParser.js +++ b/src/chart/graphicParser.js @@ -18,6 +18,38 @@ * @prop {number} id Drawing ID */ +/** + * @typedef {Object} GraphicHorizline + * @prop {number} id Drawing ID + * @prop {number} level Y position of the line + * @prop {number} startIndex Start index of the line (`chart.periods[line.startIndex]`) + * @prop {number} endIndex End index of the line (`chart.periods[line.endIndex]`) + * @prop {boolean} extendRight Is the line extended to the right + * @prop {boolean} extendLeft Is the line extended to the left + */ + +/** + * @typedef {Object} GraphicPoint + * @prop {number} index X position of the point + * @prop {number} level Y position of the point + */ + +/** + * @typedef {Object} GraphicPolygon + * @prop {number} id Drawing ID + * @prop {GraphicPoint[]} points List of polygon points + */ + +/** + * @typedef {Object} GraphicHist + * @prop {number} id Drawing ID + * @prop {number} priceLow Low Y position + * @prop {number} priceHigh High Y position + * @prop {number} firstBarTime First X position + * @prop {number} lastBarTime Last X position + * @prop {number[]} rate List of values + */ + /** * @typedef {Object} GraphicData List of drawings indexed by type * @prop {GraphicLabel[]} labels List of labels drawings @@ -35,7 +67,7 @@ * @returns {GraphicData} */ module.exports = function graphicParse(rawGraphic = {}, indexes = []) { - // console.log(rawGraphic, indexes); + // console.log('indexes', indexes); return { labels: Object.values(rawGraphic.dwglabels ?? {}).map((l) => ({ ...l, @@ -55,14 +87,22 @@ module.exports = function graphicParse(rawGraphic = {}, indexes = []) { horizlines: Object.values(rawGraphic.horizlines ?? {}).map((h) => ({ ...h, + startIndex: indexes[h.startIndex], + endIndex: indexes[h.endIndex], })), polygons: Object.values(rawGraphic.polygons ?? {}).map((p) => ({ ...p, + points: p.points.map((pt) => ({ + ...pt, + index: indexes[pt.index], + })), })), hists: Object.values(rawGraphic.hhists ?? {}).map((h) => ({ ...h, + firstBarTime: indexes[h.firstBarTime], + lastBarTime: indexes[h.lastBarTime], })), raw: rawGraphic, diff --git a/src/chart/study.js b/src/chart/study.js index 8894c06..f993c19 100644 --- a/src/chart/study.js +++ b/src/chart/study.js @@ -247,12 +247,12 @@ module.exports = (chartSession) => class ChartStudy { Object.keys(this.#graphic).forEach((drawType) => { this.#graphic[drawType] = {}; }); - } else this.#graphic[instruction.type] = {}; + } else delete this.#graphic[instruction.type]; return; } if (instruction.action === 'one') { - this.#graphic[instruction.type][instruction.id] = {}; + delete this.#graphic[instruction.type][instruction.id]; } // Can an 'instruction' contains other things ? }); @@ -269,7 +269,7 @@ module.exports = (chartSession) => class ChartStudy { }); } - console.log('graphicsCmds', Object.keys(parsed.graphicsCmds)); + // console.log('graphicsCmds', Object.keys(parsed.graphicsCmds)); // Can 'graphicsCmds' contains other things ? changes.push('graphic'); diff --git a/src/miscRequests.js b/src/miscRequests.js index 01d430e..5f18c2c 100644 --- a/src/miscRequests.js +++ b/src/miscRequests.js @@ -328,8 +328,8 @@ module.exports = { }); return new PineIndicator({ - pineId: indicID, - pineVersion: version, + pineId: data.result.metaInfo.scriptIdPart || indicID, + pineVersion: data.result.metaInfo.pine.version || version, description: data.result.metaInfo.description, shortDescription: data.result.metaInfo.shortDescription, inputs,