@mathieuc/tradingview 2.0.7 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +18 -16
- package/README.md +26 -96
- package/examples/AllPrivateIndicators.js +35 -0
- package/examples/BuiltInIndicator.js +41 -0
- package/examples/CustomChartType.js +121 -0
- package/examples/CustomTimeframe.js +29 -0
- package/examples/Errors.js +153 -0
- package/examples/GraphicIndicator.js +34 -0
- package/examples/MultipleSyncFetch.js +38 -0
- package/examples/Search.js +15 -0
- package/examples/SimpleChart.js +63 -0
- package/examples/_RenkoIndicator.js +41 -0
- package/main.js +9 -352
- package/package.json +10 -2
- package/src/chart/graphicParser.js +308 -0
- package/src/chart/session.js +367 -0
- package/src/chart/study.js +403 -0
- package/src/classes/BuiltInIndicator.js +119 -0
- package/src/classes/PineIndicator.js +132 -0
- package/src/client.js +280 -0
- package/src/miscRequests.js +525 -0
- package/src/protocol.js +54 -0
- package/src/quote/market.js +123 -0
- package/src/quote/session.js +124 -0
- package/src/types.js +32 -0
- package/src/utils.js +8 -0
- package/test.js +155 -0
- package/tests/alerts/discordWH.js +76 -76
- package/tests/alerts/drawings.js +41 -41
- package/tests/alerts/envLoader.js +11 -11
- package/tests/alerts/manualAlert.js +77 -77
- package/tests/alerts/sentNotifs.js +16 -16
- package/tests/alerts/strategyAlert.js +94 -94
- package/tests/analysis.js +13 -13
- package/tests/indicator.js +42 -42
- package/tests/strategy.js +56 -0
- package/miscRequests.js +0 -420
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
|
|
3
|
+
const PineIndicator = require('./classes/PineIndicator');
|
|
4
|
+
|
|
5
|
+
const indicators = ['Recommend.Other', 'Recommend.All', 'Recommend.MA'];
|
|
6
|
+
const builtInIndicList = [];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {https.RequestOptions} options HTTPS Request options
|
|
10
|
+
* @param {boolean} [raw] Get raw or JSON data
|
|
11
|
+
* @param {string} [content] Request body content
|
|
12
|
+
* @returns {Promise<string | object | array>} Result
|
|
13
|
+
*/
|
|
14
|
+
function request(options = {}, raw = false, content = '') {
|
|
15
|
+
return new Promise((cb, err) => {
|
|
16
|
+
const req = https.request(options, (res) => {
|
|
17
|
+
let data = '';
|
|
18
|
+
res.on('data', (c) => { data += c; });
|
|
19
|
+
res.on('end', () => {
|
|
20
|
+
if (raw) {
|
|
21
|
+
cb(data);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
data = JSON.parse(data);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
err(new Error('Can\'t parse server response'));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
cb(data);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
req.on('error', err);
|
|
37
|
+
req.end(content);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function fetchScanData(tickers = [], type = '', columns = []) {
|
|
42
|
+
let data = await request({
|
|
43
|
+
method: 'POST',
|
|
44
|
+
hostname: 'scanner.tradingview.com',
|
|
45
|
+
path: `/${type}/scan`,
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
},
|
|
49
|
+
}, true, JSON.stringify({ symbols: { tickers }, columns }));
|
|
50
|
+
|
|
51
|
+
if (!data.startsWith('{')) throw new Error('Wrong screener or symbol');
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
data = JSON.parse(data);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
throw new Error('Can\'t parse server response');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {number} advice
|
|
64
|
+
*
|
|
65
|
+
* @typedef {{ Other: advice, All: advice, MA: advice }} Period
|
|
66
|
+
*
|
|
67
|
+
* @typedef {{
|
|
68
|
+
* '1': Period,
|
|
69
|
+
* '5': Period,
|
|
70
|
+
* '15': Period,
|
|
71
|
+
* '60': Period,
|
|
72
|
+
* '240': Period,
|
|
73
|
+
* '1D': Period,
|
|
74
|
+
* '1W': Period,
|
|
75
|
+
* '1M': Period,
|
|
76
|
+
* }} Periods
|
|
77
|
+
*
|
|
78
|
+
* @typedef {string | 'forex' | 'crypto'
|
|
79
|
+
* | 'america' | 'australia' | 'canada' | 'egypt'
|
|
80
|
+
* | 'germany' | 'india' | 'israel' | 'italy'
|
|
81
|
+
* | 'luxembourg' | 'poland' | 'sweden' | 'turkey'
|
|
82
|
+
* | 'uk' | 'vietnam'} screener
|
|
83
|
+
* You can use `getScreener(exchange)` function for non-forex and non-crypto markets.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} exchange Example: BINANCE, EURONEXT, NASDAQ
|
|
89
|
+
* @returns {screener}
|
|
90
|
+
*/
|
|
91
|
+
getScreener(exchange) {
|
|
92
|
+
const e = exchange.toUpperCase();
|
|
93
|
+
if (['NASDAQ', 'NYSE', 'NYSE ARCA', 'OTC'].includes(e)) return 'america';
|
|
94
|
+
if (['ASX'].includes(e)) return 'australia';
|
|
95
|
+
if (['TSX', 'TSXV', 'CSE', 'NEO'].includes(e)) return 'canada';
|
|
96
|
+
if (['EGX'].includes(e)) return 'egypt';
|
|
97
|
+
if (['FWB', 'SWB', 'XETR'].includes(e)) return 'germany';
|
|
98
|
+
if (['BSE', 'NSE'].includes(e)) return 'india';
|
|
99
|
+
if (['TASE'].includes(e)) return 'israel';
|
|
100
|
+
if (['MIL', 'MILSEDEX'].includes(e)) return 'italy';
|
|
101
|
+
if (['LUXSE'].includes(e)) return 'luxembourg';
|
|
102
|
+
if (['NEWCONNECT'].includes(e)) return 'poland';
|
|
103
|
+
if (['NGM'].includes(e)) return 'sweden';
|
|
104
|
+
if (['BIST'].includes(e)) return 'turkey';
|
|
105
|
+
if (['LSE', 'LSIN'].includes(e)) return 'uk';
|
|
106
|
+
if (['HNX'].includes(e)) return 'vietnam';
|
|
107
|
+
return exchange.toLowerCase();
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get technical analysis
|
|
112
|
+
* @param {screener} screener
|
|
113
|
+
* @param {string} id Full market id (Example: COINBASE:BTCEUR)
|
|
114
|
+
* @returns {Promise<Periods>} results
|
|
115
|
+
*/
|
|
116
|
+
async getTA(screener, id) {
|
|
117
|
+
const advice = {};
|
|
118
|
+
|
|
119
|
+
const cols = ['1', '5', '15', '60', '240', '1D', '1W', '1M']
|
|
120
|
+
.map((t) => indicators.map((i) => (t !== '1D' ? `${i}|${t}` : i)))
|
|
121
|
+
.flat();
|
|
122
|
+
|
|
123
|
+
const rs = await fetchScanData([id], screener, cols);
|
|
124
|
+
if (!rs.data || !rs.data[0]) return false;
|
|
125
|
+
|
|
126
|
+
rs.data[0].d.forEach((val, i) => {
|
|
127
|
+
const [name, period] = cols[i].split('|');
|
|
128
|
+
const pName = period || '1D';
|
|
129
|
+
if (!advice[pName]) advice[pName] = {};
|
|
130
|
+
advice[pName][name.split('.').pop()] = Math.round(val * 1000) / 500;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return advice;
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @typedef {Object} SearchMarketResult
|
|
138
|
+
* @property {string} id
|
|
139
|
+
* @property {string} exchange
|
|
140
|
+
* @property {string} fullExchange
|
|
141
|
+
* @property {string} screener
|
|
142
|
+
* @property {string} symbol
|
|
143
|
+
* @property {string} description
|
|
144
|
+
* @property {string} type
|
|
145
|
+
* @property {() => Promise<Periods>} getTA
|
|
146
|
+
*/
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Find a symbol
|
|
150
|
+
* @param {string} search Keywords
|
|
151
|
+
* @param {'stock' | 'futures' | 'forex' | 'cfd' | 'crypto' | 'index' | 'economic'} [filter]
|
|
152
|
+
* @returns {Promise<SearchMarketResult[]>} Search results
|
|
153
|
+
*/
|
|
154
|
+
async searchMarket(search, filter = '') {
|
|
155
|
+
const data = await request({
|
|
156
|
+
host: 'symbol-search.tradingview.com',
|
|
157
|
+
path: `/symbol_search/?text=${search.replace(/ /g, '%20')}&type=${filter}`,
|
|
158
|
+
origin: 'https://www.tradingview.com',
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return data.map((s) => {
|
|
162
|
+
const exchange = s.exchange.split(' ')[0];
|
|
163
|
+
const id = `${exchange}:${s.symbol}`;
|
|
164
|
+
|
|
165
|
+
const screener = (['forex', 'crypto'].includes(s.type)
|
|
166
|
+
? s.type
|
|
167
|
+
: this.getScreener(exchange)
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
id,
|
|
172
|
+
exchange,
|
|
173
|
+
fullExchange: s.exchange,
|
|
174
|
+
screener,
|
|
175
|
+
symbol: s.symbol,
|
|
176
|
+
description: s.description,
|
|
177
|
+
type: s.type,
|
|
178
|
+
getTA: () => this.getTA(screener, id),
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @typedef {Object} SearchIndicatorResult
|
|
185
|
+
* @property {string} id Script ID
|
|
186
|
+
* @property {string} version Script version
|
|
187
|
+
* @property {string} name Script complete name
|
|
188
|
+
* @property {{ id: number, username: string }} author Author user ID
|
|
189
|
+
* @property {string} image Image ID https://tradingview.com/i/${image}
|
|
190
|
+
* @property {string | ''} source Script source (if available)
|
|
191
|
+
* @property {'study' | 'strategy'} type Script type (study / strategy)
|
|
192
|
+
* @property {'open_source' | 'closed_source' | 'invite_only'
|
|
193
|
+
* | 'private' | 'other'} access Script access type
|
|
194
|
+
* @property {() => Promise<PineIndicator>} get Get the full indicator informations
|
|
195
|
+
*/
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Find an indicator
|
|
199
|
+
* @param {string} search Keywords
|
|
200
|
+
* @returns {Promise<SearchIndicatorResult[]>} Search results
|
|
201
|
+
*/
|
|
202
|
+
async searchIndicator(search = '') {
|
|
203
|
+
if (!builtInIndicList.length) {
|
|
204
|
+
await Promise.all(['standard', 'candlestick', 'fundamental'].map(async (type) => {
|
|
205
|
+
builtInIndicList.push(...await request({
|
|
206
|
+
host: 'pine-facade.tradingview.com',
|
|
207
|
+
path: `/pine-facade/list/?filter=${type}`,
|
|
208
|
+
}));
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const data = await request({
|
|
213
|
+
host: 'www.tradingview.com',
|
|
214
|
+
path: `/pubscripts-suggest-json/?search=${search.replace(/ /g, '%20')}`,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
function norm(str = '') {
|
|
218
|
+
return str.toUpperCase().replace(/[^A-Z]/g, '');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return [
|
|
222
|
+
...builtInIndicList.filter((i) => (
|
|
223
|
+
norm(i.scriptName).includes(norm(search))
|
|
224
|
+
|| norm(i.extra.shortDescription).includes(norm(search))
|
|
225
|
+
)).map((ind) => ({
|
|
226
|
+
id: ind.scriptIdPart,
|
|
227
|
+
version: ind.version,
|
|
228
|
+
name: ind.scriptName,
|
|
229
|
+
author: {
|
|
230
|
+
id: ind.userId,
|
|
231
|
+
username: '@TRADINGVIEW@',
|
|
232
|
+
},
|
|
233
|
+
image: '',
|
|
234
|
+
access: 'closed_source',
|
|
235
|
+
source: '',
|
|
236
|
+
type: (ind.extra && ind.extra.kind) ? ind.extra.kind : 'study',
|
|
237
|
+
get() {
|
|
238
|
+
return module.exports.getIndicator(ind.scriptIdPart, ind.version);
|
|
239
|
+
},
|
|
240
|
+
})),
|
|
241
|
+
|
|
242
|
+
...data.results.map((ind) => ({
|
|
243
|
+
id: ind.scriptIdPart,
|
|
244
|
+
version: ind.version,
|
|
245
|
+
name: ind.scriptName,
|
|
246
|
+
author: {
|
|
247
|
+
id: ind.author.id,
|
|
248
|
+
username: ind.author.username,
|
|
249
|
+
},
|
|
250
|
+
image: ind.imageUrl,
|
|
251
|
+
access: ['open_source', 'closed_source', 'invite_only'][ind.access - 1] || 'other',
|
|
252
|
+
source: ind.scriptSource,
|
|
253
|
+
type: (ind.extra && ind.extra.kind) ? ind.extra.kind : 'study',
|
|
254
|
+
get() {
|
|
255
|
+
return module.exports.getIndicator(ind.scriptIdPart, ind.version);
|
|
256
|
+
},
|
|
257
|
+
})),
|
|
258
|
+
];
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Get an indicator
|
|
263
|
+
* @param {string} id Indicator ID (Like: PUB;XXXXXXXXXXXXXXXXXXXXX)
|
|
264
|
+
* @param {'last' | string} [version] Wanted version of the indicator
|
|
265
|
+
* @returns {Promise<PineIndicator>} Indicator
|
|
266
|
+
*/
|
|
267
|
+
async getIndicator(id, version = 'last') {
|
|
268
|
+
const indicID = id.replace(/ |%/g, '%25');
|
|
269
|
+
|
|
270
|
+
let data = await request({
|
|
271
|
+
host: 'pine-facade.tradingview.com',
|
|
272
|
+
path: `/pine-facade/translate/${indicID}/${version}`,
|
|
273
|
+
}, true);
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
data = JSON.parse(data);
|
|
277
|
+
} catch (e) {
|
|
278
|
+
throw new Error(`Inexistent or unsupported indicator: '${id}'`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!data.success || !data.result.metaInfo || !data.result.metaInfo.inputs) {
|
|
282
|
+
throw new Error(`Inexistent or unsupported indicator: "${data.reason}"`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const inputs = {};
|
|
286
|
+
|
|
287
|
+
data.result.metaInfo.inputs.forEach((input) => {
|
|
288
|
+
if (['text', 'pineId', 'pineVersion'].includes(input.id)) return;
|
|
289
|
+
|
|
290
|
+
inputs[input.id] = {
|
|
291
|
+
name: input.name,
|
|
292
|
+
inline: input.inline || input.name.replace(/ /g, '_').replace(/[^a-zA-Z0-9_]/g, ''),
|
|
293
|
+
internalID: input.internalID,
|
|
294
|
+
tooltip: input.tooltip,
|
|
295
|
+
|
|
296
|
+
type: input.type,
|
|
297
|
+
value: input.defval,
|
|
298
|
+
isHidden: !!input.isHidden,
|
|
299
|
+
isFake: !!input.isFake,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
if (input.options) inputs[input.id].options = input.options;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const plots = {};
|
|
306
|
+
|
|
307
|
+
Object.keys(data.result.metaInfo.styles).forEach((plotId) => {
|
|
308
|
+
const plotTitle = data
|
|
309
|
+
.result
|
|
310
|
+
.metaInfo
|
|
311
|
+
.styles[plotId]
|
|
312
|
+
.title
|
|
313
|
+
.replace(/ /g, '_')
|
|
314
|
+
.replace(/[^a-zA-Z0-9_]/g, '');
|
|
315
|
+
|
|
316
|
+
const titles = Object.values(plots);
|
|
317
|
+
|
|
318
|
+
if (titles.includes(plotTitle)) {
|
|
319
|
+
let i = 2;
|
|
320
|
+
while (titles.includes(`${plotTitle}_${i}`)) i += 1;
|
|
321
|
+
plots[plotId] = `${plotTitle}_${i}`;
|
|
322
|
+
} else plots[plotId] = plotTitle;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
data.result.metaInfo.plots.forEach((plot) => {
|
|
326
|
+
if (!plot.target) return;
|
|
327
|
+
plots[plot.id] = `${plots[plot.target] ?? plot.target}_${plot.type}`;
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
return new PineIndicator({
|
|
331
|
+
pineId: data.result.metaInfo.scriptIdPart || indicID,
|
|
332
|
+
pineVersion: data.result.metaInfo.pine.version || version,
|
|
333
|
+
description: data.result.metaInfo.description,
|
|
334
|
+
shortDescription: data.result.metaInfo.shortDescription,
|
|
335
|
+
inputs,
|
|
336
|
+
plots,
|
|
337
|
+
script: data.result.ilTemplate,
|
|
338
|
+
});
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* @typedef {Object} User Instance of User
|
|
343
|
+
* @property {number} id User ID
|
|
344
|
+
* @property {string} username User username
|
|
345
|
+
* @property {string} firstName User first name
|
|
346
|
+
* @property {string} lastName User last name
|
|
347
|
+
* @property {number} reputation User reputation
|
|
348
|
+
* @property {number} following Number of following accounts
|
|
349
|
+
* @property {number} followers Number of followers
|
|
350
|
+
* @property {Object} notifications User's notifications
|
|
351
|
+
* @property {number} notifications.user User notifications
|
|
352
|
+
* @property {number} notifications.following Notification from following accounts
|
|
353
|
+
* @property {string} session User session
|
|
354
|
+
* @property {string} sessionHash User session hash
|
|
355
|
+
* @property {string} privateChannel User private channel
|
|
356
|
+
* @property {string} authToken User auth token
|
|
357
|
+
* @property {Date} joinDate Account creation date
|
|
358
|
+
*/
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Get a token for an user from a 'sessionid' cookie
|
|
362
|
+
* @param {string} session User 'sessionid' cookie
|
|
363
|
+
* @param {string} [location] Auth page location (For france: https://fr.tradingview.com/)
|
|
364
|
+
* @returns {Promise<User>} Token
|
|
365
|
+
*/
|
|
366
|
+
async getUser(session, location = 'https://www.tradingview.com/') {
|
|
367
|
+
return new Promise((cb, err) => {
|
|
368
|
+
https.get(location, {
|
|
369
|
+
headers: { cookie: `sessionid=${session}` },
|
|
370
|
+
}, (res) => {
|
|
371
|
+
let rs = '';
|
|
372
|
+
res.on('data', (d) => { rs += d; });
|
|
373
|
+
res.on('end', async () => {
|
|
374
|
+
if (res.headers.location && location !== res.headers.location) {
|
|
375
|
+
cb(await module.exports.getUser(session, res.headers.location));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (rs.includes('auth_token')) {
|
|
379
|
+
cb({
|
|
380
|
+
id: /"id":([0-9]{1,10}),/.exec(rs)[1],
|
|
381
|
+
username: /"username":"(.*?)"/.exec(rs)[1],
|
|
382
|
+
firstName: /"first_name":"(.*?)"/.exec(rs)[1],
|
|
383
|
+
lastName: /"last_name":"(.*?)"/.exec(rs)[1],
|
|
384
|
+
reputation: parseFloat(/"reputation":(.*?),/.exec(rs)[1] || 0),
|
|
385
|
+
following: parseFloat(/,"following":([0-9]*?),/.exec(rs)[1] || 0),
|
|
386
|
+
followers: parseFloat(/,"followers":([0-9]*?),/.exec(rs)[1] || 0),
|
|
387
|
+
notifications: {
|
|
388
|
+
following: parseFloat(/"notification_count":\{"following":([0-9]*),/.exec(rs)[1] || 0),
|
|
389
|
+
user: parseFloat(/"notification_count":\{"following":[0-9]*,"user":([0-9]*)/.exec(rs)[1] || 0),
|
|
390
|
+
},
|
|
391
|
+
session,
|
|
392
|
+
sessionHash: /"session_hash":"(.*?)"/.exec(rs)[1],
|
|
393
|
+
privateChannel: /"private_channel":"(.*?)"/.exec(rs)[1],
|
|
394
|
+
authToken: /"auth_token":"(.*?)"/.exec(rs)[1],
|
|
395
|
+
joinDate: new Date(/"date_joined":"(.*?)"/.exec(rs)[1] || 0),
|
|
396
|
+
});
|
|
397
|
+
} else err(new Error('Wrong or expired sessionid'));
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
res.on('error', err);
|
|
401
|
+
}).end();
|
|
402
|
+
});
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Get user's private indicators from a 'sessionid' cookie
|
|
407
|
+
* @param {string} session User 'sessionid' cookie
|
|
408
|
+
* @returns {Promise<SearchIndicatorResult[]>} Search results
|
|
409
|
+
*/
|
|
410
|
+
async getPrivateIndicators(session) {
|
|
411
|
+
return new Promise((cb, err) => {
|
|
412
|
+
https.get('https://pine-facade.tradingview.com/pine-facade/list?filter=saved', {
|
|
413
|
+
headers: { cookie: `sessionid=${session}` },
|
|
414
|
+
}, (res) => {
|
|
415
|
+
let rs = '';
|
|
416
|
+
res.on('data', (d) => { rs += d; });
|
|
417
|
+
res.on('end', async () => {
|
|
418
|
+
try {
|
|
419
|
+
rs = JSON.parse(rs);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
err(new Error('Can\'t parse private indicator list'));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
cb(rs.map((ind) => ({
|
|
426
|
+
id: ind.scriptIdPart,
|
|
427
|
+
version: ind.version,
|
|
428
|
+
name: ind.scriptName,
|
|
429
|
+
author: {
|
|
430
|
+
id: -1,
|
|
431
|
+
username: '@ME@',
|
|
432
|
+
},
|
|
433
|
+
image: ind.imageUrl,
|
|
434
|
+
access: 'private',
|
|
435
|
+
source: ind.scriptSource,
|
|
436
|
+
type: (ind.extra && ind.extra.kind) ? ind.extra.kind : 'study',
|
|
437
|
+
get() {
|
|
438
|
+
return module.exports.getIndicator(ind.scriptIdPart, ind.version);
|
|
439
|
+
},
|
|
440
|
+
})));
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
res.on('error', err);
|
|
444
|
+
}).end();
|
|
445
|
+
});
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* User credentials
|
|
450
|
+
* @typedef {Object} UserCredentials
|
|
451
|
+
* @property {number} id User ID
|
|
452
|
+
* @property {string} session User session ('sessionid' cookie)
|
|
453
|
+
*/
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Get a chart token from a layout ID and the user credentials if the layout is not public
|
|
457
|
+
* @param {string} layout The layout ID found in the layout URL (Like: 'XXXXXXXX')
|
|
458
|
+
* @param {UserCredentials} [credentials] User credentials (id + session)
|
|
459
|
+
* @returns {Promise<string>} Token
|
|
460
|
+
*/
|
|
461
|
+
async getChartToken(layout, credentials = {}) {
|
|
462
|
+
const creds = credentials.id && credentials.session;
|
|
463
|
+
const userID = creds ? credentials.id : -1;
|
|
464
|
+
const session = creds ? credentials.session : null;
|
|
465
|
+
|
|
466
|
+
const data = await request({
|
|
467
|
+
host: 'www.tradingview.com',
|
|
468
|
+
path: `/chart-token/?image_url=${layout}&user_id=${userID}`,
|
|
469
|
+
headers: { cookie: session ? `sessionid=${session}` : '' },
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
if (!data.token) throw new Error('Wrong layout or credentials');
|
|
473
|
+
|
|
474
|
+
return data.token;
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* @typedef {Object} DrawingPoint Drawing poitn
|
|
479
|
+
* @property {number} time_t Point X time position
|
|
480
|
+
* @property {number} price Point Y price position
|
|
481
|
+
* @property {number} offset Point offset
|
|
482
|
+
*/
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* @typedef {Object} Drawing
|
|
486
|
+
* @property {string} id Drawing ID (Like: 'XXXXXX')
|
|
487
|
+
* @property {string} symbol Layout market symbol (Like: 'BINANCE:BUCEUR')
|
|
488
|
+
* @property {string} ownerSource Owner user ID (Like: 'XXXXXX')
|
|
489
|
+
* @property {string} serverUpdateTime Drawing last update timestamp
|
|
490
|
+
* @property {string} currencyId Currency ID (Like: 'EUR')
|
|
491
|
+
* @property {any} unitId Unit ID
|
|
492
|
+
* @property {string} type Drawing type
|
|
493
|
+
* @property {DrawingPoint[]} points List of drawing points
|
|
494
|
+
* @property {number} zorder Drawing Z order
|
|
495
|
+
* @property {string} linkKey Drawing link key
|
|
496
|
+
* @property {Object} state Drawing state
|
|
497
|
+
*/
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Get a chart token from a layout ID and the user credentials if the layout is not public
|
|
501
|
+
* @param {string} layout The layout ID found in the layout URL (Like: 'XXXXXXXX')
|
|
502
|
+
* @param {string | ''} [symbol] Market filter (Like: 'BINANCE:BTCEUR')
|
|
503
|
+
* @param {UserCredentials} [credentials] User credentials (id + session)
|
|
504
|
+
* @param {number} [chartID = 1] Chart ID
|
|
505
|
+
* @returns {Promise<Drawing[]>} Drawings
|
|
506
|
+
*/
|
|
507
|
+
async getDrawings(layout, symbol = '', credentials = {}, chartID = 1) {
|
|
508
|
+
const chartToken = await module.exports.getChartToken(layout, credentials);
|
|
509
|
+
const creds = credentials.id && credentials.session;
|
|
510
|
+
const session = creds ? credentials.session : null;
|
|
511
|
+
|
|
512
|
+
const data = await request({
|
|
513
|
+
host: 'charts-storage.tradingview.com',
|
|
514
|
+
path: `/charts-storage/layout/${layout}/sources?chart_id=${chartID
|
|
515
|
+
}&jwt=${chartToken}${symbol ? `&symbol=${symbol}` : ''}`,
|
|
516
|
+
headers: { cookie: session ? `sessionid=${session}` : '' },
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
if (!data.payload) throw new Error('Wrong layout, user credentials, or chart id.');
|
|
520
|
+
|
|
521
|
+
return Object.values(data.payload.sources || {}).map((drawing) => ({
|
|
522
|
+
...drawing, ...drawing.state,
|
|
523
|
+
}));
|
|
524
|
+
},
|
|
525
|
+
};
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const JSZip = require('jszip');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} TWPacket
|
|
5
|
+
* @prop {string} [m] Packet type
|
|
6
|
+
* @prop {[session: string, {}]} [p] Packet data
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
/**
|
|
11
|
+
* Parse websocket packet
|
|
12
|
+
* @param {string} str Websocket raw data
|
|
13
|
+
* @returns {TWPacket[]} TradingView packets
|
|
14
|
+
*/
|
|
15
|
+
parseWSPacket(str) {
|
|
16
|
+
return str.replace(/~h~/g, '').split(/~m~[0-9]{1,}~m~/g)
|
|
17
|
+
.map((p) => {
|
|
18
|
+
if (!p) return false;
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(p);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.warn('Cant parse', p);
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
.filter((p) => p);
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Format websocket packet
|
|
31
|
+
* @param {TWPacket} packet TradingView packet
|
|
32
|
+
* @returns {string} Websocket raw data
|
|
33
|
+
*/
|
|
34
|
+
formatWSPacket(packet) {
|
|
35
|
+
const msg = typeof packet === 'object'
|
|
36
|
+
? JSON.stringify(packet)
|
|
37
|
+
: packet;
|
|
38
|
+
return `~m~${msg.length}~m~${msg}`;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse compressed data
|
|
43
|
+
* @param {string} data Compressed data
|
|
44
|
+
* @returns {Promise<{}>} Parsed data
|
|
45
|
+
*/
|
|
46
|
+
async parseCompressed(data) {
|
|
47
|
+
const zip = new JSZip();
|
|
48
|
+
return JSON.parse(
|
|
49
|
+
await (
|
|
50
|
+
await zip.loadAsync(data, { base64: true })
|
|
51
|
+
).file('').async('text'),
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {'loaded' | 'data' | 'error'} MarketEvent
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {import('./session').QuoteSessionBridge} quoteSession
|
|
7
|
+
*/
|
|
8
|
+
module.exports = (quoteSession) => class QuoteMarket {
|
|
9
|
+
#symbolListeners = quoteSession.symbolListeners;
|
|
10
|
+
|
|
11
|
+
#symbol;
|
|
12
|
+
|
|
13
|
+
#symbolListenerID = 0;
|
|
14
|
+
|
|
15
|
+
#lastData = {};
|
|
16
|
+
|
|
17
|
+
#callbacks = {
|
|
18
|
+
loaded: [],
|
|
19
|
+
data: [],
|
|
20
|
+
|
|
21
|
+
event: [],
|
|
22
|
+
error: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {MarketEvent} ev Client event
|
|
27
|
+
* @param {...{}} data Packet data
|
|
28
|
+
*/
|
|
29
|
+
#handleEvent(ev, ...data) {
|
|
30
|
+
this.#callbacks[ev].forEach((e) => e(...data));
|
|
31
|
+
this.#callbacks.event.forEach((e) => e(ev, ...data));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#handleError(...msgs) {
|
|
35
|
+
if (this.#callbacks.error.length === 0) console.error(...msgs);
|
|
36
|
+
else this.#handleEvent('error', ...msgs);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} symbol Market symbol (like: 'BTCEUR' or 'KRAKEN:BTCEUR')
|
|
41
|
+
*/
|
|
42
|
+
constructor(symbol) {
|
|
43
|
+
this.#symbol = symbol;
|
|
44
|
+
|
|
45
|
+
if (!this.#symbolListeners[symbol]) {
|
|
46
|
+
this.#symbolListeners[symbol] = [];
|
|
47
|
+
quoteSession.send('quote_add_symbols', [
|
|
48
|
+
quoteSession.sessionID,
|
|
49
|
+
symbol,
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
this.#symbolListenerID = this.#symbolListeners[symbol].length;
|
|
53
|
+
|
|
54
|
+
this.#symbolListeners[symbol][this.#symbolListenerID] = (packet) => {
|
|
55
|
+
if (global.TW_DEBUG) console.log('§90§30§105 MARKET §0 DATA', packet);
|
|
56
|
+
|
|
57
|
+
if (packet.type === 'qsd' && packet.data[1].s === 'ok') {
|
|
58
|
+
this.#lastData = {
|
|
59
|
+
...this.#lastData,
|
|
60
|
+
...packet.data[1].v,
|
|
61
|
+
};
|
|
62
|
+
this.#handleEvent('data', this.#lastData);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (packet.type === 'quote_completed') {
|
|
67
|
+
this.#handleEvent('loaded');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (packet.type === 'qsd' && packet.data[1].s === 'error') {
|
|
72
|
+
this.#handleError('Market error', packet.data);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* When quote market is loaded
|
|
79
|
+
* @param {() => void} cb Callback
|
|
80
|
+
* @event
|
|
81
|
+
*/
|
|
82
|
+
onLoaded(cb) {
|
|
83
|
+
this.#callbacks.loaded.push(cb);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* When quote data is received
|
|
88
|
+
* @param {(data: {}) => void} cb Callback
|
|
89
|
+
* @event
|
|
90
|
+
*/
|
|
91
|
+
onData(cb) {
|
|
92
|
+
this.#callbacks.data.push(cb);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* When quote event happens
|
|
97
|
+
* @param {(...any) => void} cb Callback
|
|
98
|
+
* @event
|
|
99
|
+
*/
|
|
100
|
+
onEvent(cb) {
|
|
101
|
+
this.#callbacks.event.push(cb);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* When quote error happens
|
|
106
|
+
* @param {(...any) => void} cb Callback
|
|
107
|
+
* @event
|
|
108
|
+
*/
|
|
109
|
+
onError(cb) {
|
|
110
|
+
this.#callbacks.error.push(cb);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Close this listener */
|
|
114
|
+
close() {
|
|
115
|
+
if (this.#symbolListeners[this.#symbol].length <= 1) {
|
|
116
|
+
quoteSession.send('quote_remove_symbols', [
|
|
117
|
+
quoteSession.sessionID,
|
|
118
|
+
this.#symbol,
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
delete this.#symbolListeners[this.#symbol][this.#symbolListenerID];
|
|
122
|
+
}
|
|
123
|
+
};
|