@fullstackcraftllc/floe 0.0.2 → 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.
- package/README.md +23 -1
- package/dist/client/FloeClient.d.ts +64 -5
- package/dist/client/FloeClient.js +134 -5
- package/dist/client/brokers/SchwabClient.d.ts +2 -0
- package/dist/client/brokers/SchwabClient.js +6 -0
- package/dist/client/brokers/TastyTradeClient.d.ts +384 -0
- package/dist/client/brokers/TastyTradeClient.js +1081 -0
- package/dist/client/brokers/TradierClient.d.ts +233 -1
- package/dist/client/brokers/TradierClient.js +435 -0
- package/dist/impliedpdf/index.d.ts +148 -0
- package/dist/impliedpdf/index.js +277 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -1
- package/dist/utils/occ.d.ts +1 -1
- package/dist/utils/occ.js +5 -4
- package/dist/volatility/index.d.ts +1 -1
- package/dist/volatility/index.js +1 -1
- package/package.json +2 -1
|
@@ -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;
|