@fullstackcraftllc/floe 0.0.13 → 0.0.15

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.
Files changed (44) hide show
  1. package/README.md +8 -8
  2. package/dist/client/FloeClient.d.ts +5 -1
  3. package/dist/client/FloeClient.js +54 -0
  4. package/dist/client/brokers/IBKRClient.d.ts +324 -0
  5. package/dist/client/brokers/IBKRClient.js +797 -0
  6. package/dist/client/brokers/TradierClient.js +7 -6
  7. package/dist/hedgeflow/charm.d.ts +23 -0
  8. package/dist/hedgeflow/charm.js +113 -0
  9. package/dist/hedgeflow/curve.d.ts +27 -0
  10. package/dist/hedgeflow/curve.js +315 -0
  11. package/dist/hedgeflow/index.d.ts +33 -0
  12. package/dist/hedgeflow/index.js +52 -0
  13. package/dist/hedgeflow/regime.d.ts +7 -0
  14. package/dist/hedgeflow/regime.js +99 -0
  15. package/dist/hedgeflow/types.d.ts +185 -0
  16. package/dist/hedgeflow/types.js +2 -0
  17. package/dist/impliedpdf/adjusted.d.ts +173 -0
  18. package/dist/impliedpdf/adjusted.js +500 -0
  19. package/dist/impliedpdf/index.d.ts +1 -0
  20. package/dist/impliedpdf/index.js +10 -0
  21. package/dist/index.d.ts +8 -2
  22. package/dist/index.js +27 -1
  23. package/dist/iv/index.d.ts +52 -0
  24. package/dist/iv/index.js +287 -0
  25. package/dist/iv/types.d.ts +40 -0
  26. package/dist/iv/types.js +2 -0
  27. package/dist/pressure/grid.d.ts +14 -0
  28. package/dist/pressure/grid.js +220 -0
  29. package/dist/pressure/index.d.ts +5 -0
  30. package/dist/pressure/index.js +22 -0
  31. package/dist/pressure/ivpath.d.ts +31 -0
  32. package/dist/pressure/ivpath.js +304 -0
  33. package/dist/pressure/normalize.d.ts +6 -0
  34. package/dist/pressure/normalize.js +76 -0
  35. package/dist/pressure/regime.d.ts +7 -0
  36. package/dist/pressure/regime.js +99 -0
  37. package/dist/pressure/types.d.ts +182 -0
  38. package/dist/pressure/types.js +2 -0
  39. package/dist/rv/index.d.ts +26 -0
  40. package/dist/rv/index.js +81 -0
  41. package/dist/rv/types.d.ts +32 -0
  42. package/dist/rv/types.js +2 -0
  43. package/dist/utils/indexOptions.js +2 -1
  44. package/package.json +1 -1
