@fullstackcraftllc/floe 0.0.3 → 0.0.4

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