@tastytrade/api 1.0.0 → 2.1.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/.env CHANGED
@@ -1,5 +1,4 @@
1
1
  BASE_URL=https://api.cert.tastyworks.com
2
2
  API_USERNAME=devin.moss@tastytrade.com
3
3
  API_PASSWORD=rebuke-padding-vizor
4
- API_ACCOUNT_NUMBER=5WT05179
5
- API_CUSTOMER_ID=31
4
+ API_ACCOUNT_NUMBER=5WT05179
package/README.md CHANGED
@@ -18,30 +18,36 @@ const accountPositions = await tastytradeClient.balancesAndPositionsService.getP
18
18
 
19
19
  ### Market Data
20
20
  ```js
21
- import TastytradeClient, { QuoteStreamer } from "@tastytrade-api"
21
+ import TastytradeClient, { MarketDataStreamer, MarketDataSubscriptionType } from "@tastytrade-api"
22
22
  const tastytradeClient = new TastytradeClient(baseUrl, accountStreamerUrl)
23
23
  await tastytradeClient.sessionService.login(usernameOrEmail, pasword)
24
- const tokenResponse = await tastytradeClient.AccountsAndCustomersService.getQuoteStreamerTokens()
25
- const quoteStreamer = new QuoteStreamer(tokenResponse.token, `${tokenResponse['websocket-url']}/cometd`)
26
- quoteStreamer.connect()
24
+ const tokenResponse = await tastytradeClient.AccountsAndCustomersService.getApiQuoteToken()
25
+ const streamer = new MarketDataStreamer()
26
+ streamer.connect(tokenResponse['dxlink-url'], tokenResponse.token)
27
27
 
28
- function handleMarketDataReceived(event) {
28
+ function handleMarketDataReceived(data) {
29
29
  // Triggers every time market data event occurs
30
- console.log(event)
30
+ console.log(data)
31
31
  }
32
+
33
+ // Add a listener for incoming market data. Returns a remove() function that removes the listener from the quote streamer
34
+ const removeDataListener = streamer.addDataListener(handleMarketDataReceived)
35
+
32
36
  // Subscribe to a single equity quote
33
- quoteStreamer.subscribe('AAPL', handleMarketDataReceived)
37
+ streamer.addSubscription('AAPL')
38
+ // Optionally specify which market data events you want to subscribe to
39
+ streamer.addSubscription('SPY', { subscriptionTypes: [MarketDataSubscriptionType.Quote] })
34
40
 
35
41
  // Subscribe to a single equity option quote
36
42
  const optionChain = await tastytradeClient.instrumentsService.getOptionChain('AAPL')
37
- quoteStreamer.subscribe(optionChain[0]['streamer-symbol'], handleMarketDataReceived)
43
+ streamer.addSubscription(optionChain[0]['streamer-symbol'])
38
44
  ```
39
45
 
40
46
  ### Account Streamer
41
47
  ```js
42
48
  const TastytradeApi = require("@tastytrade/api")
43
49
  const TastytradeClient = TastytradeApi.default
44
- const { AccountStreamer, QuoteStreamer } = TastytradeApi
50
+ const { AccountStreamer } = TastytradeApi
45
51
  const _ = require('lodash')
46
52
 
47
53
  function handleStreamerMessage(json) {
@@ -79,6 +85,16 @@ global.window = { WebSocket, setTimeout, clearTimeout }
79
85
  `npm run build`
80
86
  Outputs everything to `dist/`
81
87
 
88
+ ## Running tests locally
89
+ Add a `.env` file with the following keys (you'll have to fill in the values yourself):
90
+
91
+ ```
92
+ BASE_URL=https://api.cert.tastyworks.com
93
+ API_USERNAME=<your cert username>
94
+ API_PASSWORD=<your cert password>
95
+ API_ACCOUNT_NUMBER=<your cert account number>
96
+ ```
97
+
82
98
  ## Running example app
83
99
  ```sh
84
100
  npm run build
