@fullstackcraftllc/floe 0.0.3 → 0.0.5

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,1100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TastyTradeClient = void 0;
4
+ const occ_1 = require("../../utils/occ");
5
+ /**
6
+ * Regex pattern to identify OCC option symbols
7
+ */
8
+ const OCC_OPTION_PATTERN = /^.{1,6}\d{6}[CP]\d{8}$/;
9
+ /**
10
+ * Event field configurations for different event types
11
+ */
12
+ const FEED_EVENT_FIELDS = {
13
+ Quote: ['eventType', 'eventSymbol', 'bidPrice', 'askPrice', 'bidSize', 'askSize'],
14
+ Trade: ['eventType', 'eventSymbol', 'price', 'dayVolume', 'size'],
15
+ TradeETH: ['eventType', 'eventSymbol', 'price', 'dayVolume', 'size'],
16
+ Greeks: ['eventType', 'eventSymbol', 'volatility', 'delta', 'gamma', 'theta', 'rho', 'vega'],
17
+ Profile: ['eventType', 'eventSymbol', 'description', 'shortSaleRestriction', 'tradingStatus', 'statusReason', 'haltStartTime', 'haltEndTime', 'highLimitPrice', 'lowLimitPrice', 'high52WeekPrice', 'low52WeekPrice'],
18
+ Summary: ['eventType', 'eventSymbol', 'openInterest', 'dayOpenPrice', 'dayHighPrice', 'dayLowPrice', 'prevDayClosePrice'],
19
+ };
20
+ /**
21
+ * TastyTradeClient handles real-time streaming connections to the TastyTrade API
22
+ * via DxLink WebSockets.
23
+ *
24
+ * @remarks
25
+ * This client manages WebSocket connections to TastyTrade's DxLink streaming API,
26
+ * normalizes incoming quote and trade data, and emits events for upstream
27
+ * consumption by the FloeClient.
28
+ *
29
+ * Authentication flow:
30
+ * 1. Login to TastyTrade API to get session token (optional, can pass directly)
31
+ * 2. Use session token to get API quote token from /api-quote-tokens
32
+ * 3. Connect to DxLink WebSocket using the quote token
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * const client = new TastyTradeClient({
37
+ * sessionToken: 'your-session-token'
38
+ * });
39
+ *
40
+ * client.on('tickerUpdate', (ticker) => {
41
+ * console.log(`${ticker.symbol}: ${ticker.spot}`);
42
+ * });
43
+ *
44
+ * await client.connect();
45
+ * client.subscribe(['SPY', '.SPXW231215C4500']); // Equity and option
46
+ * ```
47
+ */
48
+ class TastyTradeClient {
49
+ /**
50
+ * Creates a new TastyTradeClient instance.
51
+ *
52
+ * @param options - Client configuration options
53
+ * @param options.sessionToken - TastyTrade session token (required)
54
+ * @param options.sandbox - Whether to use sandbox environment (default: false)
55
+ * @param options.verbose - Whether to log verbose debug information (default: false)
56
+ */
57
+ constructor(options) {
58
+ /** DxLink API quote token */
59
+ this.quoteToken = null;
60
+ /** DxLink WebSocket URL */
61
+ this.dxLinkUrl = null;
62
+ /** WebSocket connection */
63
+ this.ws = null;
64
+ /** Connection state */
65
+ this.connected = false;
66
+ /** Authorization state */
67
+ this.authorized = false;
68
+ /** Feed channel ID */
69
+ this.feedChannelId = 1;
70
+ /** Feed channel opened */
71
+ this.feedChannelOpened = false;
72
+ /** Currently subscribed symbols */
73
+ this.subscribedSymbols = new Set();
74
+ /** Map from streamer symbol to OCC symbol */
75
+ this.streamerToOccMap = new Map();
76
+ /** Map from OCC symbol to streamer symbol */
77
+ this.occToStreamerMap = new Map();
78
+ /** Cached ticker data */
79
+ this.tickerCache = new Map();
80
+ /** Cached option data */
81
+ this.optionCache = new Map();
82
+ /** Base open interest from REST API */
83
+ this.baseOpenInterest = new Map();
84
+ /** Cumulative estimated OI change from intraday trades */
85
+ this.cumulativeOIChange = new Map();
86
+ /** History of intraday trades */
87
+ this.intradayTrades = new Map();
88
+ /** Event listeners */
89
+ this.eventListeners = new Map();
90
+ /** Reconnection attempt counter */
91
+ this.reconnectAttempts = 0;
92
+ /** Maximum reconnection attempts */
93
+ this.maxReconnectAttempts = 5;
94
+ /** Reconnection delay in ms */
95
+ this.baseReconnectDelay = 1000;
96
+ /** Keepalive interval handle */
97
+ this.keepaliveInterval = null;
98
+ /** Keepalive timeout in seconds */
99
+ this.keepaliveTimeoutSeconds = 60;
100
+ this.sessionToken = options.sessionToken;
101
+ this.sandbox = options.sandbox ?? false;
102
+ this.verbose = options.verbose ?? false;
103
+ this.apiBaseUrl = this.sandbox
104
+ ? 'https://api.cert.tastyworks.com'
105
+ : 'https://api.tastyworks.com';
106
+ // Initialize event listener maps
107
+ this.eventListeners.set('tickerUpdate', new Set());
108
+ this.eventListeners.set('optionUpdate', new Set());
109
+ this.eventListeners.set('optionTrade', new Set());
110
+ this.eventListeners.set('connected', new Set());
111
+ this.eventListeners.set('disconnected', new Set());
112
+ this.eventListeners.set('error', new Set());
113
+ }
114
+ // ==================== Static Factory Methods ====================
115
+ /**
116
+ * Creates a TastyTradeClient by logging in with username/password.
117
+ *
118
+ * @param username - TastyTrade username
119
+ * @param password - TastyTrade password
120
+ * @param options - Additional options
121
+ * @returns Promise resolving to configured TastyTradeClient
122
+ *
123
+ * @example
124
+ * ```typescript
125
+ * const client = await TastyTradeClient.fromCredentials(
126
+ * 'your-username',
127
+ * 'your-password'
128
+ * );
129
+ * await client.connect();
130
+ * ```
131
+ */
132
+ static async fromCredentials(username, password, options) {
133
+ const sandbox = options?.sandbox ?? false;
134
+ const baseUrl = sandbox
135
+ ? 'https://api.cert.tastyworks.com'
136
+ : 'https://api.tastyworks.com';
137
+ const response = await fetch(`${baseUrl}/sessions`, {
138
+ method: 'POST',
139
+ headers: {
140
+ 'Content-Type': 'application/json',
141
+ 'Accept': 'application/json',
142
+ },
143
+ body: JSON.stringify({
144
+ login: username,
145
+ password: password,
146
+ 'remember-me': options?.rememberMe ?? false,
147
+ }),
148
+ });
149
+ if (!response.ok) {
150
+ const errorText = await response.text();
151
+ throw new Error(`TastyTrade login failed: ${response.statusText} - ${errorText}`);
152
+ }
153
+ const data = await response.json();
154
+ return new TastyTradeClient({
155
+ sessionToken: data.data['session-token'],
156
+ sandbox,
157
+ });
158
+ }
159
+ // ==================== Public API ====================
160
+ /**
161
+ * Establishes a streaming connection to TastyTrade via DxLink.
162
+ *
163
+ * @returns Promise that resolves when connected and authorized
164
+ * @throws {Error} If token retrieval or WebSocket connection fails
165
+ */
166
+ async connect() {
167
+ // Get API quote token
168
+ const quoteTokenData = await this.getQuoteToken();
169
+ if (!quoteTokenData) {
170
+ throw new Error('Failed to get TastyTrade quote token');
171
+ }
172
+ this.quoteToken = quoteTokenData.token;
173
+ this.dxLinkUrl = quoteTokenData.url;
174
+ // Connect to DxLink WebSocket
175
+ await this.connectWebSocket();
176
+ }
177
+ /**
178
+ * Disconnects from the TastyTrade streaming API.
179
+ */
180
+ disconnect() {
181
+ if (this.keepaliveInterval) {
182
+ clearInterval(this.keepaliveInterval);
183
+ this.keepaliveInterval = null;
184
+ }
185
+ if (this.ws) {
186
+ this.ws.close(1000, 'Client disconnect');
187
+ this.ws = null;
188
+ }
189
+ this.connected = false;
190
+ this.authorized = false;
191
+ this.feedChannelOpened = false;
192
+ this.subscribedSymbols.clear();
193
+ this.streamerToOccMap.clear();
194
+ this.occToStreamerMap.clear();
195
+ }
196
+ /**
197
+ * Subscribes to real-time updates for the specified symbols.
198
+ *
199
+ * @param symbols - Array of ticker symbols and/or OCC option symbols
200
+ *
201
+ * @remarks
202
+ * For options, you can pass either:
203
+ * - OCC format symbols (e.g., 'SPY240119C00500000')
204
+ * - TastyTrade streamer symbols (e.g., '.SPXW240119C4500')
205
+ *
206
+ * The client will convert OCC symbols to streamer symbols automatically.
207
+ */
208
+ subscribe(symbols) {
209
+ // Add to tracked symbols
210
+ symbols.forEach(s => this.subscribedSymbols.add(s));
211
+ if (!this.connected || !this.feedChannelOpened) {
212
+ // Will subscribe when channel opens
213
+ return;
214
+ }
215
+ this.sendFeedSubscription(symbols, 'add');
216
+ }
217
+ /**
218
+ * Unsubscribes from real-time updates for the specified symbols.
219
+ *
220
+ * @param symbols - Array of symbols to unsubscribe from
221
+ */
222
+ unsubscribe(symbols) {
223
+ symbols.forEach(s => this.subscribedSymbols.delete(s));
224
+ if (!this.connected || !this.feedChannelOpened) {
225
+ return;
226
+ }
227
+ this.sendFeedSubscription(symbols, 'remove');
228
+ }
229
+ /**
230
+ * Returns whether the client is currently connected.
231
+ */
232
+ isConnected() {
233
+ return this.connected && this.authorized;
234
+ }
235
+ /**
236
+ * Fetches options chain data from TastyTrade REST API.
237
+ *
238
+ * @param symbol - Underlying symbol (e.g., 'SPY')
239
+ * @returns Array of option chain items
240
+ */
241
+ async fetchOptionsChain(symbol) {
242
+ try {
243
+ const response = await fetch(`${this.apiBaseUrl}/option-chains/${symbol}/nested`, {
244
+ method: 'GET',
245
+ headers: {
246
+ 'Authorization': `Bearer ${this.sessionToken}`,
247
+ 'Accept': 'application/json',
248
+ 'User-Agent': 'floe/1.0',
249
+ },
250
+ });
251
+ if (!response.ok) {
252
+ this.emit('error', new Error(`Failed to fetch options chain: ${response.statusText}`));
253
+ return [];
254
+ }
255
+ const data = await response.json();
256
+ return data.data?.items ?? [];
257
+ }
258
+ catch (error) {
259
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
260
+ return [];
261
+ }
262
+ }
263
+ /**
264
+ * Fetches open interest and other static data for subscribed options.
265
+ *
266
+ * @param occSymbols - Array of OCC option symbols to fetch data for
267
+ */
268
+ async fetchOpenInterest(occSymbols) {
269
+ // Group by underlying
270
+ const groups = new Map();
271
+ for (const occSymbol of occSymbols) {
272
+ try {
273
+ const parsed = (0, occ_1.parseOCCSymbol)(occSymbol);
274
+ if (!groups.has(parsed.symbol)) {
275
+ groups.set(parsed.symbol, new Set());
276
+ }
277
+ groups.get(parsed.symbol).add(occSymbol);
278
+ }
279
+ catch {
280
+ continue;
281
+ }
282
+ }
283
+ // Fetch chains for each underlying
284
+ const fetchPromises = Array.from(groups.entries()).map(async ([underlying, targetSymbols]) => {
285
+ const chain = await this.fetchOptionsChain(underlying);
286
+ for (const item of chain) {
287
+ // Map streamer symbol to OCC
288
+ const occSymbol = this.streamerSymbolToOCC(item['streamer-symbol'], item);
289
+ if (targetSymbols.has(occSymbol)) {
290
+ // Store mapping
291
+ this.streamerToOccMap.set(item['streamer-symbol'], occSymbol);
292
+ this.occToStreamerMap.set(occSymbol, item['streamer-symbol']);
293
+ // Store base OI
294
+ if (item['open-interest'] !== undefined) {
295
+ this.baseOpenInterest.set(occSymbol, item['open-interest']);
296
+ if (this.verbose) {
297
+ console.log(`[TastyTrade:OI] Base OI set for ${occSymbol}: ${item['open-interest']}`);
298
+ }
299
+ }
300
+ if (!this.cumulativeOIChange.has(occSymbol)) {
301
+ this.cumulativeOIChange.set(occSymbol, 0);
302
+ }
303
+ // Create or update option in cache
304
+ const existing = this.optionCache.get(occSymbol);
305
+ const option = {
306
+ occSymbol,
307
+ underlying: item.underlying || item['root-symbol'],
308
+ strike: item.strike,
309
+ expiration: item['expiration-date'],
310
+ expirationTimestamp: new Date(item['expiration-date']).getTime(),
311
+ optionType: item['option-type'] === 'C' ? 'call' : 'put',
312
+ bid: item.bid ?? existing?.bid ?? 0,
313
+ bidSize: item['bid-size'] ?? existing?.bidSize ?? 0,
314
+ ask: item.ask ?? existing?.ask ?? 0,
315
+ askSize: item['ask-size'] ?? existing?.askSize ?? 0,
316
+ mark: ((item.bid ?? 0) + (item.ask ?? 0)) / 2,
317
+ last: item.last ?? existing?.last ?? 0,
318
+ volume: item.volume ?? existing?.volume ?? 0,
319
+ openInterest: item['open-interest'] ?? existing?.openInterest ?? 0,
320
+ liveOpenInterest: this.calculateLiveOpenInterest(occSymbol),
321
+ impliedVolatility: item['implied-volatility'] ?? existing?.impliedVolatility ?? 0,
322
+ timestamp: Date.now(),
323
+ };
324
+ this.optionCache.set(occSymbol, option);
325
+ this.emit('optionUpdate', option);
326
+ }
327
+ }
328
+ });
329
+ await Promise.all(fetchPromises);
330
+ }
331
+ /**
332
+ * Returns cached option data for a symbol.
333
+ */
334
+ getOption(occSymbol) {
335
+ return this.optionCache.get(occSymbol);
336
+ }
337
+ /**
338
+ * Returns all cached options.
339
+ */
340
+ getAllOptions() {
341
+ return new Map(this.optionCache);
342
+ }
343
+ /**
344
+ * Registers an event listener.
345
+ */
346
+ on(event, listener) {
347
+ const listeners = this.eventListeners.get(event);
348
+ if (listeners) {
349
+ listeners.add(listener);
350
+ }
351
+ return this;
352
+ }
353
+ /**
354
+ * Removes an event listener.
355
+ */
356
+ off(event, listener) {
357
+ const listeners = this.eventListeners.get(event);
358
+ if (listeners) {
359
+ listeners.delete(listener);
360
+ }
361
+ return this;
362
+ }
363
+ /**
364
+ * Returns intraday trades for an option.
365
+ */
366
+ getIntradayTrades(occSymbol) {
367
+ return this.intradayTrades.get(occSymbol) ?? [];
368
+ }
369
+ /**
370
+ * Returns flow summary for an option.
371
+ */
372
+ getFlowSummary(occSymbol) {
373
+ const trades = this.intradayTrades.get(occSymbol) ?? [];
374
+ let buyVolume = 0;
375
+ let sellVolume = 0;
376
+ let unknownVolume = 0;
377
+ for (const trade of trades) {
378
+ switch (trade.aggressorSide) {
379
+ case 'buy':
380
+ buyVolume += trade.size;
381
+ break;
382
+ case 'sell':
383
+ sellVolume += trade.size;
384
+ break;
385
+ case 'unknown':
386
+ unknownVolume += trade.size;
387
+ break;
388
+ }
389
+ }
390
+ return {
391
+ buyVolume,
392
+ sellVolume,
393
+ unknownVolume,
394
+ netOIChange: this.cumulativeOIChange.get(occSymbol) ?? 0,
395
+ tradeCount: trades.length,
396
+ };
397
+ }
398
+ /**
399
+ * Resets intraday tracking data.
400
+ */
401
+ resetIntradayData(occSymbols) {
402
+ const symbolsToReset = occSymbols ?? Array.from(this.intradayTrades.keys());
403
+ for (const symbol of symbolsToReset) {
404
+ this.intradayTrades.delete(symbol);
405
+ this.cumulativeOIChange.set(symbol, 0);
406
+ }
407
+ }
408
+ // ==================== Private Methods ====================
409
+ /**
410
+ * Gets API quote token from TastyTrade.
411
+ */
412
+ async getQuoteToken() {
413
+ try {
414
+ const response = await fetch(`${this.apiBaseUrl}/api-quote-tokens`, {
415
+ method: 'GET',
416
+ headers: {
417
+ 'Authorization': `Bearer ${this.sessionToken}`,
418
+ 'Accept': 'application/json',
419
+ 'User-Agent': 'floe/1.0',
420
+ },
421
+ });
422
+ if (!response.ok) {
423
+ const errorText = await response.text();
424
+ this.emit('error', new Error(`Failed to get quote token: ${response.statusText} - ${errorText}`));
425
+ return null;
426
+ }
427
+ const data = await response.json();
428
+ return {
429
+ token: data.data.token,
430
+ url: data.data['dxlink-url'],
431
+ };
432
+ }
433
+ catch (error) {
434
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
435
+ return null;
436
+ }
437
+ }
438
+ /**
439
+ * Connects to DxLink WebSocket.
440
+ */
441
+ connectWebSocket() {
442
+ return new Promise((resolve, reject) => {
443
+ if (!this.dxLinkUrl) {
444
+ reject(new Error('DxLink URL not available'));
445
+ return;
446
+ }
447
+ this.ws = new WebSocket(this.dxLinkUrl);
448
+ this.ws.onopen = () => {
449
+ this.connected = true;
450
+ this.reconnectAttempts = 0;
451
+ // Send SETUP message
452
+ this.sendSetup();
453
+ };
454
+ this.ws.onmessage = (event) => {
455
+ this.handleMessage(event.data, resolve);
456
+ };
457
+ this.ws.onclose = (event) => {
458
+ this.connected = false;
459
+ this.authorized = false;
460
+ this.feedChannelOpened = false;
461
+ this.emit('disconnected', { reason: event.reason });
462
+ if (event.code !== 1000) {
463
+ this.attemptReconnect();
464
+ }
465
+ };
466
+ this.ws.onerror = (error) => {
467
+ this.emit('error', new Error('DxLink WebSocket error'));
468
+ reject(error);
469
+ };
470
+ });
471
+ }
472
+ /**
473
+ * Sends SETUP message to DxLink.
474
+ */
475
+ sendSetup() {
476
+ const setupMessage = {
477
+ type: 'SETUP',
478
+ channel: 0,
479
+ version: '0.1-DXF-JS/1.0.0',
480
+ keepaliveTimeout: this.keepaliveTimeoutSeconds,
481
+ acceptKeepaliveTimeout: this.keepaliveTimeoutSeconds,
482
+ };
483
+ this.sendMessage(setupMessage);
484
+ }
485
+ /**
486
+ * Sends AUTH message to DxLink.
487
+ */
488
+ sendAuth() {
489
+ if (!this.quoteToken) {
490
+ this.emit('error', new Error('No quote token available for auth'));
491
+ return;
492
+ }
493
+ const authMessage = {
494
+ type: 'AUTH',
495
+ channel: 0,
496
+ token: this.quoteToken,
497
+ };
498
+ this.sendMessage(authMessage);
499
+ }
500
+ /**
501
+ * Opens a FEED channel.
502
+ */
503
+ openFeedChannel() {
504
+ const channelRequest = {
505
+ type: 'CHANNEL_REQUEST',
506
+ channel: this.feedChannelId,
507
+ service: 'FEED',
508
+ parameters: {
509
+ contract: 'AUTO',
510
+ },
511
+ };
512
+ this.sendMessage(channelRequest);
513
+ }
514
+ /**
515
+ * Configures the feed channel with desired event fields.
516
+ */
517
+ setupFeed() {
518
+ const feedSetup = {
519
+ type: 'FEED_SETUP',
520
+ channel: this.feedChannelId,
521
+ acceptAggregationPeriod: 0.1,
522
+ acceptDataFormat: 'COMPACT',
523
+ acceptEventFields: FEED_EVENT_FIELDS,
524
+ };
525
+ this.sendMessage(feedSetup);
526
+ // Subscribe to any queued symbols
527
+ if (this.subscribedSymbols.size > 0) {
528
+ this.sendFeedSubscription(Array.from(this.subscribedSymbols), 'add');
529
+ }
530
+ }
531
+ /**
532
+ * Sends feed subscription message.
533
+ */
534
+ sendFeedSubscription(symbols, action) {
535
+ // Build subscription entries for each symbol with relevant event types
536
+ const entries = [];
537
+ for (const symbol of symbols) {
538
+ const streamerSymbol = this.getStreamerSymbol(symbol);
539
+ const isOption = this.isOptionSymbol(symbol) || streamerSymbol.startsWith('.');
540
+ if (isOption) {
541
+ // Subscribe to option-relevant events
542
+ entries.push({ type: 'Quote', symbol: streamerSymbol });
543
+ entries.push({ type: 'Trade', symbol: streamerSymbol });
544
+ entries.push({ type: 'Greeks', symbol: streamerSymbol });
545
+ entries.push({ type: 'Summary', symbol: streamerSymbol });
546
+ }
547
+ else {
548
+ // Subscribe to equity events
549
+ entries.push({ type: 'Quote', symbol: streamerSymbol });
550
+ entries.push({ type: 'Trade', symbol: streamerSymbol });
551
+ entries.push({ type: 'TradeETH', symbol: streamerSymbol });
552
+ entries.push({ type: 'Summary', symbol: streamerSymbol });
553
+ entries.push({ type: 'Profile', symbol: streamerSymbol });
554
+ }
555
+ }
556
+ const subscriptionMessage = {
557
+ type: 'FEED_SUBSCRIPTION',
558
+ channel: this.feedChannelId,
559
+ [action]: entries,
560
+ };
561
+ if (action === 'add') {
562
+ subscriptionMessage.reset = false;
563
+ }
564
+ this.sendMessage(subscriptionMessage);
565
+ }
566
+ /**
567
+ * Gets streamer symbol from OCC or ticker symbol.
568
+ */
569
+ getStreamerSymbol(symbol) {
570
+ // Check if we already have a mapping
571
+ const cached = this.occToStreamerMap.get(symbol);
572
+ if (cached) {
573
+ return cached;
574
+ }
575
+ // If it's already a streamer symbol (starts with .), return as-is
576
+ if (symbol.startsWith('.')) {
577
+ return symbol;
578
+ }
579
+ // If it's an OCC option symbol, try to convert
580
+ if (this.isOptionSymbol(symbol)) {
581
+ try {
582
+ const parsed = (0, occ_1.parseOCCSymbol)(symbol);
583
+ // TastyTrade streamer format: .UNDERLYING + YYMMDD + C/P + STRIKE
584
+ // e.g., .SPXW231215C4500
585
+ const expDate = parsed.expiration;
586
+ const yy = expDate.getFullYear().toString().slice(-2);
587
+ const mm = (expDate.getMonth() + 1).toString().padStart(2, '0');
588
+ const dd = expDate.getDate().toString().padStart(2, '0');
589
+ const optType = parsed.optionType === 'call' ? 'C' : 'P';
590
+ const strike = parsed.strike;
591
+ // Format strike - remove trailing zeros and decimal if whole number
592
+ const strikeStr = strike % 1 === 0 ? strike.toString() : strike.toFixed(2);
593
+ return `.${parsed.symbol}${yy}${mm}${dd}${optType}${strikeStr}`;
594
+ }
595
+ catch {
596
+ // Fall through to return as-is
597
+ }
598
+ }
599
+ // Return as-is for equities or unrecognized symbols
600
+ return symbol;
601
+ }
602
+ /**
603
+ * Converts streamer symbol back to OCC format.
604
+ */
605
+ streamerSymbolToOCC(streamerSymbol, item) {
606
+ // Check cache first
607
+ const cached = this.streamerToOccMap.get(streamerSymbol);
608
+ if (cached) {
609
+ return cached;
610
+ }
611
+ // If we have chain item data, build OCC from it
612
+ if (item) {
613
+ // Build OCC symbol from item data
614
+ const underlying = item['root-symbol'] || item.underlying;
615
+ const expDate = new Date(item['expiration-date']);
616
+ const yy = expDate.getFullYear().toString().slice(-2);
617
+ const mm = (expDate.getMonth() + 1).toString().padStart(2, '0');
618
+ const dd = expDate.getDate().toString().padStart(2, '0');
619
+ const optType = item['option-type'];
620
+ const strike = Math.round(item.strike * 1000).toString().padStart(8, '0');
621
+ return `${underlying}${yy}${mm}${dd}${optType}${strike}`;
622
+ }
623
+ // Parse streamer symbol format: .SYMBOL + YYMMDD + C/P + STRIKE
624
+ if (streamerSymbol.startsWith('.')) {
625
+ const match = streamerSymbol.match(/^\.([A-Z]+)(\d{6})([CP])(.+)$/);
626
+ if (match) {
627
+ const [, underlying, dateStr, optType, strikeStr] = match;
628
+ const strike = parseFloat(strikeStr);
629
+ const strikeFormatted = Math.round(strike * 1000).toString().padStart(8, '0');
630
+ return `${underlying}${dateStr}${optType}${strikeFormatted}`;
631
+ }
632
+ }
633
+ // Return as-is if not an option
634
+ return streamerSymbol;
635
+ }
636
+ /**
637
+ * Starts keepalive interval.
638
+ */
639
+ startKeepalive() {
640
+ if (this.keepaliveInterval) {
641
+ clearInterval(this.keepaliveInterval);
642
+ }
643
+ // Send keepalive every 30 seconds (half of timeout)
644
+ this.keepaliveInterval = setInterval(() => {
645
+ if (this.connected && this.ws) {
646
+ const keepalive = {
647
+ type: 'KEEPALIVE',
648
+ channel: 0,
649
+ };
650
+ this.sendMessage(keepalive);
651
+ }
652
+ }, 30000);
653
+ }
654
+ /**
655
+ * Handles incoming WebSocket messages.
656
+ */
657
+ handleMessage(data, connectResolve) {
658
+ try {
659
+ const message = JSON.parse(data);
660
+ switch (message.type) {
661
+ case 'SETUP':
662
+ // Server acknowledged setup, send auth
663
+ this.sendAuth();
664
+ break;
665
+ case 'AUTH_STATE':
666
+ this.handleAuthState(message, connectResolve);
667
+ break;
668
+ case 'CHANNEL_OPENED':
669
+ this.handleChannelOpened(message);
670
+ break;
671
+ case 'FEED_CONFIG':
672
+ // Feed is configured, ready for subscriptions
673
+ this.feedChannelOpened = true;
674
+ // Subscribe to queued symbols
675
+ if (this.subscribedSymbols.size > 0) {
676
+ this.sendFeedSubscription(Array.from(this.subscribedSymbols), 'add');
677
+ }
678
+ break;
679
+ case 'FEED_DATA':
680
+ this.handleFeedData(message);
681
+ break;
682
+ case 'ERROR':
683
+ this.handleError(message);
684
+ break;
685
+ case 'KEEPALIVE':
686
+ // Server keepalive, no action needed
687
+ break;
688
+ }
689
+ }
690
+ catch (error) {
691
+ // Ignore parse errors
692
+ }
693
+ }
694
+ /**
695
+ * Handles AUTH_STATE message.
696
+ */
697
+ handleAuthState(message, connectResolve) {
698
+ if (message.state === 'AUTHORIZED') {
699
+ this.authorized = true;
700
+ this.startKeepalive();
701
+ this.openFeedChannel();
702
+ if (this.verbose) {
703
+ console.log('[TastyTrade:DxLink] Authorized and connected');
704
+ }
705
+ this.emit('connected', undefined);
706
+ connectResolve?.();
707
+ }
708
+ else {
709
+ this.emit('error', new Error('DxLink authorization failed'));
710
+ }
711
+ }
712
+ /**
713
+ * Handles CHANNEL_OPENED message.
714
+ */
715
+ handleChannelOpened(message) {
716
+ if (message.channel === this.feedChannelId && message.service === 'FEED') {
717
+ // Configure the feed
718
+ this.setupFeed();
719
+ }
720
+ }
721
+ /**
722
+ * Handles FEED_DATA message.
723
+ */
724
+ handleFeedData(message) {
725
+ const { data } = message;
726
+ // COMPACT format: [eventType, [values...], eventType, [values...], ...]
727
+ let i = 0;
728
+ while (i < data.length) {
729
+ const eventType = data[i];
730
+ i++;
731
+ if (i >= data.length)
732
+ break;
733
+ const values = data[i];
734
+ i++;
735
+ this.processEventData(eventType, values);
736
+ }
737
+ }
738
+ /**
739
+ * Processes a single event from FEED_DATA.
740
+ */
741
+ processEventData(eventType, values) {
742
+ // Values are in order of acceptEventFields
743
+ const fields = FEED_EVENT_FIELDS[eventType];
744
+ if (!fields)
745
+ return;
746
+ // Parse into object
747
+ const event = {};
748
+ for (let i = 0; i < fields.length && i < values.length; i++) {
749
+ event[fields[i]] = values[i];
750
+ }
751
+ const streamerSymbol = event.eventSymbol;
752
+ if (!streamerSymbol)
753
+ return;
754
+ // Determine if this is an option
755
+ const isOption = streamerSymbol.startsWith('.');
756
+ const occSymbol = isOption ? this.streamerSymbolToOCC(streamerSymbol) : streamerSymbol;
757
+ const timestamp = Date.now();
758
+ switch (eventType) {
759
+ case 'Quote':
760
+ this.handleQuoteEvent(occSymbol, event, timestamp, isOption);
761
+ break;
762
+ case 'Trade':
763
+ case 'TradeETH':
764
+ this.handleTradeEvent(occSymbol, event, timestamp, isOption);
765
+ break;
766
+ case 'Greeks':
767
+ this.handleGreeksEvent(occSymbol, event, timestamp);
768
+ break;
769
+ case 'Summary':
770
+ this.handleSummaryEvent(occSymbol, event, timestamp, isOption);
771
+ break;
772
+ }
773
+ }
774
+ /**
775
+ * Handles Quote events.
776
+ */
777
+ handleQuoteEvent(symbol, event, timestamp, isOption) {
778
+ const bidPrice = this.toNumber(event.bidPrice);
779
+ const askPrice = this.toNumber(event.askPrice);
780
+ const bidSize = this.toNumber(event.bidSize);
781
+ const askSize = this.toNumber(event.askSize);
782
+ if (isOption) {
783
+ this.updateOptionFromQuote(symbol, bidPrice, askPrice, bidSize, askSize, timestamp);
784
+ }
785
+ else {
786
+ this.updateTickerFromQuote(symbol, bidPrice, askPrice, bidSize, askSize, timestamp);
787
+ }
788
+ }
789
+ /**
790
+ * Handles Trade events.
791
+ */
792
+ handleTradeEvent(symbol, event, timestamp, isOption) {
793
+ const price = this.toNumber(event.price);
794
+ const size = this.toNumber(event.size);
795
+ const dayVolume = this.toNumber(event.dayVolume);
796
+ if (isOption) {
797
+ this.updateOptionFromTrade(symbol, price, size, dayVolume, timestamp);
798
+ }
799
+ else {
800
+ this.updateTickerFromTrade(symbol, price, size, dayVolume, timestamp);
801
+ }
802
+ }
803
+ /**
804
+ * Handles Greeks events.
805
+ */
806
+ handleGreeksEvent(occSymbol, event, timestamp) {
807
+ const existing = this.optionCache.get(occSymbol);
808
+ if (!existing)
809
+ return;
810
+ const volatility = this.toNumber(event.volatility);
811
+ if (volatility > 0) {
812
+ existing.impliedVolatility = volatility;
813
+ existing.timestamp = timestamp;
814
+ this.optionCache.set(occSymbol, existing);
815
+ this.emit('optionUpdate', existing);
816
+ }
817
+ }
818
+ /**
819
+ * Handles Summary events (includes open interest).
820
+ */
821
+ handleSummaryEvent(symbol, event, timestamp, isOption) {
822
+ if (!isOption)
823
+ return;
824
+ const openInterest = this.toNumber(event.openInterest);
825
+ const existing = this.optionCache.get(symbol);
826
+ if (existing && openInterest > 0) {
827
+ existing.openInterest = openInterest;
828
+ existing.liveOpenInterest = this.calculateLiveOpenInterest(symbol);
829
+ existing.timestamp = timestamp;
830
+ this.optionCache.set(symbol, existing);
831
+ this.emit('optionUpdate', existing);
832
+ // Update base OI if not set
833
+ if (!this.baseOpenInterest.has(symbol)) {
834
+ this.baseOpenInterest.set(symbol, openInterest);
835
+ if (this.verbose) {
836
+ console.log(`[TastyTrade:OI] Base OI set from stream for ${symbol}: ${openInterest}`);
837
+ }
838
+ }
839
+ }
840
+ }
841
+ /**
842
+ * Updates ticker from Quote event.
843
+ */
844
+ updateTickerFromQuote(symbol, bidPrice, askPrice, bidSize, askSize, timestamp) {
845
+ const existing = this.tickerCache.get(symbol);
846
+ const ticker = {
847
+ symbol,
848
+ spot: bidPrice > 0 && askPrice > 0 ? (bidPrice + askPrice) / 2 : existing?.spot ?? 0,
849
+ bid: bidPrice,
850
+ bidSize,
851
+ ask: askPrice,
852
+ askSize,
853
+ last: existing?.last ?? 0,
854
+ volume: existing?.volume ?? 0,
855
+ timestamp,
856
+ };
857
+ this.tickerCache.set(symbol, ticker);
858
+ this.emit('tickerUpdate', ticker);
859
+ }
860
+ /**
861
+ * Updates ticker from Trade event.
862
+ */
863
+ updateTickerFromTrade(symbol, price, size, dayVolume, timestamp) {
864
+ const existing = this.tickerCache.get(symbol);
865
+ const ticker = {
866
+ symbol,
867
+ spot: existing?.spot ?? price,
868
+ bid: existing?.bid ?? 0,
869
+ bidSize: existing?.bidSize ?? 0,
870
+ ask: existing?.ask ?? 0,
871
+ askSize: existing?.askSize ?? 0,
872
+ last: price,
873
+ volume: dayVolume > 0 ? dayVolume : (existing?.volume ?? 0) + size,
874
+ timestamp,
875
+ };
876
+ this.tickerCache.set(symbol, ticker);
877
+ this.emit('tickerUpdate', ticker);
878
+ }
879
+ /**
880
+ * Updates option from Quote event.
881
+ */
882
+ updateOptionFromQuote(occSymbol, bidPrice, askPrice, bidSize, askSize, timestamp) {
883
+ const existing = this.optionCache.get(occSymbol);
884
+ // Parse OCC symbol if we don't have existing data
885
+ let parsed;
886
+ try {
887
+ parsed = (0, occ_1.parseOCCSymbol)(occSymbol);
888
+ }
889
+ catch {
890
+ // Try to use existing data or skip
891
+ if (!existing)
892
+ return;
893
+ parsed = {
894
+ symbol: existing.underlying,
895
+ expiration: new Date(existing.expirationTimestamp),
896
+ optionType: existing.optionType,
897
+ strike: existing.strike,
898
+ };
899
+ }
900
+ const option = {
901
+ occSymbol,
902
+ underlying: parsed.symbol,
903
+ strike: parsed.strike,
904
+ expiration: parsed.expiration.toISOString().split('T')[0],
905
+ expirationTimestamp: parsed.expiration.getTime(),
906
+ optionType: parsed.optionType,
907
+ bid: bidPrice,
908
+ bidSize,
909
+ ask: askPrice,
910
+ askSize,
911
+ mark: bidPrice > 0 && askPrice > 0 ? (bidPrice + askPrice) / 2 : existing?.mark ?? 0,
912
+ last: existing?.last ?? 0,
913
+ volume: existing?.volume ?? 0,
914
+ openInterest: existing?.openInterest ?? 0,
915
+ liveOpenInterest: this.calculateLiveOpenInterest(occSymbol),
916
+ impliedVolatility: existing?.impliedVolatility ?? 0,
917
+ timestamp,
918
+ };
919
+ this.optionCache.set(occSymbol, option);
920
+ this.emit('optionUpdate', option);
921
+ }
922
+ /**
923
+ * Updates option from Trade event.
924
+ */
925
+ updateOptionFromTrade(occSymbol, price, size, dayVolume, timestamp) {
926
+ const existing = this.optionCache.get(occSymbol);
927
+ // Parse OCC symbol
928
+ let parsed;
929
+ try {
930
+ parsed = (0, occ_1.parseOCCSymbol)(occSymbol);
931
+ }
932
+ catch {
933
+ if (!existing)
934
+ return;
935
+ parsed = {
936
+ symbol: existing.underlying,
937
+ expiration: new Date(existing.expirationTimestamp),
938
+ optionType: existing.optionType,
939
+ strike: existing.strike,
940
+ };
941
+ }
942
+ // Determine aggressor side
943
+ const bid = existing?.bid ?? 0;
944
+ const ask = existing?.ask ?? 0;
945
+ const aggressorSide = this.determineAggressorSide(price, bid, ask);
946
+ // Calculate OI change
947
+ const estimatedOIChange = this.calculateOIChangeFromTrade(aggressorSide, size, parsed.optionType);
948
+ const currentChange = this.cumulativeOIChange.get(occSymbol) ?? 0;
949
+ this.cumulativeOIChange.set(occSymbol, currentChange + estimatedOIChange);
950
+ if (this.verbose && estimatedOIChange !== 0) {
951
+ const baseOI = this.baseOpenInterest.get(occSymbol) ?? 0;
952
+ const newLiveOI = Math.max(0, baseOI + currentChange + estimatedOIChange);
953
+ console.log(`[TastyTrade:OI] ${occSymbol} trade: price=${price.toFixed(2)}, size=${size}, aggressor=${aggressorSide}, OI change=${estimatedOIChange > 0 ? '+' : ''}${estimatedOIChange}, liveOI=${newLiveOI} (base=${baseOI}, cumulative=${currentChange + estimatedOIChange})`);
954
+ }
955
+ // Record trade
956
+ const trade = {
957
+ occSymbol,
958
+ price,
959
+ size,
960
+ bid,
961
+ ask,
962
+ aggressorSide,
963
+ timestamp,
964
+ estimatedOIChange,
965
+ };
966
+ if (!this.intradayTrades.has(occSymbol)) {
967
+ this.intradayTrades.set(occSymbol, []);
968
+ }
969
+ this.intradayTrades.get(occSymbol).push(trade);
970
+ this.emit('optionTrade', trade);
971
+ const option = {
972
+ occSymbol,
973
+ underlying: parsed.symbol,
974
+ strike: parsed.strike,
975
+ expiration: parsed.expiration.toISOString().split('T')[0],
976
+ expirationTimestamp: parsed.expiration.getTime(),
977
+ optionType: parsed.optionType,
978
+ bid,
979
+ bidSize: existing?.bidSize ?? 0,
980
+ ask,
981
+ askSize: existing?.askSize ?? 0,
982
+ mark: bid > 0 && ask > 0 ? (bid + ask) / 2 : price,
983
+ last: price,
984
+ volume: dayVolume > 0 ? dayVolume : (existing?.volume ?? 0) + size,
985
+ openInterest: existing?.openInterest ?? 0,
986
+ liveOpenInterest: this.calculateLiveOpenInterest(occSymbol),
987
+ impliedVolatility: existing?.impliedVolatility ?? 0,
988
+ timestamp,
989
+ };
990
+ this.optionCache.set(occSymbol, option);
991
+ this.emit('optionUpdate', option);
992
+ }
993
+ /**
994
+ * Determines aggressor side from trade price vs NBBO.
995
+ */
996
+ determineAggressorSide(tradePrice, bid, ask) {
997
+ if (bid <= 0 || ask <= 0)
998
+ return 'unknown';
999
+ const spread = ask - bid;
1000
+ const tolerance = spread > 0 ? spread * 0.001 : 0.001;
1001
+ if (tradePrice >= ask - tolerance) {
1002
+ return 'buy';
1003
+ }
1004
+ else if (tradePrice <= bid + tolerance) {
1005
+ return 'sell';
1006
+ }
1007
+ return 'unknown';
1008
+ }
1009
+ /**
1010
+ * Calculates estimated OI change from trade.
1011
+ */
1012
+ calculateOIChangeFromTrade(aggressorSide, size, _optionType) {
1013
+ if (aggressorSide === 'unknown')
1014
+ return 0;
1015
+ return aggressorSide === 'buy' ? size : -size;
1016
+ }
1017
+ /**
1018
+ * Calculates live open interest.
1019
+ */
1020
+ calculateLiveOpenInterest(occSymbol) {
1021
+ const baseOI = this.baseOpenInterest.get(occSymbol) ?? 0;
1022
+ const cumulativeChange = this.cumulativeOIChange.get(occSymbol) ?? 0;
1023
+ return Math.max(0, baseOI + cumulativeChange);
1024
+ }
1025
+ /**
1026
+ * Handles DxLink error messages.
1027
+ */
1028
+ handleError(message) {
1029
+ this.emit('error', new Error(`DxLink error: ${message.error} - ${message.message}`));
1030
+ }
1031
+ /**
1032
+ * Attempts to reconnect with exponential backoff.
1033
+ */
1034
+ async attemptReconnect() {
1035
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
1036
+ this.emit('error', new Error('Max reconnection attempts reached'));
1037
+ return;
1038
+ }
1039
+ this.reconnectAttempts++;
1040
+ const delay = this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
1041
+ if (this.verbose) {
1042
+ console.log(`[TastyTrade:DxLink] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`);
1043
+ }
1044
+ await this.sleep(delay);
1045
+ try {
1046
+ await this.connect();
1047
+ }
1048
+ catch {
1049
+ // Will try again via onclose
1050
+ }
1051
+ }
1052
+ /**
1053
+ * Checks if symbol is an OCC option symbol.
1054
+ */
1055
+ isOptionSymbol(symbol) {
1056
+ return OCC_OPTION_PATTERN.test(symbol);
1057
+ }
1058
+ /**
1059
+ * Sends a message to the WebSocket.
1060
+ */
1061
+ sendMessage(message) {
1062
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1063
+ this.ws.send(JSON.stringify(message));
1064
+ }
1065
+ }
1066
+ /**
1067
+ * Emits an event to all listeners.
1068
+ */
1069
+ emit(event, data) {
1070
+ const listeners = this.eventListeners.get(event);
1071
+ if (listeners) {
1072
+ listeners.forEach(listener => {
1073
+ try {
1074
+ listener(data);
1075
+ }
1076
+ catch (error) {
1077
+ console.error('Event listener error:', error);
1078
+ }
1079
+ });
1080
+ }
1081
+ }
1082
+ /**
1083
+ * Converts value to number, handling NaN and null.
1084
+ */
1085
+ toNumber(value) {
1086
+ if (value === null || value === undefined)
1087
+ return 0;
1088
+ if (typeof value === 'number')
1089
+ return isNaN(value) ? 0 : value;
1090
+ const num = parseFloat(value);
1091
+ return isNaN(num) ? 0 : num;
1092
+ }
1093
+ /**
1094
+ * Sleep utility.
1095
+ */
1096
+ sleep(ms) {
1097
+ return new Promise(resolve => setTimeout(resolve, ms));
1098
+ }
1099
+ }
1100
+ exports.TastyTradeClient = TastyTradeClient;