@@ -0,0 +1,797 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IBKRClient = exports.IBKR_FIELD_TAGS = void 0;
4
+ const indexOptions_1 = require("../../utils/indexOptions");
5
+ const occ_1 = require("../../utils/occ");
6
+ const BaseBrokerClient_1 = require("./BaseBrokerClient");
7
+ // ==================== IBKR Market Data Field Tags ====================
8
+ // Reference: https://www.interactivebrokers.com/campus/ibkr-api-page/webapi-ref/#tag/Market-Data
9
+ /**
10
+ * IBKR market data field tags for snapshot and streaming requests
11
+ */
12
+ exports.IBKR_FIELD_TAGS = {
13
+ // Price fields
14
+ LAST_PRICE: '31',
15
+ BID_PRICE: '84',
16
+ BID_SIZE: '85',
17
+ ASK_PRICE: '86',
18
+ ASK_SIZE: '88',
19
+ // Volume fields
20
+ VOLUME: '7059',
21
+ VOLUME_LONG: '7762',
22
+ // Option-specific fields
23
+ OPEN_INTEREST: '7089',
24
+ IMPLIED_VOLATILITY: '7283',
25
+ IMPLIED_VOLATILITY_PERCENT: '7284',
26
+ DELTA: '7308',
27
+ GAMMA: '7309',
28
+ THETA: '7310',
29
+ VEGA: '7311',
30
+ // Time and sales
31
+ LAST_SIZE: '32',
32
+ LAST_TIMESTAMP: '7295',
33
+ // Additional fields
34
+ OPEN: '7296',
35
+ HIGH: '7297',
36
+ LOW: '7298',
37
+ CLOSE: '7299',
38
+ CHANGE: '82',
39
+ CHANGE_PERCENT: '83',
40
+ // Contract info
41
+ UNDERLYING_CONID: '6457',
42
+ CONTRACT_DESC: '6509',
43
+ // Mark/Mid
44
+ MARK: '7219',
45
+ };
46
+ /**
47
+ * IBKRClient handles real-time streaming connections to the Interactive Brokers Web API.
48
+ *
49
+ * @remarks
50
+ * This client manages WebSocket connections to IBKR's streaming API,
51
+ * normalizes incoming quote and trade data, and emits events for upstream
52
+ * consumption by the FloeClient.
53
+ *
54
+ * IBKR uses contract IDs (conids) to identify instruments. This client maintains
55
+ * a mapping between OCC option symbols and IBKR conids for seamless integration.
56
+ *
57
+ * @example
58
+ * ```typescript
59
+ * // Using Client Portal Gateway (local)
60
+ * const client = new IBKRClient({
61
+ * baseUrl: 'https://localhost:5000/v1/api',
62
+ * rejectUnauthorized: false
63
+ * });
64
+ *
65
+ * // Using OAuth (direct API)
66
+ * const client = new IBKRClient({
67
+ * baseUrl: 'https://api.ibkr.com/v1/api',
68
+ * accessToken: 'your-oauth-token'
69
+ * });
70
+ *
71
+ * client.on('tickerUpdate', (ticker) => {
72
+ * console.log(`${ticker.symbol}: ${ticker.spot}`);
73
+ * });
74
+ *
75
+ * await client.connect();
76
+ * client.subscribe(['QQQ', 'AAPL240119C00500000']);
77
+ * ```
78
+ */
79
+ class IBKRClient extends BaseBrokerClient_1.BaseBrokerClient {
80
+ /**
81
+ * Creates a new IBKRClient instance.
82
+ *
83
+ * @param options - Client configuration options
84
+ */
85
+ constructor(options) {
86
+ super(options);
87
+ this.brokerName = 'IBKR';
88
+ /** WebSocket connection */
89
+ this.ws = null;
90
+ /** Connection state */
91
+ this.connected = false;
92
+ /**
93
+ * Mapping from OCC symbol to IBKR conid
94
+ * This is populated as options are discovered/subscribed
95
+ */
96
+ this.occToConid = new Map();
97
+ /**
98
+ * Mapping from IBKR conid to OCC symbol (reverse lookup)
99
+ */
100
+ this.conidToOcc = new Map();
101
+ /**
102
+ * Mapping from ticker symbol to IBKR conid (for underlyings)
103
+ */
104
+ this.symbolToConid = new Map();
105
+ /**
106
+ * Mapping from conid to ticker symbol (reverse lookup for underlyings)
107
+ */
108
+ this.conidToSymbol = new Map();
109
+ /**
110
+ * Set of conids that have been "primed" for market data
111
+ * (IBKR requires a pre-flight request before streaming)
112
+ */
113
+ this.primedConids = new Set();
114
+ this.baseUrl = options.baseUrl.replace(/\/$/, ''); // Remove trailing slash
115
+ this.accessToken = options.accessToken;
116
+ this.accountId = options.accountId;
117
+ this.rejectUnauthorized = options.rejectUnauthorized ?? true;
118
+ }
119
+ // ==================== Public API ====================
120
+ /**
121
+ * Establishes a connection to IBKR.
122
+ *
123
+ * @returns Promise that resolves when connected
124
+ * @throws {Error} If connection fails
125
+ */
126
+ async connect() {
127
+ // First, validate the brokerage session
128
+ await this.validateSession();
129
+ // Connect WebSocket for streaming
130
+ await this.connectWebSocket();
131
+ }
132
+ /**
133
+ * Disconnects from the IBKR API.
134
+ */
135
+ disconnect() {
136
+ if (this.ws) {
137
+ this.ws.close(1000, 'Client disconnect');
138
+ this.ws = null;
139
+ }
140
+ this.connected = false;
141
+ this.subscribedSymbols.clear();
142
+ this.primedConids.clear();
143
+ }
144
+ /**
145
+ * Subscribes to real-time updates for the specified symbols.
146
+ *
147
+ * @param symbols - Array of ticker symbols and/or OCC option symbols
148
+ */
149
+ async subscribe(symbols) {
150
+ // Separate options from underlyings
151
+ const optionSymbols = symbols.filter(s => this.isOptionSymbol(s));
152
+ const tickerSymbols = symbols.filter(s => !this.isOptionSymbol(s));
153
+ // Resolve conids for all symbols
154
+ await this.resolveConids([...tickerSymbols], [...optionSymbols]);
155
+ // Add to tracked symbols
156
+ symbols.forEach(s => this.subscribedSymbols.add(s));
157
+ if (!this.connected || !this.ws) {
158
+ // Symbols queued for subscription when connected
159
+ return;
160
+ }
161
+ // Prime market data for all conids (IBKR requires this)
162
+ await this.primeMarketData(symbols);
163
+ // Subscribe via WebSocket
164
+ for (const symbol of symbols) {
165
+ const conid = this.getConidForSymbol(symbol);
166
+ if (conid) {
167
+ this.subscribeToConid(conid);
168
+ }
169
+ }
170
+ }
171
+ /**
172
+ * Unsubscribes from real-time updates for the specified symbols.
173
+ *
174
+ * @param symbols - Array of symbols to unsubscribe from
175
+ */
176
+ unsubscribe(symbols) {
177
+ symbols.forEach(s => this.subscribedSymbols.delete(s));
178
+ if (!this.connected || !this.ws) {
179
+ return;
180
+ }
181
+ for (const symbol of symbols) {
182
+ const conid = this.getConidForSymbol(symbol);
183
+ if (conid) {
184
+ this.unsubscribeFromConid(conid);
185
+ }
186
+ }
187
+ }
188
+ /**
189
+ * Unsubscribes from all symbols.
190
+ */
191
+ unsubscribeFromAll() {
192
+ const symbols = Array.from(this.subscribedSymbols);
193
+ this.unsubscribe(symbols);
194
+ }
195
+ /**
196
+ * Returns whether the client is currently connected.
197
+ */
198
+ isConnected() {
199
+ return this.connected;
200
+ }
201
+ /**
202
+ * Fetches open interest and other static data for subscribed options via REST API.
203
+ *
204
+ * @param occSymbols - Array of OCC option symbols to fetch data for
205
+ * @returns Promise that resolves when all data is fetched
206
+ */
207
+ async fetchOpenInterest(occSymbols) {
208
+ // For IBKR, we need to fetch market data snapshots to get open interest
209
+ const conids = [];
210
+ for (const occSymbol of occSymbols) {
211
+ const conid = this.occToConid.get(occSymbol);
212
+ if (conid) {
213
+ conids.push(conid);
214
+ }
215
+ else {
216
+ // Try to resolve the conid first
217
+ await this.resolveOptionConid(occSymbol);
218
+ const resolvedConid = this.occToConid.get(occSymbol);
219
+ if (resolvedConid) {
220
+ conids.push(resolvedConid);
221
+ }
222
+ }
223
+ }
224
+ if (conids.length === 0)
225
+ return;
226
+ // Fetch market data snapshots with open interest field
227
+ const fields = [
228
+ exports.IBKR_FIELD_TAGS.BID_PRICE,
229
+ exports.IBKR_FIELD_TAGS.BID_SIZE,
230
+ exports.IBKR_FIELD_TAGS.ASK_PRICE,
231
+ exports.IBKR_FIELD_TAGS.ASK_SIZE,
232
+ exports.IBKR_FIELD_TAGS.LAST_PRICE,
233
+ exports.IBKR_FIELD_TAGS.VOLUME,
234
+ exports.IBKR_FIELD_TAGS.OPEN_INTEREST,
235
+ exports.IBKR_FIELD_TAGS.IMPLIED_VOLATILITY,
236
+ ];
237
+ // Prime and fetch in batches (IBKR has limits)
238
+ const batchSize = 50;
239
+ for (let i = 0; i < conids.length; i += batchSize) {
240
+ const batch = conids.slice(i, i + batchSize);
241
+ // Prime first
242
+ await this.primeMarketDataByConids(batch, fields);
243
+ // Wait a moment for data to be ready
244
+ await this.sleep(250);
245
+ // Fetch snapshots
246
+ const snapshots = await this.fetchMarketDataSnapshots(batch, fields);
247
+ // Process snapshots
248
+ for (const snapshot of snapshots) {
249
+ this.processOptionSnapshot(snapshot);
250
+ }
251
+ }
252
+ }
253
+ /**
254
+ * Fetches options chain data for a given underlying.
255
+ *
256
+ * @param underlying - Underlying symbol (e.g., 'QQQ')
257
+ * @param expiration - Expiration date in YYYY-MM-DD format
258
+ * @returns Array of option contract info
259
+ */
260
+ async fetchOptionsChain(underlying, expiration) {
261
+ try {
262
+ // Step 1: Get underlying conid and available months
263
+ const underlyingConid = await this.resolveUnderlyingConid(underlying);
264
+ if (!underlyingConid) {
265
+ this.log(`Could not resolve conid for ${underlying}`);
266
+ return [];
267
+ }
268
+ // Convert expiration to MMMYY format (e.g., "2024-01-19" -> "JAN24")
269
+ const expDate = new Date(expiration + 'T12:00:00');
270
+ const monthNames = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN',
271
+ 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
272
+ const month = `${monthNames[expDate.getMonth()]}${expDate.getFullYear().toString().slice(-2)}`;
273
+ // Step 2: Get valid strikes
274
+ const strikesResponse = await this.makeRequest(`/iserver/secdef/strikes?conid=${underlyingConid}&sectype=OPT&month=${month}&exchange=SMART`);
275
+ if (!strikesResponse || !strikesResponse.call) {
276
+ this.log(`No strikes found for ${underlying} ${expiration}`);
277
+ return [];
278
+ }
279
+ const allStrikes = Array.from(new Set([...strikesResponse.call, ...strikesResponse.put]));
280
+ // Step 3: Fetch option contracts for each strike (batched)
281
+ const allOptions = [];
282
+ for (const strike of allStrikes) {
283
+ const options = await this.makeRequest(`/iserver/secdef/info?conid=${underlyingConid}&sectype=OPT&month=${month}&strike=${strike}&exchange=SMART`);
284
+ if (options && Array.isArray(options)) {
285
+ // Filter to exact expiration date
286
+ const filteredOptions = options.filter(opt => {
287
+ if (!opt.maturityDate)
288
+ return false;
289
+ // maturityDate format: YYYYMMDD
290
+ const optExpDate = opt.maturityDate;
291
+ const targetExpDate = expiration.replace(/-/g, '');
292
+ return optExpDate === targetExpDate;
293
+ });
294
+ for (const opt of filteredOptions) {
295
+ allOptions.push(opt);
296
+ // Build and store OCC symbol mapping
297
+ const occSymbol = this.ibkrOptionToOCC(opt);
298
+ if (occSymbol) {
299
+ this.occToConid.set(occSymbol, opt.conid);
300
+ this.conidToOcc.set(opt.conid, occSymbol);
301
+ }
302
+ }
303
+ }
304
+ }
305
+ return allOptions;
306
+ }
307
+ catch (error) {
308
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
309
+ return [];
310
+ }
311
+ }
312
+ /**
313
+ * Resolves an OCC option symbol to an IBKR conid.
314
+ *
315
+ * @param occSymbol - OCC option symbol
316
+ * @returns The IBKR conid, or null if not found
317
+ */
318
+ async resolveOptionConid(occSymbol) {
319
+ // Check cache first
320
+ const cached = this.occToConid.get(occSymbol);
321
+ if (cached)
322
+ return cached;
323
+ try {
324
+ // Parse the OCC symbol
325
+ const parsed = (0, occ_1.parseOCCSymbol)(occSymbol);
326
+ const underlying = (0, indexOptions_1.getUnderlyingFromOptionRoot)(parsed.symbol);
327
+ const expiration = parsed.expiration.toISOString().split('T')[0];
328
+ // Fetch the options chain for this expiration
329
+ await this.fetchOptionsChain(underlying, expiration);
330
+ // Check if we found it
331
+ return this.occToConid.get(occSymbol) ?? null;
332
+ }
333
+ catch (error) {
334
+ this.log(`Failed to resolve conid for ${occSymbol}: ${error}`);
335
+ return null;
336
+ }
337
+ }
338
+ /**
339
+ * Resolves an underlying symbol to an IBKR conid.
340
+ *
341
+ * @param symbol - Ticker symbol
342
+ * @returns The IBKR conid, or null if not found
343
+ */
344
+ async resolveUnderlyingConid(symbol) {
345
+ // Check cache first
346
+ const cached = this.symbolToConid.get(symbol);
347
+ if (cached)
348
+ return cached;
349
+ try {
350
+ // Search for the symbol
351
+ const results = await this.makeRequest(`/iserver/secdef/search?symbol=${encodeURIComponent(symbol)}&secType=STK`);
352
+ if (!results || results.length === 0) {
353
+ // Try as index
354
+ const indexResults = await this.makeRequest(`/iserver/secdef/search?symbol=${encodeURIComponent(symbol)}&secType=IND`);
355
+ if (!indexResults || indexResults.length === 0) {
356
+ this.log(`No results found for symbol ${symbol}`);
357
+ return null;
358
+ }
359
+ // Use the first matching result
360
+ const conid = parseInt(indexResults[0].conid, 10);
361
+ this.symbolToConid.set(symbol, conid);
362
+ this.conidToSymbol.set(conid, symbol);
363
+ return conid;
364
+ }
365
+ // Find the US-listed version (or first result)
366
+ const conid = parseInt(results[0].conid, 10);
367
+ this.symbolToConid.set(symbol, conid);
368
+ this.conidToSymbol.set(conid, symbol);
369
+ return conid;
370
+ }
371
+ catch (error) {
372
+ this.log(`Failed to resolve conid for ${symbol}: ${error}`);
373
+ return null;
374
+ }
375
+ }
376
+ // ==================== Private Methods ====================
377
+ /**
378
+ * Validates the brokerage session with IBKR.
379
+ */
380
+ async validateSession() {
381
+ try {
382
+ const response = await this.makeRequest('/sso/validate');
383
+ if (!response?.validated) {
384
+ throw new Error('IBKR session not validated. Please ensure you are logged in.');
385
+ }
386
+ this.log('Session validated');
387
+ }
388
+ catch (error) {
389
+ throw new Error(`Failed to validate IBKR session: ${error}`);
390
+ }
391
+ }
392
+ /**
393
+ * Connects to the IBKR WebSocket for streaming data.
394
+ */
395
+ connectWebSocket() {
396
+ return new Promise((resolve, reject) => {
397
+ // Derive WebSocket URL from REST API URL
398
+ const wsUrl = this.baseUrl
399
+ .replace('https://', 'wss://')
400
+ .replace('http://', 'ws://')
401
+ .replace('/v1/api', '/v1/api/ws');
402
+ this.ws = new WebSocket(wsUrl);
403
+ this.ws.onopen = () => {
404
+ this.connected = true;
405
+ this.reconnectAttempts = 0;
406
+ this.log('WebSocket connected');
407
+ this.emit('connected', undefined);
408
+ // Subscribe to any queued symbols
409
+ if (this.subscribedSymbols.size > 0) {
410
+ this.subscribe(Array.from(this.subscribedSymbols));
411
+ }
412
+ resolve();
413
+ };
414
+ this.ws.onmessage = (event) => {
415
+ this.handleWebSocketMessage(event.data);
416
+ };
417
+ this.ws.onclose = (event) => {
418
+ this.connected = false;
419
+ this.emit('disconnected', { reason: event.reason });
420
+ // Attempt reconnection if not a clean close
421
+ if (event.code !== 1000) {
422
+ this.attemptReconnect();
423
+ }
424
+ };
425
+ this.ws.onerror = (error) => {
426
+ this.emit('error', new Error('WebSocket error'));
427
+ reject(error);
428
+ };
429
+ });
430
+ }
431
+ /**
432
+ * Handles incoming WebSocket messages.
433
+ */
434
+ handleWebSocketMessage(data) {
435
+ try {
436
+ const message = JSON.parse(data);
437
+ // Check message type from topic
438
+ if (message.topic?.startsWith('smd+')) {
439
+ this.handleMarketDataMessage(message);
440
+ }
441
+ else if (message.topic === 'sts') {
442
+ // Status message
443
+ this.log(`Status: ${JSON.stringify(message)}`);
444
+ }
445
+ else if (message.topic === 'system') {
446
+ // System message
447
+ this.log(`System: ${JSON.stringify(message)}`);
448
+ }
449
+ }
450
+ catch (error) {
451
+ // Ignore parse errors for heartbeat/status messages
452
+ }
453
+ }
454
+ /**
455
+ * Handles streaming market data messages.
456
+ */
457
+ handleMarketDataMessage(message) {
458
+ const conid = message.conid;
459
+ const timestamp = message._updated ?? Date.now();
460
+ // Determine if this is an option or underlying
461
+ const occSymbol = this.conidToOcc.get(conid);
462
+ const tickerSymbol = this.conidToSymbol.get(conid);
463
+ if (occSymbol) {
464
+ this.updateOptionFromWSMessage(occSymbol, message, timestamp);
465
+ }
466
+ else if (tickerSymbol) {
467
+ this.updateTickerFromWSMessage(tickerSymbol, message, timestamp);
468
+ }
469
+ }
470
+ /**
471
+ * Updates option data from a WebSocket message.
472
+ */
473
+ updateOptionFromWSMessage(occSymbol, message, timestamp) {
474
+ const bid = this.toNumber(message[exports.IBKR_FIELD_TAGS.BID_PRICE]);
475
+ const bidSize = this.toNumber(message[exports.IBKR_FIELD_TAGS.BID_SIZE]);
476
+ const ask = this.toNumber(message[exports.IBKR_FIELD_TAGS.ASK_PRICE]);
477
+ const askSize = this.toNumber(message[exports.IBKR_FIELD_TAGS.ASK_SIZE]);
478
+ const last = this.toNumber(message[exports.IBKR_FIELD_TAGS.LAST_PRICE]);
479
+ const volume = this.toNumber(message[exports.IBKR_FIELD_TAGS.VOLUME]);
480
+ const openInterest = this.toNumber(message[exports.IBKR_FIELD_TAGS.OPEN_INTEREST]);
481
+ const iv = this.toNumber(message[exports.IBKR_FIELD_TAGS.IMPLIED_VOLATILITY]);
482
+ // Check if this is a trade update (has last price and last size)
483
+ const lastSize = this.toNumber(message[exports.IBKR_FIELD_TAGS.LAST_SIZE]);
484
+ const isTradeUpdate = lastSize > 0 && last > 0;
485
+ if (isTradeUpdate && bid > 0 && ask > 0) {
486
+ // Record the trade for OI tracking
487
+ this.recordTrade(occSymbol, last, lastSize, bid, ask, timestamp);
488
+ }
489
+ const existing = this.optionCache.get(occSymbol);
490
+ let parsed;
491
+ try {
492
+ parsed = (0, occ_1.parseOCCSymbol)(occSymbol);
493
+ }
494
+ catch {
495
+ if (!existing)
496
+ return;
497
+ parsed = {
498
+ symbol: existing.underlying,
499
+ expiration: new Date(existing.expirationTimestamp),
500
+ optionType: existing.optionType,
501
+ strike: existing.strike,
502
+ };
503
+ }
504
+ // Set base open interest if we have it and haven't set it yet
505
+ if (openInterest > 0) {
506
+ this.setBaseOpenInterest(occSymbol, openInterest);
507
+ }
508
+ const option = {
509
+ occSymbol,
510
+ underlying: parsed.symbol,
511
+ strike: parsed.strike,
512
+ expiration: parsed.expiration.toISOString().split('T')[0],
513
+ expirationTimestamp: parsed.expiration.getTime(),
514
+ optionType: parsed.optionType,
515
+ bid: bid || existing?.bid || 0,
516
+ bidSize: bidSize || existing?.bidSize || 0,
517
+ ask: ask || existing?.ask || 0,
518
+ askSize: askSize || existing?.askSize || 0,
519
+ mark: (bid && ask) ? (bid + ask) / 2 : existing?.mark || 0,
520
+ last: last || existing?.last || 0,
521
+ volume: volume || existing?.volume || 0,
522
+ openInterest: openInterest || existing?.openInterest || 0,
523
+ liveOpenInterest: this.calculateLiveOpenInterest(occSymbol),
524
+ impliedVolatility: iv || existing?.impliedVolatility || 0,
525
+ timestamp,
526
+ };
527
+ this.optionCache.set(occSymbol, option);
528
+ this.emit('optionUpdate', option);
529
+ }
530
+ /**
531
+ * Updates ticker data from a WebSocket message.
532
+ */
533
+ updateTickerFromWSMessage(symbol, message, timestamp) {
534
+ const existing = this.tickerCache.get(symbol);
535
+ const bid = this.toNumber(message[exports.IBKR_FIELD_TAGS.BID_PRICE]);
536
+ const bidSize = this.toNumber(message[exports.IBKR_FIELD_TAGS.BID_SIZE]);
537
+ const ask = this.toNumber(message[exports.IBKR_FIELD_TAGS.ASK_PRICE]);
538
+ const askSize = this.toNumber(message[exports.IBKR_FIELD_TAGS.ASK_SIZE]);
539
+ const last = this.toNumber(message[exports.IBKR_FIELD_TAGS.LAST_PRICE]);
540
+ const volume = this.toNumber(message[exports.IBKR_FIELD_TAGS.VOLUME]);
541
+ const ticker = {
542
+ symbol,
543
+ spot: (bid && ask) ? (bid + ask) / 2 : last || existing?.spot || 0,
544
+ bid: bid || existing?.bid || 0,
545
+ bidSize: bidSize || existing?.bidSize || 0,
546
+ ask: ask || existing?.ask || 0,
547
+ askSize: askSize || existing?.askSize || 0,
548
+ last: last || existing?.last || 0,
549
+ volume: volume || existing?.volume || 0,
550
+ timestamp,
551
+ };
552
+ this.tickerCache.set(symbol, ticker);
553
+ this.emit('tickerUpdate', ticker);
554
+ }
555
+ /**
556
+ * Processes an option snapshot response.
557
+ */
558
+ processOptionSnapshot(snapshot) {
559
+ const conid = snapshot.conid;
560
+ const occSymbol = this.conidToOcc.get(conid);
561
+ if (!occSymbol)
562
+ return;
563
+ const timestamp = snapshot._updated ?? Date.now();
564
+ const openInterest = this.toNumber(snapshot[exports.IBKR_FIELD_TAGS.OPEN_INTEREST]);
565
+ const iv = this.toNumber(snapshot[exports.IBKR_FIELD_TAGS.IMPLIED_VOLATILITY]);
566
+ const bid = this.toNumber(snapshot[exports.IBKR_FIELD_TAGS.BID_PRICE]);
567
+ const bidSize = this.toNumber(snapshot[exports.IBKR_FIELD_TAGS.BID_SIZE]);
568
+ const ask = this.toNumber(snapshot[exports.IBKR_FIELD_TAGS.ASK_PRICE]);
569
+ const askSize = this.toNumber(snapshot[exports.IBKR_FIELD_TAGS.ASK_SIZE]);
570
+ const last = this.toNumber(snapshot[exports.IBKR_FIELD_TAGS.LAST_PRICE]);
571
+ const volume = this.toNumber(snapshot[exports.IBKR_FIELD_TAGS.VOLUME]);
572
+ // Set base open interest for live OI tracking
573
+ if (openInterest > 0) {
574
+ this.setBaseOpenInterest(occSymbol, openInterest);
575
+ }
576
+ const existing = this.optionCache.get(occSymbol);
577
+ let parsed;
578
+ try {
579
+ parsed = (0, occ_1.parseOCCSymbol)(occSymbol);
580
+ }
581
+ catch {
582
+ if (!existing)
583
+ return;
584
+ parsed = {
585
+ symbol: existing.underlying,
586
+ expiration: new Date(existing.expirationTimestamp),
587
+ optionType: existing.optionType,
588
+ strike: existing.strike,
589
+ };
590
+ }
591
+ const option = {
592
+ occSymbol,
593
+ underlying: parsed.symbol,
594
+ strike: parsed.strike,
595
+ expiration: parsed.expiration.toISOString().split('T')[0],
596
+ expirationTimestamp: parsed.expiration.getTime(),
597
+ optionType: parsed.optionType,
598
+ bid: bid || existing?.bid || 0,
599
+ bidSize: bidSize || existing?.bidSize || 0,
600
+ ask: ask || existing?.ask || 0,
601
+ askSize: askSize || existing?.askSize || 0,
602
+ mark: (bid && ask) ? (bid + ask) / 2 : existing?.mark || 0,
603
+ last: last || existing?.last || 0,
604
+ volume: volume || existing?.volume || 0,
605
+ openInterest: openInterest || existing?.openInterest || 0,
606
+ liveOpenInterest: this.calculateLiveOpenInterest(occSymbol),
607
+ impliedVolatility: iv || existing?.impliedVolatility || 0,
608
+ timestamp,
609
+ };
610
+ this.optionCache.set(occSymbol, option);
611
+ this.emit('optionUpdate', option);
612
+ }
613
+ /**
614
+ * Sends a subscription message for a conid.
615
+ */
616
+ subscribeToConid(conid) {
617
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
618
+ return;
619
+ const fields = [
620
+ exports.IBKR_FIELD_TAGS.LAST_PRICE,
621
+ exports.IBKR_FIELD_TAGS.BID_PRICE,
622
+ exports.IBKR_FIELD_TAGS.BID_SIZE,
623
+ exports.IBKR_FIELD_TAGS.ASK_PRICE,
624
+ exports.IBKR_FIELD_TAGS.ASK_SIZE,
625
+ exports.IBKR_FIELD_TAGS.VOLUME,
626
+ exports.IBKR_FIELD_TAGS.OPEN_INTEREST,
627
+ exports.IBKR_FIELD_TAGS.IMPLIED_VOLATILITY,
628
+ exports.IBKR_FIELD_TAGS.LAST_SIZE,
629
+ ];
630
+ const message = `smd+${conid}+${JSON.stringify({ fields })}`;
631
+ this.ws.send(message);
632
+ this.log(`Subscribed to conid ${conid}`);
633
+ }
634
+ /**
635
+ * Sends an unsubscription message for a conid.
636
+ */
637
+ unsubscribeFromConid(conid) {
638
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
639
+ return;
640
+ const message = `umd+${conid}+{}`;
641
+ this.ws.send(message);
642
+ this.log(`Unsubscribed from conid ${conid}`);
643
+ }
644
+ /**
645
+ * Primes market data for the given symbols.
646
+ * IBKR requires a "pre-flight" snapshot request before streaming works.
647
+ */
648
+ async primeMarketData(symbols) {
649
+ const conids = [];
650
+ for (const symbol of symbols) {
651
+ const conid = this.getConidForSymbol(symbol);
652
+ if (conid && !this.primedConids.has(conid)) {
653
+ conids.push(conid);
654
+ }
655
+ }
656
+ if (conids.length === 0)
657
+ return;
658
+ await this.primeMarketDataByConids(conids);
659
+ }
660
+ /**
661
+ * Primes market data for specific conids.
662
+ */
663
+ async primeMarketDataByConids(conids, fields) {
664
+ const fieldsToUse = fields ?? [
665
+ exports.IBKR_FIELD_TAGS.LAST_PRICE,
666
+ exports.IBKR_FIELD_TAGS.BID_PRICE,
667
+ exports.IBKR_FIELD_TAGS.BID_SIZE,
668
+ exports.IBKR_FIELD_TAGS.ASK_PRICE,
669
+ exports.IBKR_FIELD_TAGS.ASK_SIZE,
670
+ exports.IBKR_FIELD_TAGS.VOLUME,
671
+ ];
672
+ try {
673
+ await this.makeRequest(`/iserver/marketdata/snapshot?conids=${conids.join(',')}&fields=${fieldsToUse.join(',')}`);
674
+ conids.forEach(c => this.primedConids.add(c));
675
+ this.log(`Primed market data for ${conids.length} conids`);
676
+ }
677
+ catch (error) {
678
+ this.log(`Failed to prime market data: ${error}`);
679
+ }
680
+ }
681
+ /**
682
+ * Fetches market data snapshots for given conids.
683
+ */
684
+ async fetchMarketDataSnapshots(conids, fields) {
685
+ try {
686
+ const response = await this.makeRequest(`/iserver/marketdata/snapshot?conids=${conids.join(',')}&fields=${fields.join(',')}`);
687
+ return response ?? [];
688
+ }
689
+ catch (error) {
690
+ this.log(`Failed to fetch market data snapshots: ${error}`);
691
+ return [];
692
+ }
693
+ }
694
+ /**
695
+ * Resolves conids for both ticker symbols and OCC option symbols.
696
+ */
697
+ async resolveConids(tickerSymbols, optionSymbols) {
698
+ // Resolve underlying conids
699
+ for (const symbol of tickerSymbols) {
700
+ if (!this.symbolToConid.has(symbol)) {
701
+ await this.resolveUnderlyingConid(symbol);
702
+ }
703
+ }
704
+ // Resolve option conids
705
+ for (const occSymbol of optionSymbols) {
706
+ if (!this.occToConid.has(occSymbol)) {
707
+ await this.resolveOptionConid(occSymbol);
708
+ }
709
+ }
710
+ }
711
+ /**
712
+ * Gets the conid for a symbol (either option or underlying).
713
+ */
714
+ getConidForSymbol(symbol) {
715
+ if (this.isOptionSymbol(symbol)) {
716
+ return this.occToConid.get(symbol);
717
+ }
718
+ return this.symbolToConid.get(symbol);
719
+ }
720
+ /**
721
+ * Converts an IBKR option info to OCC symbol format.
722
+ */
723
+ ibkrOptionToOCC(opt) {
724
+ try {
725
+ // maturityDate format: YYYYMMDD
726
+ if (!opt.maturityDate || opt.maturityDate.length !== 8)
727
+ return null;
728
+ const year = parseInt(opt.maturityDate.slice(0, 4), 10);
729
+ const month = parseInt(opt.maturityDate.slice(4, 6), 10) - 1;
730
+ const day = parseInt(opt.maturityDate.slice(6, 8), 10);
731
+ const expiration = new Date(year, month, day, 12, 0, 0);
732
+ const optionType = opt.right === 'C' ? 'call' : 'put';
733
+ return (0, occ_1.buildOCCSymbol)({
734
+ symbol: opt.symbol,
735
+ expiration,
736
+ optionType,
737
+ strike: opt.strike,
738
+ });
739
+ }
740
+ catch {
741
+ return null;
742
+ }
743
+ }
744
+ /**
745
+ * Makes an authenticated request to the IBKR API.
746
+ */
747
+ async makeRequest(endpoint, options) {
748
+ const url = `${this.baseUrl}${endpoint}`;
749
+ const headers = {
750
+ 'Accept': 'application/json',
751
+ 'Content-Type': 'application/json',
752
+ };
753
+ if (this.accessToken) {
754
+ headers['Authorization'] = `Bearer ${this.accessToken}`;
755
+ }
756
+ try {
757
+ const response = await fetch(url, {
758
+ ...options,
759
+ headers: {
760
+ ...headers,
761
+ ...options?.headers,
762
+ },
763
+ });
764
+ if (!response.ok) {
765
+ const errorText = await response.text();
766
+ this.log(`API error ${response.status}: ${errorText}`);
767
+ return null;
768
+ }
769
+ const data = await response.json();
770
+ return data;
771
+ }
772
+ catch (error) {
773
+ this.log(`Request failed: ${error}`);
774
+ return null;
775
+ }
776
+ }
777
+ /**
778
+ * Attempts to reconnect with exponential backoff.
779
+ */
780
+ async attemptReconnect() {
781
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
782
+ this.emit('error', new Error('Max reconnection attempts reached'));
783
+ return;
784
+ }
785
+ this.reconnectAttempts++;
786
+ const delay = this.getReconnectDelay();
787
+ this.log(`Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`);
788
+ await this.sleep(delay);
789
+ try {
790
+ await this.connect();
791
+ }
792
+ catch {
793
+ // Reconnect attempt failed, will try again via onclose
794
+ }
795
+ }
796
+ }
797
+ exports.IBKRClient = IBKRClient;