@@ -0,0 +1,95 @@
1
+ export declare enum MarketDataSubscriptionType {
2
+ Candle = "Candle",
3
+ Quote = "Quote",
4
+ Trade = "Trade",
5
+ Summary = "Summary",
6
+ Profile = "Profile",
7
+ Greeks = "Greeks",
8
+ Underlying = "Underlying"
9
+ }
10
+ export declare enum CandleType {
11
+ Tick = "t",
12
+ Second = "s",
13
+ Minute = "m",
14
+ Hour = "h",
15
+ Day = "d",
16
+ Week = "w",
17
+ Month = "mo",
18
+ ThirdFriday = "o",
19
+ Year = "y",
20
+ Volume = "v",
21
+ Price = "p"
22
+ }
23
+ export declare type MarketDataListener = (data: any) => void;
24
+ export declare type ErrorListener = (error: any) => void;
25
+ export declare type AuthStateListener = (isAuthorized: boolean) => void;
26
+ export declare type CandleSubscriptionOptions = {
27
+ period: number;
28
+ type: CandleType;
29
+ channelId: number;
30
+ };
31
+ declare type Remover = () => void;
32
+ export default class MarketDataStreamer {
33
+ private webSocket;
34
+ private token;
35
+ private keepaliveIntervalId;
36
+ private dataListeners;
37
+ private openChannels;
38
+ private subscriptionsQueue;
39
+ private authState;
40
+ private errorListeners;
41
+ private authStateListeners;
42
+ addDataListener(dataListener: MarketDataListener, channelId?: number | null): Remover;
43
+ addErrorListener(errorListener: ErrorListener): Remover;
44
+ addAuthStateChangeListener(authStateListener: AuthStateListener): Remover;
45
+ connect(url: string, token: string): void;
46
+ disconnect(): void;
47
+ addSubscription(symbol: string, options?: {
48
+ subscriptionTypes: MarketDataSubscriptionType[];
49
+ channelId: number;
50
+ }): Remover;
51
+ /**
52
+ * Adds a candle subscription (historical data)
53
+ * @param streamerSymbol Get this from an instrument's streamer-symbol json response field
54
+ * @param fromTime Epoch timestamp from where you want to start
55
+ * @param options Period and Type are the grouping you want to apply to the candle data
56
+ * For example, a period/type of 5/m means you want each candle to represent 5 minutes of data
57
+ * From there, setting fromTime to 24 hours ago would give you 24 hours of data grouped in 5 minute intervals
58
+ * @returns
59
+ */
60
+ addCandleSubscription(streamerSymbol: string, fromTime: number, options: CandleSubscriptionOptions): () => void;
61
+ removeSubscription(symbol: string, options?: {
62
+ subscriptionTypes: MarketDataSubscriptionType[];
63
+ channelId: number;
64
+ }): void;
65
+ removeAllSubscriptions(channelId?: number): void;
66
+ openFeedChannel(channelId: number): void;
67
+ isChannelOpened(channelId: number): boolean;
68
+ get isReadyToOpenChannels(): boolean;
69
+ get isConnected(): boolean;
70
+ private scheduleKeepalive;
71
+ private sendKeepalive;
72
+ private queueSubscription;
73
+ private dequeueSubscription;
74
+ private sendQueuedSubscriptions;
75
+ /**
76
+ *
77
+ * @param {*} symbol
78
+ * @param {*} subscriptionTypes
79
+ * @param {*} channelId
80
+ * @param {*} direction add or remove
81
+ */
82
+ private sendSubscriptionMessage;
83
+ private onError;
84
+ private onOpen;
85
+ private onClose;
86
+ private clearKeepalive;
87
+ get isDxLinkAuthorized(): boolean;
88
+ private handleAuthStateMessage;
89
+ private handleChannelOpened;
90
+ private notifyListeners;
91
+ private notifyErrorListeners;
92
+ private handleMessageReceived;
93
+ private sendMessage;
94
+ }
95
+ export {};
@@ -0,0 +1,345 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.CandleType = exports.MarketDataSubscriptionType = void 0;
7
+ var isomorphic_ws_1 = __importDefault(require("isomorphic-ws"));
8
+ var lodash_1 = __importDefault(require("lodash"));
9
+ var uuid_1 = require("uuid");
10
+ var MarketDataSubscriptionType;
11
+ (function (MarketDataSubscriptionType) {
12
+ MarketDataSubscriptionType["Candle"] = "Candle";
13
+ MarketDataSubscriptionType["Quote"] = "Quote";
14
+ MarketDataSubscriptionType["Trade"] = "Trade";
15
+ MarketDataSubscriptionType["Summary"] = "Summary";
16
+ MarketDataSubscriptionType["Profile"] = "Profile";
17
+ MarketDataSubscriptionType["Greeks"] = "Greeks";
18
+ MarketDataSubscriptionType["Underlying"] = "Underlying";
19
+ })(MarketDataSubscriptionType = exports.MarketDataSubscriptionType || (exports.MarketDataSubscriptionType = {}));
20
+ var CandleType;
21
+ (function (CandleType) {
22
+ CandleType["Tick"] = "t";
23
+ CandleType["Second"] = "s";
24
+ CandleType["Minute"] = "m";
25
+ CandleType["Hour"] = "h";
26
+ CandleType["Day"] = "d";
27
+ CandleType["Week"] = "w";
28
+ CandleType["Month"] = "mo";
29
+ CandleType["ThirdFriday"] = "o";
30
+ CandleType["Year"] = "y";
31
+ CandleType["Volume"] = "v";
32
+ CandleType["Price"] = "p";
33
+ })(CandleType = exports.CandleType || (exports.CandleType = {}));
34
+ // List of all subscription types except for Candle
35
+ var AllSubscriptionTypes = Object.values(MarketDataSubscriptionType);
36
+ var KeepaliveInterval = 30000; // 30 seconds
37
+ var DefaultChannelId = 1;
38
+ var MarketDataStreamer = /** @class */ (function () {
39
+ function MarketDataStreamer() {
40
+ this.webSocket = null;
41
+ this.token = '';
42
+ this.keepaliveIntervalId = null;
43
+ this.dataListeners = new Map();
44
+ this.openChannels = new Set();
45
+ this.subscriptionsQueue = new Map();
46
+ this.authState = '';
47
+ this.errorListeners = new Map();
48
+ this.authStateListeners = new Map();
49
+ }
50
+ MarketDataStreamer.prototype.addDataListener = function (dataListener, channelId) {
51
+ var _this = this;
52
+ if (channelId === void 0) { channelId = null; }
53
+ if (lodash_1.default.isNil(dataListener)) {
54
+ return lodash_1.default.noop;
55
+ }
56
+ var guid = (0, uuid_1.v4)();
57
+ this.dataListeners.set(guid, { listener: dataListener, channelId: channelId });
58
+ return function () { return _this.dataListeners.delete(guid); };
59
+ };
60
+ MarketDataStreamer.prototype.addErrorListener = function (errorListener) {
61
+ var _this = this;
62
+ if (lodash_1.default.isNil(errorListener)) {
63
+ return lodash_1.default.noop;
64
+ }
65
+ var guid = (0, uuid_1.v4)();
66
+ this.errorListeners.set(guid, errorListener);
67
+ return function () { return _this.errorListeners.delete(guid); };
68
+ };
69
+ MarketDataStreamer.prototype.addAuthStateChangeListener = function (authStateListener) {
70
+ var _this = this;
71
+ if (lodash_1.default.isNil(authStateListener)) {
72
+ return lodash_1.default.noop;
73
+ }
74
+ var guid = (0, uuid_1.v4)();
75
+ this.authStateListeners.set(guid, authStateListener);
76
+ return function () { return _this.authStateListeners.delete(guid); };
77
+ };
78
+ MarketDataStreamer.prototype.connect = function (url, token) {
79
+ if (this.isConnected) {
80
+ throw new Error('MarketDataStreamer is attempting to connect when an existing websocket is already connected');
81
+ }
82
+ this.token = token;
83
+ this.webSocket = new isomorphic_ws_1.default(url);
84
+ this.webSocket.onopen = this.onOpen.bind(this);
85
+ this.webSocket.onerror = this.onError.bind(this);
86
+ this.webSocket.onmessage = this.handleMessageReceived.bind(this);
87
+ this.webSocket.onclose = this.onClose.bind(this);
88
+ };
89
+ MarketDataStreamer.prototype.disconnect = function () {
90
+ if (lodash_1.default.isNil(this.webSocket)) {
91
+ return;
92
+ }
93
+ this.clearKeepalive();
94
+ this.webSocket.onopen = null;
95
+ this.webSocket.onerror = null;
96
+ this.webSocket.onmessage = null;
97
+ this.webSocket.onclose = null;
98
+ this.webSocket.close();
99
+ this.webSocket = null;
100
+ this.openChannels.clear();
101
+ this.subscriptionsQueue.clear();
102
+ this.authState = '';
103
+ };
104
+ MarketDataStreamer.prototype.addSubscription = function (symbol, options) {
105
+ var _this = this;
106
+ if (options === void 0) { options = { subscriptionTypes: AllSubscriptionTypes, channelId: DefaultChannelId }; }
107
+ var subscriptionTypes = options.subscriptionTypes;
108
+ // Don't allow candle subscriptions in this method. Use addCandleSubscription instead
109
+ subscriptionTypes = lodash_1.default.without(subscriptionTypes, MarketDataSubscriptionType.Candle);
110
+ var isOpen = this.isChannelOpened(options.channelId);
111
+ if (isOpen) {
112
+ this.sendSubscriptionMessage(symbol, subscriptionTypes, options.channelId, 'add');
113
+ }
114
+ else {
115
+ this.queueSubscription(symbol, { subscriptionTypes: subscriptionTypes, channelId: options.channelId });
116
+ }
117
+ return function () {
118
+ _this.removeSubscription(symbol, options);
119
+ };
120
+ };
121
+ /**
122
+ * Adds a candle subscription (historical data)
123
+ * @param streamerSymbol Get this from an instrument's streamer-symbol json response field
124
+ * @param fromTime Epoch timestamp from where you want to start
125
+ * @param options Period and Type are the grouping you want to apply to the candle data
126
+ * For example, a period/type of 5/m means you want each candle to represent 5 minutes of data
127
+ * From there, setting fromTime to 24 hours ago would give you 24 hours of data grouped in 5 minute intervals
128
+ * @returns
129
+ */
130
+ MarketDataStreamer.prototype.addCandleSubscription = function (streamerSymbol, fromTime, options) {
131
+ var _this = this;
132
+ var _a;
133
+ var subscriptionTypes = [MarketDataSubscriptionType.Candle];
134
+ var channelId = (_a = options.channelId) !== null && _a !== void 0 ? _a : DefaultChannelId;
135
+ // Example: AAPL{=5m} where each candle represents 5 minutes of data
136
+ var candleSymbol = "".concat(streamerSymbol, "{=").concat(options.period).concat(options.type, "}");
137
+ var isOpen = this.isChannelOpened(channelId);
138
+ var subscriptionArgs = { fromTime: fromTime };
139
+ if (isOpen) {
140
+ this.sendSubscriptionMessage(candleSymbol, subscriptionTypes, channelId, 'add', subscriptionArgs);
141
+ }
142
+ else {
143
+ this.queueSubscription(candleSymbol, { subscriptionTypes: subscriptionTypes, channelId: channelId, subscriptionArgs: subscriptionArgs });
144
+ }
145
+ return function () {
146
+ _this.removeSubscription(candleSymbol, { subscriptionTypes: subscriptionTypes, channelId: channelId });
147
+ };
148
+ };
149
+ MarketDataStreamer.prototype.removeSubscription = function (symbol, options) {
150
+ if (options === void 0) { options = { subscriptionTypes: AllSubscriptionTypes, channelId: DefaultChannelId }; }
151
+ var subscriptionTypes = options.subscriptionTypes, channelId = options.channelId;
152
+ var isOpen = this.isChannelOpened(channelId);
153
+ if (isOpen) {
154
+ this.sendSubscriptionMessage(symbol, subscriptionTypes, channelId, 'remove');
155
+ }
156
+ else {
157
+ this.dequeueSubscription(symbol, options);
158
+ }
159
+ };
160
+ MarketDataStreamer.prototype.removeAllSubscriptions = function (channelId) {
161
+ if (channelId === void 0) { channelId = DefaultChannelId; }
162
+ var isOpen = this.isChannelOpened(channelId);
163
+ if (isOpen) {
164
+ this.sendMessage({ "type": "FEED_SUBSCRIPTION", "channel": channelId, reset: true });
165
+ }
166
+ else {
167
+ this.subscriptionsQueue.set(channelId, []);
168
+ }
169
+ };
170
+ MarketDataStreamer.prototype.openFeedChannel = function (channelId) {
171
+ if (!this.isReadyToOpenChannels) {
172
+ throw new Error("Unable to open channel ".concat(channelId, " due to DxLink authorization state: ").concat(this.authState));
173
+ }
174
+ if (this.isChannelOpened(channelId)) {
175
+ return;
176
+ }
177
+ this.sendMessage({
178
+ "type": "CHANNEL_REQUEST",
179
+ "channel": channelId,
180
+ "service": "FEED",
181
+ "parameters": {
182
+ "contract": "AUTO"
183
+ }
184
+ });
185
+ };
186
+ MarketDataStreamer.prototype.isChannelOpened = function (channelId) {
187
+ return this.isConnected && this.openChannels.has(channelId);
188
+ };
189
+ Object.defineProperty(MarketDataStreamer.prototype, "isReadyToOpenChannels", {
190
+ get: function () {
191
+ return this.isConnected && this.isDxLinkAuthorized;
192
+ },
193
+ enumerable: false,
194
+ configurable: true
195
+ });
196
+ Object.defineProperty(MarketDataStreamer.prototype, "isConnected", {
197
+ get: function () {
198
+ return !lodash_1.default.isNil(this.webSocket);
199
+ },
200
+ enumerable: false,
201
+ configurable: true
202
+ });
203
+ MarketDataStreamer.prototype.scheduleKeepalive = function () {
204
+ this.keepaliveIntervalId = setInterval(this.sendKeepalive, KeepaliveInterval);
205
+ };
206
+ MarketDataStreamer.prototype.sendKeepalive = function () {
207
+ if (lodash_1.default.isNil(this.keepaliveIntervalId)) {
208
+ return;
209
+ }
210
+ this.sendMessage({
211
+ "type": "KEEPALIVE",
212
+ "channel": 0
213
+ });
214
+ };
215
+ MarketDataStreamer.prototype.queueSubscription = function (symbol, options) {
216
+ var subscriptionTypes = options.subscriptionTypes, channelId = options.channelId, subscriptionArgs = options.subscriptionArgs;
217
+ var queue = this.subscriptionsQueue.get(options.channelId);
218
+ if (lodash_1.default.isNil(queue)) {
219
+ queue = [];
220
+ this.subscriptionsQueue.set(channelId, queue);
221
+ }
222
+ queue.push({ symbol: symbol, subscriptionTypes: subscriptionTypes, subscriptionArgs: subscriptionArgs });
223
+ };
224
+ MarketDataStreamer.prototype.dequeueSubscription = function (symbol, options) {
225
+ var queue = this.subscriptionsQueue.get(options.channelId);
226
+ if (lodash_1.default.isNil(queue) || lodash_1.default.isEmpty(queue)) {
227
+ return;
228
+ }
229
+ lodash_1.default.remove(queue, function (queueItem) { return queueItem.symbol === symbol; });
230
+ };
231
+ MarketDataStreamer.prototype.sendQueuedSubscriptions = function (channelId) {
232
+ var _this = this;
233
+ var queuedSubscriptions = this.subscriptionsQueue.get(channelId);
234
+ if (lodash_1.default.isNil(queuedSubscriptions)) {
235
+ return;
236
+ }
237
+ // Clear out queue immediately
238
+ this.subscriptionsQueue.set(channelId, []);
239
+ queuedSubscriptions.forEach(function (subscription) {
240
+ _this.sendSubscriptionMessage(subscription.symbol, subscription.subscriptionTypes, channelId, 'add', subscription.subscriptionArgs);
241
+ });
242
+ };
243
+ /**
244
+ *
245
+ * @param {*} symbol
246
+ * @param {*} subscriptionTypes
247
+ * @param {*} channelId
248
+ * @param {*} direction add or remove
249
+ */
250
+ MarketDataStreamer.prototype.sendSubscriptionMessage = function (symbol, subscriptionTypes, channelId, direction, subscriptionArgs) {
251
+ var _a;
252
+ if (subscriptionArgs === void 0) { subscriptionArgs = {}; }
253
+ var subscriptions = subscriptionTypes.map(function (type) { return (Object.assign({}, { "symbol": symbol, "type": type }, subscriptionArgs !== null && subscriptionArgs !== void 0 ? subscriptionArgs : {})); });
254
+ this.sendMessage((_a = {
255
+ "type": "FEED_SUBSCRIPTION",
256
+ "channel": channelId
257
+ },
258
+ _a[direction] = subscriptions,
259
+ _a));
260
+ };
261
+ MarketDataStreamer.prototype.onError = function (error) {
262
+ console.error('Error received: ', error);
263
+ this.notifyErrorListeners(error);
264
+ };
265
+ MarketDataStreamer.prototype.onOpen = function () {
266
+ this.openChannels.clear();
267
+ this.sendMessage({
268
+ "type": "SETUP",
269
+ "channel": 0,
270
+ "keepaliveTimeout": KeepaliveInterval,
271
+ "acceptKeepaliveTimeout": KeepaliveInterval,
272
+ "version": "0.1-js/1.0.0"
273
+ });
274
+ this.scheduleKeepalive();
275
+ };
276
+ MarketDataStreamer.prototype.onClose = function () {
277
+ this.webSocket = null;
278
+ this.clearKeepalive();
279
+ };
280
+ MarketDataStreamer.prototype.clearKeepalive = function () {
281
+ if (!lodash_1.default.isNil(this.keepaliveIntervalId)) {
282
+ clearInterval(this.keepaliveIntervalId);
283
+ }
284
+ this.keepaliveIntervalId = null;
285
+ };
286
+ Object.defineProperty(MarketDataStreamer.prototype, "isDxLinkAuthorized", {
287
+ get: function () {
288
+ return this.authState === 'AUTHORIZED';
289
+ },
290
+ enumerable: false,
291
+ configurable: true
292
+ });
293
+ MarketDataStreamer.prototype.handleAuthStateMessage = function (data) {
294
+ var _this = this;
295
+ this.authState = data.state;
296
+ this.authStateListeners.forEach(function (listener) { return listener(_this.isDxLinkAuthorized); });
297
+ if (this.isDxLinkAuthorized) {
298
+ this.openFeedChannel(DefaultChannelId);
299
+ }
300
+ else {
301
+ this.sendMessage({
302
+ "type": "AUTH",
303
+ "channel": 0,
304
+ "token": this.token
305
+ });
306
+ }
307
+ };
308
+ MarketDataStreamer.prototype.handleChannelOpened = function (jsonData) {
309
+ this.openChannels.add(jsonData.channel);
310
+ this.sendQueuedSubscriptions(jsonData.channel);
311
+ };
312
+ MarketDataStreamer.prototype.notifyListeners = function (jsonData) {
313
+ this.dataListeners.forEach(function (listenerData) {
314
+ if (listenerData.channelId === jsonData.channel || lodash_1.default.isNil(listenerData.channelId)) {
315
+ listenerData.listener(jsonData);
316
+ }
317
+ });
318
+ };
319
+ MarketDataStreamer.prototype.notifyErrorListeners = function (error) {
320
+ this.errorListeners.forEach(function (listener) { return listener(error); });
321
+ };
322
+ MarketDataStreamer.prototype.handleMessageReceived = function (data) {
323
+ var messageData = lodash_1.default.get(data, 'data', data);
324
+ var jsonData = JSON.parse(messageData);
325
+ switch (jsonData.type) {
326
+ case 'AUTH_STATE':
327
+ this.handleAuthStateMessage(jsonData);
328
+ break;
329
+ case 'CHANNEL_OPENED':
330
+ this.handleChannelOpened(jsonData);
331
+ break;
332
+ case 'FEED_DATA':
333
+ this.notifyListeners(jsonData);
334
+ break;
335
+ }
336
+ };
337
+ MarketDataStreamer.prototype.sendMessage = function (json) {
338
+ if (lodash_1.default.isNil(this.webSocket)) {
339
+ return;
340
+ }
341
+ this.webSocket.send(JSON.stringify(json));
342
+ };
343
+ return MarketDataStreamer;
344
+ }());
345
+ exports.default = MarketDataStreamer;
@@ -6,5 +6,5 @@ export default class AccountsAndCustomersService {
6
6
  getCustomerResource(): Promise<any>;
7
7
  getCustomerAccountResources(): Promise<any>;
8
8
  getFullCustomerAccountResource(accountNumber: string): Promise<any>;
9
- getQuoteStreamerTokens(): Promise<any>;
9
+ getApiQuoteToken(): Promise<any>;
10
10
  }
