@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.
@@ -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
+ };
@@ -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
+ };