@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.
- package/README.md +8 -8
- package/dist/client/FloeClient.d.ts +5 -1
- package/dist/client/FloeClient.js +54 -0
- package/dist/client/brokers/IBKRClient.d.ts +324 -0
- package/dist/client/brokers/IBKRClient.js +797 -0
- package/dist/client/brokers/TradierClient.js +7 -6
- package/dist/hedgeflow/charm.d.ts +23 -0
- package/dist/hedgeflow/charm.js +113 -0
- package/dist/hedgeflow/curve.d.ts +27 -0
- package/dist/hedgeflow/curve.js +315 -0
- package/dist/hedgeflow/index.d.ts +33 -0
- package/dist/hedgeflow/index.js +52 -0
- package/dist/hedgeflow/regime.d.ts +7 -0
- package/dist/hedgeflow/regime.js +99 -0
- package/dist/hedgeflow/types.d.ts +185 -0
- package/dist/hedgeflow/types.js +2 -0
- package/dist/impliedpdf/adjusted.d.ts +173 -0
- package/dist/impliedpdf/adjusted.js +500 -0
- package/dist/impliedpdf/index.d.ts +1 -0
- package/dist/impliedpdf/index.js +10 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +27 -1
- package/dist/iv/index.d.ts +52 -0
- package/dist/iv/index.js +287 -0
- package/dist/iv/types.d.ts +40 -0
- package/dist/iv/types.js +2 -0
- package/dist/pressure/grid.d.ts +14 -0
- package/dist/pressure/grid.js +220 -0
- package/dist/pressure/index.d.ts +5 -0
- package/dist/pressure/index.js +22 -0
- package/dist/pressure/ivpath.d.ts +31 -0
- package/dist/pressure/ivpath.js +304 -0
- package/dist/pressure/normalize.d.ts +6 -0
- package/dist/pressure/normalize.js +76 -0
- package/dist/pressure/regime.d.ts +7 -0
- package/dist/pressure/regime.js +99 -0
- package/dist/pressure/types.d.ts +182 -0
- package/dist/pressure/types.js +2 -0
- package/dist/rv/index.d.ts +26 -0
- package/dist/rv/index.js +81 -0
- package/dist/rv/types.d.ts +32 -0
- package/dist/rv/types.js +2 -0
- package/dist/utils/indexOptions.js +2 -1
- 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}§ype=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}§ype=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;
|