@@ -97,16 +97,16 @@ var AccountsAndCustomersService = /** @class */ (function () {
97
97
  });
98
98
  });
99
99
  };
100
- //Quote-streamer-tokens: Operations about quote-streamer-tokens
101
- AccountsAndCustomersService.prototype.getQuoteStreamerTokens = function () {
100
+ //Returns the appropriate quote streamer endpoint, level and identification token for the current customer to receive market data.
101
+ AccountsAndCustomersService.prototype.getApiQuoteToken = function () {
102
102
  return __awaiter(this, void 0, void 0, function () {
103
- var quoteStreamerTokens;
103
+ var apiQuoteToken;
104
104
  return __generator(this, function (_a) {
105
105
  switch (_a.label) {
106
- case 0: return [4 /*yield*/, this.httpClient.getData('/quote-streamer-tokens', {}, {})];
106
+ case 0: return [4 /*yield*/, this.httpClient.getData('/api-quote-tokens', {}, {})];
107
107
  case 1:
108
- quoteStreamerTokens = (_a.sent());
109
- return [2 /*return*/, (0, response_util_1.default)(quoteStreamerTokens)];
108
+ apiQuoteToken = (_a.sent());
109
+ return [2 /*return*/, (0, response_util_1.default)(apiQuoteToken)];
110
110
  }
111
111
  });
112
112
  });
@@ -1,6 +1,6 @@
1
1
  import TastytradeHttpClient from "./services/tastytrade-http-client";
2
2
  import { AccountStreamer, STREAMER_STATE, Disposer, StreamerStateObserver } from './account-streamer';
3
- import QuoteStreamer from "./quote-streamer";
3
+ import MarketDataStreamer, { CandleSubscriptionOptions, CandleType, MarketDataSubscriptionType, MarketDataListener } from "./market-data-streamer";
4
4
  import SessionService from "./services/session-service";
5
5
  import AccountStatusService from "./services/account-status-service";
6
6
  import AccountsAndCustomersService from "./services/accounts-and-customers-service";
@@ -36,5 +36,5 @@ export default class TastytradeClient {
36
36
  constructor(baseUrl: string, accountStreamerUrl: string);
37
37
  get session(): TastytradeSession;
38
38
  }
39
- export { QuoteStreamer };
39
+ export { MarketDataStreamer, MarketDataSubscriptionType, MarketDataListener, CandleSubscriptionOptions, CandleType };
40
40
  export { AccountStreamer, STREAMER_STATE, Disposer, StreamerStateObserver };
@@ -1,15 +1,40 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
2
25
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
26
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
27
  };
5
28
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.STREAMER_STATE = exports.AccountStreamer = exports.QuoteStreamer = void 0;
29
+ exports.STREAMER_STATE = exports.AccountStreamer = exports.CandleType = exports.MarketDataSubscriptionType = exports.MarketDataStreamer = void 0;
7
30
  var tastytrade_http_client_1 = __importDefault(require("./services/tastytrade-http-client"));
8
31
  var account_streamer_1 = require("./account-streamer");
9
32
  Object.defineProperty(exports, "AccountStreamer", { enumerable: true, get: function () { return account_streamer_1.AccountStreamer; } });
10
33
  Object.defineProperty(exports, "STREAMER_STATE", { enumerable: true, get: function () { return account_streamer_1.STREAMER_STATE; } });
11
- var quote_streamer_1 = __importDefault(require("./quote-streamer"));
12
- exports.QuoteStreamer = quote_streamer_1.default;
34
+ var market_data_streamer_1 = __importStar(require("./market-data-streamer"));
35
+ exports.MarketDataStreamer = market_data_streamer_1.default;
36
+ Object.defineProperty(exports, "CandleType", { enumerable: true, get: function () { return market_data_streamer_1.CandleType; } });
37
+ Object.defineProperty(exports, "MarketDataSubscriptionType", { enumerable: true, get: function () { return market_data_streamer_1.MarketDataSubscriptionType; } });
13
38
  //Services:
14
39
  var session_service_1 = __importDefault(require("./services/session-service"));
15
40
  var account_status_service_1 = __importDefault(require("./services/account-status-service"));
@@ -0,0 +1,369 @@
1
+ import WebSocket from 'isomorphic-ws'
2
+ import _ from 'lodash'
3
+ import { v4 as uuidv4 } from 'uuid'
4
+
5
+ export enum MarketDataSubscriptionType {
6
+ Candle = 'Candle',
7
+ Quote = 'Quote',
8
+ Trade = 'Trade',
9
+ Summary = 'Summary',
10
+ Profile = 'Profile',
11
+ Greeks = 'Greeks',
12
+ Underlying = 'Underlying'
13
+ }
14
+
15
+ export enum CandleType {
16
+ Tick = 't',
17
+ Second = 's',
18
+ Minute = 'm',
19
+ Hour = 'h',
20
+ Day = 'd',
21
+ Week = 'w',
22
+ Month = 'mo',
23
+ ThirdFriday = 'o',
24
+ Year = 'y',
25
+ Volume = 'v',
26
+ Price = 'p'
27
+ }
28
+
29
+ export type MarketDataListener = (data: any) => void
30
+ export type ErrorListener = (error: any) => void
31
+ export type AuthStateListener = (isAuthorized: boolean) => void
32
+
33
+ type QueuedSubscription = { symbol: string, subscriptionTypes: MarketDataSubscriptionType[], subscriptionArgs?: any }
34
+ type SubscriptionOptions = { subscriptionTypes: MarketDataSubscriptionType[], channelId: number, subscriptionArgs?: any }
35
+ export type CandleSubscriptionOptions = { period: number, type: CandleType, channelId: number }
36
+ type Remover = () => void
37
+
38
+ // List of all subscription types except for Candle
39
+ const AllSubscriptionTypes = Object.values(MarketDataSubscriptionType)
40
+
41
+ const KeepaliveInterval = 30000 // 30 seconds
42
+
43
+ const DefaultChannelId = 1
44
+
45
+ export default class MarketDataStreamer {
46
+ private webSocket: WebSocket | null = null
47
+ private token = ''
48
+ private keepaliveIntervalId: any | null = null
49
+ private dataListeners = new Map()
50
+ private openChannels = new Set()
51
+ private subscriptionsQueue: Map<number, QueuedSubscription[]> = new Map()
52
+ private authState = ''
53
+ private errorListeners = new Map()
54
+ private authStateListeners = new Map()
55
+
56
+ addDataListener(dataListener: MarketDataListener, channelId: number | null = null): Remover {
57
+ if (_.isNil(dataListener)) {
58
+ return _.noop
59
+ }
60
+ const guid = uuidv4()
61
+ this.dataListeners.set(guid, { listener: dataListener, channelId })
62
+
63
+ return () => this.dataListeners.delete(guid)
64
+ }
65
+
66
+ addErrorListener(errorListener: ErrorListener): Remover {
67
+ if (_.isNil(errorListener)) {
68
+ return _.noop
69
+ }
70
+ const guid = uuidv4()
71
+ this.errorListeners.set(guid, errorListener)
72
+
73
+ return () => this.errorListeners.delete(guid)
74
+ }
75
+
76
+ addAuthStateChangeListener(authStateListener: AuthStateListener): Remover {
77
+ if (_.isNil(authStateListener)) {
78
+ return _.noop
79
+ }
80
+ const guid = uuidv4()
81
+ this.authStateListeners.set(guid, authStateListener)
82
+
83
+ return () => this.authStateListeners.delete(guid)
84
+ }
85
+
86
+ connect(url: string, token: string) {
87
+ if (this.isConnected) {
88
+ throw new Error('MarketDataStreamer is attempting to connect when an existing websocket is already connected')
89
+ }
90
+
91
+ this.token = token
92
+ this.webSocket = new WebSocket(url)
93
+ this.webSocket.onopen = this.onOpen.bind(this)
94
+ this.webSocket.onerror = this.onError.bind(this)
95
+ this.webSocket.onmessage = this.handleMessageReceived.bind(this)
96
+ this.webSocket.onclose = this.onClose.bind(this)
97
+ }
98
+
99
+ disconnect() {
100
+ if (_.isNil(this.webSocket)) {
101
+ return
102
+ }
103
+
104
+ this.clearKeepalive()
105
+
106
+ this.webSocket.onopen = null
107
+ this.webSocket.onerror = null
108
+ this.webSocket.onmessage = null
109
+ this.webSocket.onclose = null
110
+
111
+ this.webSocket.close()
112
+ this.webSocket = null
113
+
114
+ this.openChannels.clear()
115
+ this.subscriptionsQueue.clear()
116
+ this.authState = ''
117
+ }
118
+
119
+ addSubscription(symbol: string, options = { subscriptionTypes: AllSubscriptionTypes, channelId: DefaultChannelId }): Remover {
120
+ let { subscriptionTypes } = options
121
+ // Don't allow candle subscriptions in this method. Use addCandleSubscription instead
122
+ subscriptionTypes = _.without(subscriptionTypes, MarketDataSubscriptionType.Candle)
123
+
124
+ const isOpen = this.isChannelOpened(options.channelId)
125
+ if (isOpen) {
126
+ this.sendSubscriptionMessage(symbol, subscriptionTypes, options.channelId, 'add')
127
+ } else {
128
+ this.queueSubscription(symbol, { subscriptionTypes, channelId: options.channelId })
129
+ }
130
+
131
+ return () => {
132
+ this.removeSubscription(symbol, options)
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Adds a candle subscription (historical data)
138
+ * @param streamerSymbol Get this from an instrument's streamer-symbol json response field
139
+ * @param fromTime Epoch timestamp from where you want to start
140
+ * @param options Period and Type are the grouping you want to apply to the candle data
141
+ * For example, a period/type of 5/m means you want each candle to represent 5 minutes of data
142
+ * From there, setting fromTime to 24 hours ago would give you 24 hours of data grouped in 5 minute intervals
143
+ * @returns
144
+ */
145
+ addCandleSubscription(streamerSymbol: string, fromTime: number, options: CandleSubscriptionOptions) {
146
+ const subscriptionTypes = [MarketDataSubscriptionType.Candle]
147
+ const channelId = options.channelId ?? DefaultChannelId
148
+
149
+ // Example: AAPL{=5m} where each candle represents 5 minutes of data
150
+ const candleSymbol = `${streamerSymbol}{=${options.period}${options.type}}`
151
+ const isOpen = this.isChannelOpened(channelId)
152
+ const subscriptionArgs = { fromTime }
153
+ if (isOpen) {
154
+ this.sendSubscriptionMessage(candleSymbol, subscriptionTypes, channelId, 'add', subscriptionArgs)
155
+ } else {
156
+ this.queueSubscription(candleSymbol, { subscriptionTypes, channelId, subscriptionArgs })
157
+ }
158
+
159
+ return () => {
160
+ this.removeSubscription(candleSymbol, { subscriptionTypes, channelId })
161
+ }
162
+ }
163
+
164
+ removeSubscription(symbol: string, options = { subscriptionTypes: AllSubscriptionTypes, channelId: DefaultChannelId }) {
165
+ const { subscriptionTypes, channelId } = options
166
+ const isOpen = this.isChannelOpened(channelId)
167
+ if (isOpen) {
168
+ this.sendSubscriptionMessage(symbol, subscriptionTypes, channelId, 'remove')
169
+ } else {
170
+ this.dequeueSubscription(symbol, options)
171
+ }
172
+ }
173
+
174
+ removeAllSubscriptions(channelId = DefaultChannelId) {
175
+ const isOpen = this.isChannelOpened(channelId)
176
+ if (isOpen) {
177
+ this.sendMessage({ "type": "FEED_SUBSCRIPTION", "channel": channelId, reset: true })
178
+ } else {
179
+ this.subscriptionsQueue.set(channelId, [])
180
+ }
181
+ }
182
+
183
+ openFeedChannel(channelId: number) {
184
+ if (!this.isReadyToOpenChannels) {
185
+ throw new Error(`Unable to open channel ${channelId} due to DxLink authorization state: ${this.authState}`)
186
+ }
187
+
188
+ if (this.isChannelOpened(channelId)) {
189
+ return
190
+ }
191
+
192
+ this.sendMessage({
193
+ "type": "CHANNEL_REQUEST",
194
+ "channel": channelId,
195
+ "service": "FEED",
196
+ "parameters": {
197
+ "contract": "AUTO"
198
+ }
199
+ })
200
+ }
201
+
202
+ isChannelOpened(channelId: number) {
203
+ return this.isConnected && this.openChannels.has(channelId)
204
+ }
205
+
206
+ get isReadyToOpenChannels() {
207
+ return this.isConnected && this.isDxLinkAuthorized
208
+ }
209
+
210
+ get isConnected() {
211
+ return !_.isNil(this.webSocket)
212
+ }
213
+
214
+ private scheduleKeepalive() {
215
+ this.keepaliveIntervalId = setInterval(this.sendKeepalive, KeepaliveInterval)
216
+ }
217
+
218
+ private sendKeepalive() {
219
+ if (_.isNil(this.keepaliveIntervalId)) {
220
+ return
221
+ }
222
+
223
+ this.sendMessage({
224
+ "type": "KEEPALIVE",
225
+ "channel": 0
226
+ })
227
+ }
228
+
229
+ private queueSubscription(symbol: string, options: SubscriptionOptions) {
230
+ const { subscriptionTypes, channelId, subscriptionArgs } = options
231
+ let queue = this.subscriptionsQueue.get(options.channelId)
232
+ if (_.isNil(queue)) {
233
+ queue = []
234
+ this.subscriptionsQueue.set(channelId, queue)
235
+ }
236
+
237
+ queue.push({ symbol, subscriptionTypes, subscriptionArgs })
238
+ }
239
+
240
+ private dequeueSubscription(symbol: string, options: SubscriptionOptions) {
241
+ const queue = this.subscriptionsQueue.get(options.channelId)
242
+ if (_.isNil(queue) || _.isEmpty(queue)) {
243
+ return
244
+ }
245
+
246
+ _.remove(queue, (queueItem: any) => queueItem.symbol === symbol)
247
+ }
248
+
249
+ private sendQueuedSubscriptions(channelId: number) {
250
+ const queuedSubscriptions = this.subscriptionsQueue.get(channelId)
251
+ if (_.isNil(queuedSubscriptions)) {
252
+ return
253
+ }
254
+
255
+ // Clear out queue immediately
256
+ this.subscriptionsQueue.set(channelId, [])
257
+ queuedSubscriptions.forEach(subscription => {
258
+ this.sendSubscriptionMessage(subscription.symbol, subscription.subscriptionTypes, channelId, 'add', subscription.subscriptionArgs)
259
+ })
260
+ }
261
+
262
+ /**
263
+ *
264
+ * @param {*} symbol
265
+ * @param {*} subscriptionTypes
266
+ * @param {*} channelId
267
+ * @param {*} direction add or remove
268
+ */
269
+ private sendSubscriptionMessage(symbol: string, subscriptionTypes: MarketDataSubscriptionType[], channelId: number, direction: string, subscriptionArgs: any = {}) {
270
+ const subscriptions = subscriptionTypes.map(type => (Object.assign({}, { "symbol": symbol, "type": type }, subscriptionArgs ?? {})))
271
+ this.sendMessage({
272
+ "type": "FEED_SUBSCRIPTION",
273
+ "channel": channelId,
274
+ [direction]: subscriptions
275
+ })
276
+ }
277
+
278
+ private onError(error: any) {
279
+ console.error('Error received: ', error)
280
+ this.notifyErrorListeners(error)
281
+ }
282
+
283
+ private onOpen() {
284
+ this.openChannels.clear()
285
+
286
+ this.sendMessage({
287
+ "type": "SETUP",
288
+ "channel": 0,
289
+ "keepaliveTimeout": KeepaliveInterval,
290
+ "acceptKeepaliveTimeout": KeepaliveInterval,
291
+ "version": "0.1-js/1.0.0"
292
+ })
293
+
294
+ this.scheduleKeepalive()
295
+ }
296
+
297
+ private onClose() {
298
+ this.webSocket = null
299
+ this.clearKeepalive()
300
+ }
301
+
302
+ private clearKeepalive() {
303
+ if (!_.isNil(this.keepaliveIntervalId)) {
304
+ clearInterval(this.keepaliveIntervalId)
305
+ }
306
+
307
+ this.keepaliveIntervalId = null
308
+ }
309
+
310
+ get isDxLinkAuthorized() {
311
+ return this.authState === 'AUTHORIZED'
312
+ }
313
+
314
+ private handleAuthStateMessage(data: any) {
315
+ this.authState = data.state
316
+ this.authStateListeners.forEach(listener => listener(this.isDxLinkAuthorized))
317
+
318
+ if (this.isDxLinkAuthorized) {
319
+ this.openFeedChannel(DefaultChannelId)
320
+ } else {
321
+ this.sendMessage({
322
+ "type": "AUTH",
323
+ "channel": 0,
324
+ "token": this.token
325
+ })
326
+ }
327
+ }
328
+
329
+ private handleChannelOpened(jsonData: any) {
330
+ this.openChannels.add(jsonData.channel)
331
+ this.sendQueuedSubscriptions(jsonData.channel)
332
+ }
333
+
334
+ private notifyListeners(jsonData: any) {
335
+ this.dataListeners.forEach(listenerData => {
336
+ if (listenerData.channelId === jsonData.channel || _.isNil(listenerData.channelId)) {
337
+ listenerData.listener(jsonData)
338
+ }
339
+ })
340
+ }
341
+
342
+ private notifyErrorListeners(error: any) {
343
+ this.errorListeners.forEach(listener => listener(error))
344
+ }
345
+
346
+ private handleMessageReceived(data: string) {
347
+ const messageData = _.get(data, 'data', data)
348
+ const jsonData = JSON.parse(messageData)
349
+ switch (jsonData.type) {
350
+ case 'AUTH_STATE':
351
+ this.handleAuthStateMessage(jsonData)
352
+ break
353
+ case 'CHANNEL_OPENED':
354
+ this.handleChannelOpened(jsonData)
355
+ break
356
+ case 'FEED_DATA':
357
+ this.notifyListeners(jsonData)
358
+ break
359
+ }
360
+ }
361
+
362
+ private sendMessage(json: object) {
363
+ if (_.isNil(this.webSocket)) {
364
+ return
365
+ }
366
+
367
+ this.webSocket.send(JSON.stringify(json))
368
+ }
369
+ }
@@ -26,10 +26,9 @@ export default class AccountsAndCustomersService {
26
26
  return extractResponseData(fullCustomerAccountResource)
27
27
  }
28
28
 
29
- //Quote-streamer-tokens: Operations about quote-streamer-tokens
30
- async getQuoteStreamerTokens(){
31
- //Returns the appropriate quote streamer endpoint, level and identification token for the current customer to receive market data.
32
- const quoteStreamerTokens = (await this.httpClient.getData('/quote-streamer-tokens', {}, {}))
33
- return extractResponseData(quoteStreamerTokens)
29
+ //Returns the appropriate quote streamer endpoint, level and identification token for the current customer to receive market data.
30
+ async getApiQuoteToken() {
31
+ const apiQuoteToken = (await this.httpClient.getData('/api-quote-tokens', {}, {}))
32
+ return extractResponseData(apiQuoteToken)
34
33
  }
35
34
  }
@@ -1,6 +1,6 @@
1
1
  import TastytradeHttpClient from "./services/tastytrade-http-client"
2
2
  import { AccountStreamer, STREAMER_STATE, Disposer, StreamerStateObserver } from './account-streamer'
3
- import QuoteStreamer from "./quote-streamer"
3
+ import MarketDataStreamer, { CandleSubscriptionOptions, CandleType, MarketDataSubscriptionType, MarketDataListener } from "./market-data-streamer"
4
4
 
5
5
  //Services:
6
6
  import SessionService from "./services/session-service"
@@ -61,5 +61,5 @@ export default class TastytradeClient {
61
61
  }
62
62
  }
63
63
 
64
- export { QuoteStreamer }
64
+ export { MarketDataStreamer, MarketDataSubscriptionType, MarketDataListener, CandleSubscriptionOptions, CandleType }
65
65
  export { AccountStreamer, STREAMER_STATE, Disposer, StreamerStateObserver }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastytrade/api",
3
- "version": "1.0.0",
3
+ "version": "2.1.0",
4
4
  "main": "dist/tastytrade-api.js",
5
5
  "typings": "dist/tastytrade-api.d.ts",
6
6
  "repository": "https://github.com/tastytrade/tastytrade-api-js",
@@ -11,19 +11,24 @@
11
11
  "test": "jest -i --restoreMocks",
12
12
  "unit-test": "jest --testPathPattern=tests/unit",
13
13
  "integration-test": "jest --testPathPattern=tests/integration",
14
- "lint": "eslint lib/** tests/**"
14
+ "lint": "eslint lib/** tests/**",
15
+ "prepublishOnly": "npm run test && npm run build",
16
+ "postpack": "git tag -a $npm_package_version -m $npm_package_version && git push origin $npm_package_version"
15
17
  },
16
18
  "dependencies": {
17
- "@dxfeed/api": "^1.1.0",
18
19
  "@types/lodash": "^4.14.182",
19
20
  "@types/qs": "^6.9.7",
20
21
  "axios": "^1.3.4",
22
+ "isomorphic-ws": "^5.0.0",
21
23
  "lodash": "^4.17.21",
22
- "qs": "^6.11.1"
24
+ "qs": "^6.11.1",
25
+ "uuid": "^9.0.0",
26
+ "ws": "^8.13.0"
23
27
  },
24
28
  "devDependencies": {
25
29
  "@types/jest": "^29.5.0",
26
30
  "@types/node": "17.0.27",
31
+ "@types/uuid": "^9.0.2",
27
32
  "@typescript-eslint/eslint-plugin": "^5.57.1",
28
33
  "@typescript-eslint/parser": "^5.57.1",
29
34
  "dotenv": "^16.0.3",
@@ -1,40 +0,0 @@
1
- import Feed, { EventType, IEvent } from '@dxfeed/api'
2
- import _ from 'lodash'
3
-
4
- export const SupportedEventTypes = [
5
- EventType.Quote,
6
- EventType.Trade,
7
- EventType.Summary,
8
- EventType.Greeks,
9
- EventType.Profile
10
- ]
11
-
12
- export default class QuoteStreamer {
13
- private feed: Feed | null = null
14
-
15
- constructor(private readonly token: string, private readonly url: string) {}
16
-
17
- connect() {
18
- this.feed = new Feed()
19
- this.feed.setAuthToken(this.token)
20
- this.feed.connect(this.url)
21
- }
22
-
23
- disconnect() {
24
- if (!_.isNil(this.feed)) {
25
- this.feed.disconnect()
26
- }
27
- }
28
-
29
- subscribe(dxfeedSymbol: string, eventHandler: (event: IEvent) => void): () => void {
30
- if (_.isNil(this.feed)) {
31
- return _.noop
32
- }
33
-
34
- return this.feed.subscribe(
35
- SupportedEventTypes,
36
- [dxfeedSymbol],
37
- eventHandler
38
- )
39
- }
40
- }