@fullstackcraftllc/floe 0.0.4 → 0.0.6

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,880 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TradeStationClient = void 0;
4
+ const occ_1 = require("../../utils/occ");
5
+ /**
6
+ * Regex pattern to identify TradeStation option symbols
7
+ * TradeStation uses space-padded format: "MSFT 220916C305"
8
+ */
9
+ const TS_OPTION_PATTERN = /^[A-Z]+\s+\d{6}[CP]\d+(\.\d+)?$/;
10
+ /**
11
+ * Regex pattern to identify OCC option symbols
12
+ * Format: ROOT + YYMMDD + C/P + 8-digit strike
13
+ */
14
+ const OCC_OPTION_PATTERN = /^.{1,6}\d{6}[CP]\d{8}$/;
15
+ /**
16
+ * TradeStationClient handles real-time streaming connections to the TradeStation API
17
+ * via HTTP chunked transfer encoding.
18
+ *
19
+ * @remarks
20
+ * This client manages HTTP streaming connections to TradeStation's market data API,
21
+ * normalizes incoming quote data, and emits events for upstream consumption by
22
+ * the FloeClient.
23
+ *
24
+ * TradeStation uses HTTP streaming (chunked transfer encoding) instead of WebSockets.
25
+ * Each stream is a long-lived HTTP connection that returns JSON objects separated
26
+ * by newlines.
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * const client = new TradeStationClient({
31
+ * accessToken: 'your-oauth-access-token'
32
+ * });
33
+ *
34
+ * client.on('tickerUpdate', (ticker) => {
35
+ * console.log(`${ticker.symbol}: ${ticker.spot}`);
36
+ * });
37
+ *
38
+ * await client.connect();
39
+ * client.subscribe(['MSFT', 'AAPL']);
40
+ * ```
41
+ */
42
+ class TradeStationClient {
43
+ /**
44
+ * Creates a new TradeStationClient instance.
45
+ *
46
+ * @param options - Client configuration options
47
+ * @param options.accessToken - TradeStation OAuth access token (required)
48
+ * @param options.refreshToken - OAuth refresh token for automatic token renewal
49
+ * @param options.simulation - Whether to use simulation environment (default: false)
50
+ * @param options.onTokenRefresh - Callback when token is refreshed
51
+ * @param options.verbose - Whether to log verbose debug information (default: false)
52
+ */
53
+ constructor(options) {
54
+ /** Connection state */
55
+ this.connected = false;
56
+ /** Currently subscribed ticker symbols */
57
+ this.subscribedTickers = new Set();
58
+ /** Currently subscribed option symbols */
59
+ this.subscribedOptions = new Set();
60
+ /** Active AbortControllers for streams */
61
+ this.activeStreams = new Map();
62
+ /** Cached ticker data */
63
+ this.tickerCache = new Map();
64
+ /** Cached option data */
65
+ this.optionCache = new Map();
66
+ /** Base open interest from REST API */
67
+ this.baseOpenInterest = new Map();
68
+ /** Cumulative estimated OI change from intraday trades */
69
+ this.cumulativeOIChange = new Map();
70
+ /** History of intraday trades */
71
+ this.intradayTrades = new Map();
72
+ /** Event listeners */
73
+ this.eventListeners = new Map();
74
+ /** Reconnection attempt counter */
75
+ this.reconnectAttempts = 0;
76
+ /** Maximum reconnection attempts */
77
+ this.maxReconnectAttempts = 5;
78
+ /** Reconnection delay in ms */
79
+ this.baseReconnectDelay = 1000;
80
+ /** TradeStation API base URL */
81
+ this.apiBaseUrl = 'https://api.tradestation.com/v3';
82
+ /** Refresh token for token refresh */
83
+ this.refreshToken = null;
84
+ /** Token refresh callback */
85
+ this.onTokenRefresh = null;
86
+ this.accessToken = options.accessToken;
87
+ this.refreshToken = options.refreshToken ?? null;
88
+ this.simulation = options.simulation ?? false;
89
+ this.onTokenRefresh = options.onTokenRefresh ?? null;
90
+ this.verbose = options.verbose ?? false;
91
+ // Initialize event listener maps
92
+ this.eventListeners.set('tickerUpdate', new Set());
93
+ this.eventListeners.set('optionUpdate', new Set());
94
+ this.eventListeners.set('optionTrade', new Set());
95
+ this.eventListeners.set('connected', new Set());
96
+ this.eventListeners.set('disconnected', new Set());
97
+ this.eventListeners.set('error', new Set());
98
+ }
99
+ // ==================== Public API ====================
100
+ /**
101
+ * Establishes connection state for TradeStation streaming.
102
+ *
103
+ * @returns Promise that resolves when ready to stream
104
+ * @throws {Error} If token validation fails
105
+ *
106
+ * @remarks
107
+ * Unlike WebSocket-based clients, TradeStation uses HTTP streaming.
108
+ * This method validates the access token by making a test API call.
109
+ */
110
+ async connect() {
111
+ try {
112
+ // Validate token by making a simple API call
113
+ const response = await fetch(`${this.apiBaseUrl}/brokerage/accounts`, {
114
+ method: 'GET',
115
+ headers: this.getAuthHeaders(),
116
+ });
117
+ if (!response.ok) {
118
+ if (response.status === 401) {
119
+ throw new Error('TradeStation authentication failed - invalid or expired token');
120
+ }
121
+ throw new Error(`TradeStation connection failed: ${response.statusText}`);
122
+ }
123
+ this.connected = true;
124
+ this.reconnectAttempts = 0;
125
+ if (this.verbose) {
126
+ console.log('[TradeStation:HTTP] Connected to streaming API');
127
+ }
128
+ this.emit('connected', undefined);
129
+ }
130
+ catch (error) {
131
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
132
+ throw error;
133
+ }
134
+ }
135
+ /**
136
+ * Disconnects from all TradeStation streaming APIs.
137
+ */
138
+ disconnect() {
139
+ // Abort all active streams
140
+ for (const [streamId, controller] of this.activeStreams) {
141
+ controller.abort();
142
+ }
143
+ this.activeStreams.clear();
144
+ this.connected = false;
145
+ this.subscribedTickers.clear();
146
+ this.subscribedOptions.clear();
147
+ this.emit('disconnected', { reason: 'Client disconnect' });
148
+ }
149
+ /**
150
+ * Subscribes to real-time updates for the specified symbols.
151
+ *
152
+ * @param symbols - Array of ticker symbols and/or option symbols
153
+ *
154
+ * @remarks
155
+ * TradeStation uses different streaming endpoints for equities and options.
156
+ * This method automatically routes symbols to the appropriate endpoint.
157
+ *
158
+ * Option symbols can be in either:
159
+ * - TradeStation format: "MSFT 220916C305"
160
+ * - OCC format: "MSFT220916C00305000"
161
+ */
162
+ subscribe(symbols) {
163
+ const tickers = [];
164
+ const options = [];
165
+ for (const symbol of symbols) {
166
+ if (this.isOptionSymbol(symbol)) {
167
+ options.push(symbol);
168
+ this.subscribedOptions.add(symbol);
169
+ }
170
+ else {
171
+ tickers.push(symbol);
172
+ this.subscribedTickers.add(symbol);
173
+ }
174
+ }
175
+ if (tickers.length > 0) {
176
+ this.startQuoteStream(tickers);
177
+ }
178
+ if (options.length > 0) {
179
+ this.startOptionQuoteStream(options);
180
+ }
181
+ }
182
+ /**
183
+ * Unsubscribes from real-time updates for the specified symbols.
184
+ *
185
+ * @param symbols - Array of symbols to unsubscribe from
186
+ *
187
+ * @remarks
188
+ * For TradeStation, unsubscribing requires stopping the stream and
189
+ * restarting with the remaining symbols.
190
+ */
191
+ unsubscribe(symbols) {
192
+ const tickersToRemove = [];
193
+ const optionsToRemove = [];
194
+ for (const symbol of symbols) {
195
+ if (this.isOptionSymbol(symbol)) {
196
+ this.subscribedOptions.delete(symbol);
197
+ optionsToRemove.push(symbol);
198
+ }
199
+ else {
200
+ this.subscribedTickers.delete(symbol);
201
+ tickersToRemove.push(symbol);
202
+ }
203
+ }
204
+ // Restart streams with remaining symbols
205
+ if (tickersToRemove.length > 0 && this.subscribedTickers.size > 0) {
206
+ this.stopStream('quotes');
207
+ this.startQuoteStream(Array.from(this.subscribedTickers));
208
+ }
209
+ else if (tickersToRemove.length > 0) {
210
+ this.stopStream('quotes');
211
+ }
212
+ if (optionsToRemove.length > 0 && this.subscribedOptions.size > 0) {
213
+ this.stopStream('options');
214
+ this.startOptionQuoteStream(Array.from(this.subscribedOptions));
215
+ }
216
+ else if (optionsToRemove.length > 0) {
217
+ this.stopStream('options');
218
+ }
219
+ }
220
+ /**
221
+ * Returns whether the client is currently connected.
222
+ */
223
+ isConnected() {
224
+ return this.connected;
225
+ }
226
+ /**
227
+ * Fetches quote snapshots for the specified symbols.
228
+ *
229
+ * @param symbols - Array of symbols (max 100)
230
+ * @returns Array of normalized tickers
231
+ */
232
+ async fetchQuotes(symbols) {
233
+ try {
234
+ const symbolList = symbols.slice(0, 100).join(',');
235
+ const url = `${this.apiBaseUrl}/marketdata/quotes/${encodeURIComponent(symbolList)}`;
236
+ const response = await fetch(url, {
237
+ method: 'GET',
238
+ headers: this.getAuthHeaders(),
239
+ });
240
+ if (!response.ok) {
241
+ this.emit('error', new Error(`Failed to fetch quotes: ${response.statusText}`));
242
+ return [];
243
+ }
244
+ const data = await response.json();
245
+ const tickers = [];
246
+ for (const quote of data.Quotes) {
247
+ if (!quote.Error) {
248
+ const ticker = this.normalizeQuote(quote);
249
+ this.tickerCache.set(ticker.symbol, ticker);
250
+ tickers.push(ticker);
251
+ }
252
+ }
253
+ return tickers;
254
+ }
255
+ catch (error) {
256
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
257
+ return [];
258
+ }
259
+ }
260
+ /**
261
+ * Fetches option expirations for an underlying symbol.
262
+ *
263
+ * @param underlying - Underlying symbol (e.g., 'AAPL')
264
+ * @returns Array of expiration dates
265
+ */
266
+ async fetchOptionExpirations(underlying) {
267
+ try {
268
+ const url = `${this.apiBaseUrl}/marketdata/options/expirations/${encodeURIComponent(underlying)}`;
269
+ const response = await fetch(url, {
270
+ method: 'GET',
271
+ headers: this.getAuthHeaders(),
272
+ });
273
+ if (!response.ok) {
274
+ this.emit('error', new Error(`Failed to fetch option expirations: ${response.statusText}`));
275
+ return [];
276
+ }
277
+ const data = await response.json();
278
+ return data.Expirations.map(exp => exp.Date);
279
+ }
280
+ catch (error) {
281
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
282
+ return [];
283
+ }
284
+ }
285
+ /**
286
+ * Streams option chain data for an underlying symbol.
287
+ *
288
+ * @param underlying - Underlying symbol (e.g., 'AAPL')
289
+ * @param options - Stream options
290
+ * @returns Promise that resolves when stream is established
291
+ */
292
+ async streamOptionChain(underlying, options) {
293
+ const params = new URLSearchParams();
294
+ if (options?.expiration)
295
+ params.set('expiration', options.expiration);
296
+ if (options?.strikeProximity)
297
+ params.set('strikeProximity', options.strikeProximity.toString());
298
+ if (options?.enableGreeks !== undefined)
299
+ params.set('enableGreeks', options.enableGreeks.toString());
300
+ if (options?.optionType)
301
+ params.set('optionType', options.optionType);
302
+ const url = `${this.apiBaseUrl}/marketdata/stream/options/chains/${encodeURIComponent(underlying)}?${params.toString()}`;
303
+ const streamId = `chain_${underlying}`;
304
+ await this.startHttpStream(streamId, url, (data) => {
305
+ this.handleOptionChainData(underlying, data);
306
+ });
307
+ }
308
+ /**
309
+ * Fetches symbol details for the specified symbols.
310
+ *
311
+ * @param symbols - Array of symbols (max 50)
312
+ * @returns Symbol details response
313
+ */
314
+ async fetchSymbolDetails(symbols) {
315
+ try {
316
+ const symbolList = symbols.slice(0, 50).join(',');
317
+ const url = `${this.apiBaseUrl}/marketdata/symbols/${encodeURIComponent(symbolList)}`;
318
+ const response = await fetch(url, {
319
+ method: 'GET',
320
+ headers: this.getAuthHeaders(),
321
+ });
322
+ if (!response.ok) {
323
+ this.emit('error', new Error(`Failed to fetch symbol details: ${response.statusText}`));
324
+ return { Symbols: [], Errors: [] };
325
+ }
326
+ return await response.json();
327
+ }
328
+ catch (error) {
329
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
330
+ return { Symbols: [], Errors: [] };
331
+ }
332
+ }
333
+ /**
334
+ * Returns cached option data for a symbol.
335
+ */
336
+ getOption(occSymbol) {
337
+ return this.optionCache.get(occSymbol);
338
+ }
339
+ /**
340
+ * Returns all cached options.
341
+ */
342
+ getAllOptions() {
343
+ return new Map(this.optionCache);
344
+ }
345
+ /**
346
+ * Returns cached ticker data for a symbol.
347
+ */
348
+ getTicker(symbol) {
349
+ return this.tickerCache.get(symbol);
350
+ }
351
+ /**
352
+ * Returns all cached tickers.
353
+ */
354
+ getAllTickers() {
355
+ return new Map(this.tickerCache);
356
+ }
357
+ /**
358
+ * Registers an event listener.
359
+ */
360
+ on(event, listener) {
361
+ const listeners = this.eventListeners.get(event);
362
+ if (listeners) {
363
+ listeners.add(listener);
364
+ }
365
+ return this;
366
+ }
367
+ /**
368
+ * Removes an event listener.
369
+ */
370
+ off(event, listener) {
371
+ const listeners = this.eventListeners.get(event);
372
+ if (listeners) {
373
+ listeners.delete(listener);
374
+ }
375
+ return this;
376
+ }
377
+ /**
378
+ * Returns intraday trades for an option.
379
+ */
380
+ getIntradayTrades(occSymbol) {
381
+ return this.intradayTrades.get(occSymbol) ?? [];
382
+ }
383
+ /**
384
+ * Returns flow summary for an option.
385
+ */
386
+ getFlowSummary(occSymbol) {
387
+ const trades = this.intradayTrades.get(occSymbol) ?? [];
388
+ let buyVolume = 0;
389
+ let sellVolume = 0;
390
+ let unknownVolume = 0;
391
+ for (const trade of trades) {
392
+ switch (trade.aggressorSide) {
393
+ case 'buy':
394
+ buyVolume += trade.size;
395
+ break;
396
+ case 'sell':
397
+ sellVolume += trade.size;
398
+ break;
399
+ case 'unknown':
400
+ unknownVolume += trade.size;
401
+ break;
402
+ }
403
+ }
404
+ return {
405
+ buyVolume,
406
+ sellVolume,
407
+ unknownVolume,
408
+ netOIChange: this.cumulativeOIChange.get(occSymbol) ?? 0,
409
+ tradeCount: trades.length,
410
+ };
411
+ }
412
+ /**
413
+ * Resets intraday tracking data.
414
+ */
415
+ resetIntradayData(occSymbols) {
416
+ const symbolsToReset = occSymbols ?? Array.from(this.intradayTrades.keys());
417
+ for (const symbol of symbolsToReset) {
418
+ this.intradayTrades.delete(symbol);
419
+ this.cumulativeOIChange.set(symbol, 0);
420
+ }
421
+ }
422
+ /**
423
+ * Updates the access token (for token refresh scenarios).
424
+ */
425
+ updateAccessToken(newToken) {
426
+ this.accessToken = newToken;
427
+ }
428
+ // ==================== Private Methods ====================
429
+ /**
430
+ * Gets authorization headers for API requests.
431
+ */
432
+ getAuthHeaders() {
433
+ return {
434
+ 'Authorization': `Bearer ${this.accessToken}`,
435
+ 'Accept': 'application/json',
436
+ };
437
+ }
438
+ /**
439
+ * Gets headers for streaming requests.
440
+ */
441
+ getStreamHeaders() {
442
+ return {
443
+ 'Authorization': `Bearer ${this.accessToken}`,
444
+ 'Accept': 'application/vnd.tradestation.streams.v2+json',
445
+ };
446
+ }
447
+ /**
448
+ * Starts a quote stream for ticker symbols.
449
+ */
450
+ async startQuoteStream(symbols) {
451
+ if (symbols.length === 0)
452
+ return;
453
+ const symbolList = symbols.slice(0, 100).join(',');
454
+ const url = `${this.apiBaseUrl}/marketdata/stream/quotes/${encodeURIComponent(symbolList)}`;
455
+ await this.startHttpStream('quotes', url, (data) => {
456
+ this.handleQuoteData(data);
457
+ });
458
+ }
459
+ /**
460
+ * Starts an option quote stream.
461
+ */
462
+ async startOptionQuoteStream(symbols) {
463
+ if (symbols.length === 0)
464
+ return;
465
+ // TradeStation option streaming uses legs parameter
466
+ // Build URL with legs for each option
467
+ const params = new URLSearchParams();
468
+ symbols.forEach((symbol, index) => {
469
+ const tsSymbol = this.toTradeStationOptionSymbol(symbol);
470
+ params.set(`legs[${index}].Symbol`, tsSymbol);
471
+ params.set(`legs[${index}].Ratio`, '1');
472
+ });
473
+ params.set('enableGreeks', 'true');
474
+ const url = `${this.apiBaseUrl}/marketdata/stream/options/quotes?${params.toString()}`;
475
+ await this.startHttpStream('options', url, (data) => {
476
+ this.handleOptionQuoteData(data);
477
+ });
478
+ }
479
+ /**
480
+ * Starts an HTTP streaming connection.
481
+ */
482
+ async startHttpStream(streamId, url, onData) {
483
+ // Stop any existing stream with this ID
484
+ this.stopStream(streamId);
485
+ const controller = new AbortController();
486
+ this.activeStreams.set(streamId, controller);
487
+ try {
488
+ const response = await fetch(url, {
489
+ method: 'GET',
490
+ headers: this.getStreamHeaders(),
491
+ signal: controller.signal,
492
+ });
493
+ if (!response.ok) {
494
+ if (response.status === 401) {
495
+ this.emit('error', new Error('TradeStation stream authentication failed'));
496
+ return;
497
+ }
498
+ this.emit('error', new Error(`TradeStation stream failed: ${response.statusText}`));
499
+ return;
500
+ }
501
+ if (!response.body) {
502
+ this.emit('error', new Error('TradeStation stream response has no body'));
503
+ return;
504
+ }
505
+ // Process the stream
506
+ this.processStream(streamId, response.body, onData);
507
+ }
508
+ catch (error) {
509
+ if (error instanceof Error && error.name === 'AbortError') {
510
+ // Stream was intentionally aborted
511
+ return;
512
+ }
513
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
514
+ }
515
+ }
516
+ /**
517
+ * Processes an HTTP chunked stream.
518
+ */
519
+ async processStream(streamId, body, onData) {
520
+ const reader = body.getReader();
521
+ const decoder = new TextDecoder();
522
+ let buffer = '';
523
+ try {
524
+ while (true) {
525
+ const { done, value } = await reader.read();
526
+ if (done) {
527
+ // Stream ended
528
+ this.handleStreamEnd(streamId);
529
+ break;
530
+ }
531
+ // Decode chunk and add to buffer
532
+ buffer += decoder.decode(value, { stream: true });
533
+ // Process complete JSON objects
534
+ // TradeStation separates objects with newlines
535
+ const lines = buffer.split('\n');
536
+ buffer = lines.pop() ?? ''; // Keep incomplete line in buffer
537
+ for (const line of lines) {
538
+ const trimmed = line.trim();
539
+ if (!trimmed)
540
+ continue;
541
+ try {
542
+ const data = JSON.parse(trimmed);
543
+ onData(data);
544
+ }
545
+ catch {
546
+ // May be chunk boundary or malformed JSON, skip
547
+ }
548
+ }
549
+ }
550
+ }
551
+ catch (error) {
552
+ if (error instanceof Error && error.name === 'AbortError') {
553
+ return;
554
+ }
555
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
556
+ this.handleStreamEnd(streamId);
557
+ }
558
+ finally {
559
+ reader.releaseLock();
560
+ }
561
+ }
562
+ /**
563
+ * Handles stream end/disconnect.
564
+ */
565
+ handleStreamEnd(streamId) {
566
+ this.activeStreams.delete(streamId);
567
+ if (this.activeStreams.size === 0) {
568
+ this.emit('disconnected', { reason: 'All streams ended' });
569
+ }
570
+ }
571
+ /**
572
+ * Stops a stream by ID.
573
+ */
574
+ stopStream(streamId) {
575
+ const controller = this.activeStreams.get(streamId);
576
+ if (controller) {
577
+ controller.abort();
578
+ this.activeStreams.delete(streamId);
579
+ }
580
+ }
581
+ /**
582
+ * Handles incoming quote stream data.
583
+ */
584
+ handleQuoteData(data) {
585
+ // Check for stream status
586
+ if (data.StreamStatus) {
587
+ if (data.StreamStatus === 'GoAway') {
588
+ // Server is terminating stream, need to reconnect
589
+ this.handleStreamGoAway('quotes');
590
+ }
591
+ return;
592
+ }
593
+ // Check for errors
594
+ if (data.Error) {
595
+ this.emit('error', new Error(`TradeStation quote error: ${data.Error}`));
596
+ return;
597
+ }
598
+ if (!data.Symbol)
599
+ return;
600
+ const ticker = this.normalizeQuote(data);
601
+ this.tickerCache.set(ticker.symbol, ticker);
602
+ this.emit('tickerUpdate', ticker);
603
+ }
604
+ /**
605
+ * Handles incoming option quote stream data.
606
+ */
607
+ handleOptionQuoteData(data) {
608
+ // Check for stream status
609
+ if (data.StreamStatus) {
610
+ if (data.StreamStatus === 'GoAway') {
611
+ this.handleStreamGoAway('options');
612
+ }
613
+ return;
614
+ }
615
+ if (data.Error) {
616
+ this.emit('error', new Error(`TradeStation option error: ${data.Error}`));
617
+ return;
618
+ }
619
+ if (!data.Legs || data.Legs.length === 0)
620
+ return;
621
+ // Process each leg
622
+ for (const leg of data.Legs) {
623
+ if (!leg.Symbol)
624
+ continue;
625
+ const option = this.normalizeOptionQuote(data, leg);
626
+ if (option) {
627
+ this.optionCache.set(option.occSymbol, option);
628
+ this.emit('optionUpdate', option);
629
+ }
630
+ }
631
+ }
632
+ /**
633
+ * Handles option chain stream data.
634
+ */
635
+ handleOptionChainData(underlying, data) {
636
+ if (data.StreamStatus) {
637
+ if (data.StreamStatus === 'GoAway') {
638
+ this.handleStreamGoAway(`chain_${underlying}`);
639
+ }
640
+ return;
641
+ }
642
+ if (data.Error) {
643
+ this.emit('error', new Error(`TradeStation chain error: ${data.Error}`));
644
+ return;
645
+ }
646
+ if (!data.Legs || data.Legs.length === 0)
647
+ return;
648
+ for (const leg of data.Legs) {
649
+ if (!leg.Symbol)
650
+ continue;
651
+ const option = this.normalizeOptionQuote(data, leg);
652
+ if (option) {
653
+ // Store base OI
654
+ if (leg.OpenInterest !== undefined) {
655
+ this.baseOpenInterest.set(option.occSymbol, leg.OpenInterest);
656
+ if (this.verbose) {
657
+ console.log(`[TradeStation:OI] Base OI set for ${option.occSymbol}: ${leg.OpenInterest}`);
658
+ }
659
+ }
660
+ if (!this.cumulativeOIChange.has(option.occSymbol)) {
661
+ this.cumulativeOIChange.set(option.occSymbol, 0);
662
+ }
663
+ this.optionCache.set(option.occSymbol, option);
664
+ this.emit('optionUpdate', option);
665
+ }
666
+ }
667
+ }
668
+ /**
669
+ * Handles GoAway stream status - server is terminating stream.
670
+ */
671
+ handleStreamGoAway(streamId) {
672
+ // Remove the stream and attempt to restart after a delay
673
+ this.stopStream(streamId);
674
+ setTimeout(() => {
675
+ if (!this.connected)
676
+ return;
677
+ if (streamId === 'quotes' && this.subscribedTickers.size > 0) {
678
+ this.startQuoteStream(Array.from(this.subscribedTickers));
679
+ }
680
+ else if (streamId === 'options' && this.subscribedOptions.size > 0) {
681
+ this.startOptionQuoteStream(Array.from(this.subscribedOptions));
682
+ }
683
+ else if (streamId.startsWith('chain_')) {
684
+ const underlying = streamId.replace('chain_', '');
685
+ this.streamOptionChain(underlying);
686
+ }
687
+ }, this.baseReconnectDelay);
688
+ }
689
+ /**
690
+ * Normalizes TradeStation quote to NormalizedTicker.
691
+ */
692
+ normalizeQuote(quote) {
693
+ const bid = this.parseNumber(quote.Bid);
694
+ const ask = this.parseNumber(quote.Ask);
695
+ const last = this.parseNumber(quote.Last);
696
+ return {
697
+ symbol: quote.Symbol,
698
+ spot: bid > 0 && ask > 0 ? (bid + ask) / 2 : last,
699
+ bid,
700
+ bidSize: this.parseNumber(quote.BidSize),
701
+ ask,
702
+ askSize: this.parseNumber(quote.AskSize),
703
+ last,
704
+ volume: this.parseNumber(quote.Volume),
705
+ timestamp: quote.TradeTime ? new Date(quote.TradeTime).getTime() : Date.now(),
706
+ };
707
+ }
708
+ /**
709
+ * Normalizes TradeStation option quote to NormalizedOption.
710
+ */
711
+ normalizeOptionQuote(data, leg) {
712
+ // Convert TradeStation symbol to OCC format
713
+ const occSymbol = this.toOCCSymbol(leg.Symbol);
714
+ if (!occSymbol)
715
+ return null;
716
+ // Parse OCC symbol for details
717
+ let parsed;
718
+ try {
719
+ parsed = (0, occ_1.parseOCCSymbol)(occSymbol);
720
+ }
721
+ catch {
722
+ // Try to extract from leg data
723
+ if (!leg.Underlying || !leg.ExpirationDate || !leg.StrikePrice || !leg.OptionType) {
724
+ return null;
725
+ }
726
+ parsed = {
727
+ symbol: leg.Underlying,
728
+ expiration: new Date(leg.ExpirationDate),
729
+ optionType: leg.OptionType.toLowerCase(),
730
+ strike: parseFloat(leg.StrikePrice),
731
+ };
732
+ }
733
+ const bid = this.parseNumber(data.Bid);
734
+ const ask = this.parseNumber(data.Ask);
735
+ const last = this.parseNumber(data.Last);
736
+ const existingOI = this.baseOpenInterest.get(occSymbol) ?? 0;
737
+ return {
738
+ occSymbol,
739
+ underlying: leg.Underlying ?? parsed.symbol,
740
+ strike: parseFloat(leg.StrikePrice ?? parsed.strike.toString()),
741
+ expiration: leg.ExpirationDate ?? parsed.expiration.toISOString().split('T')[0],
742
+ expirationTimestamp: parsed.expiration.getTime(),
743
+ optionType: (leg.OptionType?.toLowerCase() ?? parsed.optionType),
744
+ bid,
745
+ bidSize: data.BidSize ?? 0,
746
+ ask,
747
+ askSize: data.AskSize ?? 0,
748
+ mark: bid > 0 && ask > 0 ? (bid + ask) / 2 : last,
749
+ last,
750
+ volume: data.Volume ?? 0,
751
+ openInterest: data.DailyOpenInterest ?? leg.OpenInterest ?? existingOI,
752
+ liveOpenInterest: this.calculateLiveOpenInterest(occSymbol),
753
+ impliedVolatility: this.parseNumber(data.ImpliedVolatility),
754
+ timestamp: Date.now(),
755
+ };
756
+ }
757
+ /**
758
+ * Converts TradeStation option symbol to OCC format.
759
+ * TradeStation format: "MSFT 220916C305" or "MSFT 220916C305.00"
760
+ * OCC format: "MSFT220916C00305000"
761
+ */
762
+ toOCCSymbol(tsSymbol) {
763
+ if (!tsSymbol)
764
+ return null;
765
+ // Already in OCC format?
766
+ if (OCC_OPTION_PATTERN.test(tsSymbol.replace(/\s+/g, ''))) {
767
+ return tsSymbol.replace(/\s+/g, '');
768
+ }
769
+ // Parse TradeStation format
770
+ const match = tsSymbol.match(/^([A-Z]+)\s+(\d{6})([CP])(\d+(?:\.\d+)?)$/);
771
+ if (!match)
772
+ return null;
773
+ const [, root, dateStr, optType, strikeStr] = match;
774
+ const strike = parseFloat(strikeStr);
775
+ const strikeFormatted = Math.round(strike * 1000).toString().padStart(8, '0');
776
+ return `${root}${dateStr}${optType}${strikeFormatted}`;
777
+ }
778
+ /**
779
+ * Converts OCC symbol to TradeStation format.
780
+ * OCC format: "MSFT220916C00305000"
781
+ * TradeStation format: "MSFT 220916C305"
782
+ */
783
+ toTradeStationOptionSymbol(symbol) {
784
+ // If already in TradeStation format, return as-is
785
+ if (TS_OPTION_PATTERN.test(symbol)) {
786
+ return symbol;
787
+ }
788
+ // Parse OCC format
789
+ try {
790
+ const parsed = (0, occ_1.parseOCCSymbol)(symbol);
791
+ const dateStr = [
792
+ parsed.expiration.getFullYear().toString().slice(-2),
793
+ (parsed.expiration.getMonth() + 1).toString().padStart(2, '0'),
794
+ parsed.expiration.getDate().toString().padStart(2, '0'),
795
+ ].join('');
796
+ const optType = parsed.optionType === 'call' ? 'C' : 'P';
797
+ // Format strike - remove trailing zeros
798
+ let strikeStr = parsed.strike.toString();
799
+ if (parsed.strike % 1 === 0) {
800
+ strikeStr = parsed.strike.toFixed(0);
801
+ }
802
+ return `${parsed.symbol} ${dateStr}${optType}${strikeStr}`;
803
+ }
804
+ catch {
805
+ return symbol;
806
+ }
807
+ }
808
+ /**
809
+ * Determines aggressor side from trade price vs NBBO.
810
+ */
811
+ determineAggressorSide(tradePrice, bid, ask) {
812
+ if (bid <= 0 || ask <= 0)
813
+ return 'unknown';
814
+ const spread = ask - bid;
815
+ const tolerance = spread > 0 ? spread * 0.001 : 0.001;
816
+ if (tradePrice >= ask - tolerance) {
817
+ return 'buy';
818
+ }
819
+ else if (tradePrice <= bid + tolerance) {
820
+ return 'sell';
821
+ }
822
+ return 'unknown';
823
+ }
824
+ /**
825
+ * Calculates estimated OI change from trade.
826
+ */
827
+ calculateOIChangeFromTrade(aggressorSide, size, _optionType) {
828
+ if (aggressorSide === 'unknown')
829
+ return 0;
830
+ return aggressorSide === 'buy' ? size : -size;
831
+ }
832
+ /**
833
+ * Calculates live open interest.
834
+ */
835
+ calculateLiveOpenInterest(occSymbol) {
836
+ const baseOI = this.baseOpenInterest.get(occSymbol) ?? 0;
837
+ const cumulativeChange = this.cumulativeOIChange.get(occSymbol) ?? 0;
838
+ return Math.max(0, baseOI + cumulativeChange);
839
+ }
840
+ /**
841
+ * Checks if a symbol is an option symbol.
842
+ */
843
+ isOptionSymbol(symbol) {
844
+ return TS_OPTION_PATTERN.test(symbol) || OCC_OPTION_PATTERN.test(symbol);
845
+ }
846
+ /**
847
+ * Parses a numeric string value.
848
+ */
849
+ parseNumber(value) {
850
+ if (value === undefined || value === null)
851
+ return 0;
852
+ if (typeof value === 'number')
853
+ return isNaN(value) ? 0 : value;
854
+ const num = parseFloat(value);
855
+ return isNaN(num) ? 0 : num;
856
+ }
857
+ /**
858
+ * Emits an event to all listeners.
859
+ */
860
+ emit(event, data) {
861
+ const listeners = this.eventListeners.get(event);
862
+ if (listeners) {
863
+ listeners.forEach(listener => {
864
+ try {
865
+ listener(data);
866
+ }
867
+ catch (error) {
868
+ console.error('Event listener error:', error);
869
+ }
870
+ });
871
+ }
872
+ }
873
+ /**
874
+ * Sleep utility.
875
+ */
876
+ sleep(ms) {
877
+ return new Promise(resolve => setTimeout(resolve, ms));
878
+ }
879
+ }
880
+ exports.TradeStationClient = TradeStationClient;