@fullstackcraftllc/floe 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,509 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BaseBrokerClient = exports.OCC_OPTION_PATTERN_WITH_SPACES = exports.OCC_OPTION_PATTERN = void 0;
4
+ /**
5
+ * Regex pattern to identify OCC option symbols (compact format)
6
+ * Format: ROOT + YYMMDD + C/P + 8-digit strike
7
+ * Example: "AAPL240517C00170000"
8
+ */
9
+ exports.OCC_OPTION_PATTERN = /^[A-Z]{1,6}\d{6}[CP]\d{8}$/;
10
+ /**
11
+ * Regex pattern that also matches space-padded OCC symbols
12
+ * Example: "AAPL 240517C00170000"
13
+ */
14
+ exports.OCC_OPTION_PATTERN_WITH_SPACES = /^.{1,6}\s*\d{6}[CP]\d{8}$/;
15
+ /**
16
+ * Abstract base class for all broker streaming clients.
17
+ *
18
+ * @remarks
19
+ * This class provides shared state management, event handling, and utility methods
20
+ * that are common across all broker implementations. Subclasses implement the
21
+ * broker-specific connection, subscription, and data parsing logic.
22
+ *
23
+ * All broker clients normalize their data to `NormalizedOption` and `NormalizedTicker`
24
+ * formats, providing a consistent interface regardless of the underlying broker API.
25
+ */
26
+ class BaseBrokerClient {
27
+ // ==================== Constructor ====================
28
+ constructor(options = {}) {
29
+ // ==================== Shared State ====================
30
+ /** Cached ticker data */
31
+ this.tickerCache = new Map();
32
+ /** Cached option data */
33
+ this.optionCache = new Map();
34
+ /** Base open interest from REST API - used as t=0 reference */
35
+ this.baseOpenInterest = new Map();
36
+ /** Cumulative estimated OI change from intraday trades */
37
+ this.cumulativeOIChange = new Map();
38
+ /** History of intraday trades with aggressor classification */
39
+ this.intradayTrades = new Map();
40
+ /** Event listeners */
41
+ this.eventListeners = new Map();
42
+ /** Currently subscribed symbols */
43
+ this.subscribedSymbols = new Set();
44
+ /** Reconnection attempt counter */
45
+ this.reconnectAttempts = 0;
46
+ this.verbose = options.verbose ?? false;
47
+ this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
48
+ this.baseReconnectDelay = options.baseReconnectDelay ?? 1000;
49
+ // Initialize event listener maps
50
+ this.eventListeners.set('tickerUpdate', new Set());
51
+ this.eventListeners.set('optionUpdate', new Set());
52
+ this.eventListeners.set('optionTrade', new Set());
53
+ this.eventListeners.set('connected', new Set());
54
+ this.eventListeners.set('disconnected', new Set());
55
+ this.eventListeners.set('error', new Set());
56
+ }
57
+ // ==================== Concrete Public Methods ====================
58
+ /**
59
+ * Returns cached option data for a symbol.
60
+ * @param occSymbol - OCC option symbol
61
+ */
62
+ getOption(occSymbol) {
63
+ return this.optionCache.get(this.normalizeOccSymbol(occSymbol));
64
+ }
65
+ /**
66
+ * Returns all cached options.
67
+ */
68
+ getAllOptions() {
69
+ return new Map(this.optionCache);
70
+ }
71
+ /**
72
+ * Returns cached ticker data for a symbol.
73
+ * @param symbol - Ticker symbol
74
+ */
75
+ getTicker(symbol) {
76
+ return this.tickerCache.get(symbol);
77
+ }
78
+ /**
79
+ * Returns all cached tickers.
80
+ */
81
+ getAllTickers() {
82
+ return new Map(this.tickerCache);
83
+ }
84
+ /**
85
+ * Returns intraday trades for an option.
86
+ * @param occSymbol - OCC option symbol
87
+ */
88
+ getIntradayTrades(occSymbol) {
89
+ return this.intradayTrades.get(this.normalizeOccSymbol(occSymbol)) ?? [];
90
+ }
91
+ /**
92
+ * Returns flow summary statistics for an option.
93
+ * @param occSymbol - OCC option symbol
94
+ */
95
+ getFlowSummary(occSymbol) {
96
+ const normalizedSymbol = this.normalizeOccSymbol(occSymbol);
97
+ const trades = this.intradayTrades.get(normalizedSymbol) ?? [];
98
+ let buyVolume = 0;
99
+ let sellVolume = 0;
100
+ let unknownVolume = 0;
101
+ for (const trade of trades) {
102
+ switch (trade.aggressorSide) {
103
+ case 'buy':
104
+ buyVolume += trade.size;
105
+ break;
106
+ case 'sell':
107
+ sellVolume += trade.size;
108
+ break;
109
+ case 'unknown':
110
+ unknownVolume += trade.size;
111
+ break;
112
+ }
113
+ }
114
+ return {
115
+ buyVolume,
116
+ sellVolume,
117
+ unknownVolume,
118
+ netOIChange: this.cumulativeOIChange.get(normalizedSymbol) ?? 0,
119
+ tradeCount: trades.length,
120
+ };
121
+ }
122
+ /**
123
+ * Resets intraday tracking data.
124
+ * @param occSymbols - Optional specific symbols to reset. If not provided, resets all.
125
+ */
126
+ resetIntradayData(occSymbols) {
127
+ const symbolsToReset = occSymbols?.map(s => this.normalizeOccSymbol(s))
128
+ ?? Array.from(this.intradayTrades.keys());
129
+ for (const symbol of symbolsToReset) {
130
+ this.intradayTrades.delete(symbol);
131
+ this.cumulativeOIChange.set(symbol, 0);
132
+ }
133
+ }
134
+ /**
135
+ * Registers an event listener.
136
+ * @param event - Event type to listen for
137
+ * @param listener - Callback function
138
+ */
139
+ on(event, listener) {
140
+ const listeners = this.eventListeners.get(event);
141
+ if (listeners) {
142
+ listeners.add(listener);
143
+ }
144
+ return this;
145
+ }
146
+ /**
147
+ * Removes an event listener.
148
+ * @param event - Event type
149
+ * @param listener - Callback function to remove
150
+ */
151
+ off(event, listener) {
152
+ const listeners = this.eventListeners.get(event);
153
+ if (listeners) {
154
+ listeners.delete(listener);
155
+ }
156
+ return this;
157
+ }
158
+ // ==================== Protected Helpers ====================
159
+ /**
160
+ * Determines the aggressor side of a trade by comparing trade price to NBBO.
161
+ *
162
+ * @param tradePrice - The executed trade price
163
+ * @param bid - The bid price at time of trade
164
+ * @param ask - The ask price at time of trade
165
+ * @returns The aggressor side: 'buy' if lifting offer, 'sell' if hitting bid, 'unknown' if mid
166
+ *
167
+ * @remarks
168
+ * The aggressor is the party that initiated the trade by crossing the spread:
169
+ * - Buy aggressor: Buyer lifts the offer (trades at or above ask) → bullish intent
170
+ * - Sell aggressor: Seller hits the bid (trades at or below bid) → bearish intent
171
+ * - Unknown: Trade occurred mid-market (could be internalized, crossed, or negotiated)
172
+ */
173
+ determineAggressorSide(tradePrice, bid, ask) {
174
+ if (bid <= 0 || ask <= 0)
175
+ return 'unknown';
176
+ const spread = ask - bid;
177
+ const tolerance = spread > 0 ? spread * 0.001 : 0.001;
178
+ if (tradePrice >= ask - tolerance) {
179
+ return 'buy';
180
+ }
181
+ else if (tradePrice <= bid + tolerance) {
182
+ return 'sell';
183
+ }
184
+ return 'unknown';
185
+ }
186
+ /**
187
+ * Calculates the estimated open interest change from a single trade.
188
+ *
189
+ * @param aggressorSide - The aggressor side of the trade
190
+ * @param size - Number of contracts traded
191
+ * @returns Estimated OI change (positive = OI increase, negative = OI decrease)
192
+ *
193
+ * @remarks
194
+ * This uses a simplified heuristic:
195
+ * - Buy aggressor → typically opening new long positions → +OI
196
+ * - Sell aggressor → typically closing longs or opening shorts → -OI
197
+ * - Unknown → ambiguous, assume neutral impact
198
+ */
199
+ calculateOIChangeFromTrade(aggressorSide, size) {
200
+ if (aggressorSide === 'unknown')
201
+ return 0;
202
+ return aggressorSide === 'buy' ? size : -size;
203
+ }
204
+ /**
205
+ * Calculates the live (intraday) open interest estimate for an option.
206
+ *
207
+ * @param occSymbol - OCC option symbol
208
+ * @returns Live OI estimate = base OI + cumulative estimated changes
209
+ */
210
+ calculateLiveOpenInterest(occSymbol) {
211
+ const baseOI = this.baseOpenInterest.get(occSymbol) ?? 0;
212
+ const cumulativeChange = this.cumulativeOIChange.get(occSymbol) ?? 0;
213
+ return Math.max(0, baseOI + cumulativeChange);
214
+ }
215
+ /**
216
+ * Records a trade and updates OI tracking.
217
+ */
218
+ recordTrade(occSymbol, price, size, bid, ask, timestamp, optionType) {
219
+ const aggressorSide = this.determineAggressorSide(price, bid, ask);
220
+ const estimatedOIChange = this.calculateOIChangeFromTrade(aggressorSide, size);
221
+ const currentChange = this.cumulativeOIChange.get(occSymbol) ?? 0;
222
+ this.cumulativeOIChange.set(occSymbol, currentChange + estimatedOIChange);
223
+ if (this.verbose && estimatedOIChange !== 0) {
224
+ const baseOI = this.baseOpenInterest.get(occSymbol) ?? 0;
225
+ const newLiveOI = Math.max(0, baseOI + currentChange + estimatedOIChange);
226
+ console.log(`[${this.brokerName}:OI] ${occSymbol} trade: price=${price.toFixed(2)}, size=${size}, ` +
227
+ `aggressor=${aggressorSide}, OI change=${estimatedOIChange > 0 ? '+' : ''}${estimatedOIChange}, ` +
228
+ `liveOI=${newLiveOI} (base=${baseOI}, cumulative=${currentChange + estimatedOIChange})`);
229
+ }
230
+ const trade = {
231
+ occSymbol,
232
+ price,
233
+ size,
234
+ bid,
235
+ ask,
236
+ aggressorSide,
237
+ timestamp,
238
+ estimatedOIChange,
239
+ };
240
+ if (!this.intradayTrades.has(occSymbol)) {
241
+ this.intradayTrades.set(occSymbol, []);
242
+ }
243
+ this.intradayTrades.get(occSymbol).push(trade);
244
+ this.emit('optionTrade', trade);
245
+ }
246
+ /**
247
+ * Sets base open interest for a symbol.
248
+ */
249
+ setBaseOpenInterest(occSymbol, openInterest) {
250
+ if (openInterest > 0 && !this.baseOpenInterest.has(occSymbol)) {
251
+ this.baseOpenInterest.set(occSymbol, openInterest);
252
+ if (!this.cumulativeOIChange.has(occSymbol)) {
253
+ this.cumulativeOIChange.set(occSymbol, 0);
254
+ }
255
+ if (this.verbose) {
256
+ console.log(`[${this.brokerName}:OI] Base OI set for ${occSymbol}: ${openInterest}`);
257
+ }
258
+ }
259
+ }
260
+ /**
261
+ * Emits an event to all registered listeners.
262
+ */
263
+ emit(event, data) {
264
+ const listeners = this.eventListeners.get(event);
265
+ if (listeners) {
266
+ listeners.forEach(listener => {
267
+ try {
268
+ listener(data);
269
+ }
270
+ catch (error) {
271
+ console.error(`[${this.brokerName}] Event listener error:`, error);
272
+ }
273
+ });
274
+ }
275
+ }
276
+ /**
277
+ * Normalizes an OCC symbol to consistent format.
278
+ * Removes extra spaces, ensures proper formatting.
279
+ */
280
+ normalizeOccSymbol(symbol) {
281
+ const stripped = symbol.replace(/\s+/g, '');
282
+ const match = stripped.match(/^([A-Z]+)(\d{6})([CP])(\d{8})$/);
283
+ if (match) {
284
+ return `${match[1]}${match[2]}${match[3]}${match[4]}`;
285
+ }
286
+ return stripped;
287
+ }
288
+ /**
289
+ * Checks if a symbol is an OCC option symbol.
290
+ */
291
+ isOptionSymbol(symbol) {
292
+ const normalized = symbol.replace(/\s+/g, '');
293
+ return exports.OCC_OPTION_PATTERN.test(normalized) || /\d{6}[CP]\d{8}/.test(normalized);
294
+ }
295
+ /**
296
+ * Converts value to number, handling NaN and null.
297
+ */
298
+ toNumber(value) {
299
+ if (value === null || value === undefined)
300
+ return 0;
301
+ if (typeof value === 'number')
302
+ return isNaN(value) ? 0 : value;
303
+ if (typeof value === 'string') {
304
+ const num = parseFloat(value);
305
+ return isNaN(num) ? 0 : num;
306
+ }
307
+ return 0;
308
+ }
309
+ /**
310
+ * Sleep utility for delays and reconnection backoff.
311
+ */
312
+ sleep(ms) {
313
+ return new Promise(resolve => setTimeout(resolve, ms));
314
+ }
315
+ /**
316
+ * Calculates reconnection delay with exponential backoff.
317
+ */
318
+ getReconnectDelay() {
319
+ return this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
320
+ }
321
+ /**
322
+ * Logs a message if verbose mode is enabled.
323
+ */
324
+ log(message) {
325
+ if (this.verbose) {
326
+ console.log(`[${this.brokerName}] ${message}`);
327
+ }
328
+ }
329
+ // ==================== Common Update Helpers ====================
330
+ /**
331
+ * Updates or creates a ticker from quote data (bid/ask update).
332
+ * @returns The updated ticker
333
+ */
334
+ updateTickerFromQuoteData(symbol, bid, bidSize, ask, askSize, timestamp) {
335
+ const existing = this.tickerCache.get(symbol);
336
+ const ticker = {
337
+ symbol,
338
+ spot: bid > 0 && ask > 0 ? (bid + ask) / 2 : existing?.spot ?? 0,
339
+ bid,
340
+ bidSize,
341
+ ask,
342
+ askSize,
343
+ last: existing?.last ?? 0,
344
+ volume: existing?.volume ?? 0,
345
+ timestamp,
346
+ };
347
+ this.tickerCache.set(symbol, ticker);
348
+ this.emit('tickerUpdate', ticker);
349
+ return ticker;
350
+ }
351
+ /**
352
+ * Updates or creates a ticker from trade data (last price/volume update).
353
+ * @returns The updated ticker
354
+ */
355
+ updateTickerFromTradeData(symbol, price, size, dayVolume, timestamp) {
356
+ const existing = this.tickerCache.get(symbol);
357
+ const ticker = {
358
+ symbol,
359
+ spot: existing?.spot ?? price,
360
+ bid: existing?.bid ?? 0,
361
+ bidSize: existing?.bidSize ?? 0,
362
+ ask: existing?.ask ?? 0,
363
+ askSize: existing?.askSize ?? 0,
364
+ last: price,
365
+ volume: dayVolume !== null ? dayVolume : (existing?.volume ?? 0) + size,
366
+ timestamp,
367
+ };
368
+ this.tickerCache.set(symbol, ticker);
369
+ this.emit('tickerUpdate', ticker);
370
+ return ticker;
371
+ }
372
+ /**
373
+ * Updates or creates an option from quote data (bid/ask update).
374
+ * @returns The updated option, or null if symbol cannot be parsed
375
+ */
376
+ updateOptionFromQuoteData(occSymbol, bid, bidSize, ask, askSize, timestamp, parseSymbolFn) {
377
+ const existing = this.optionCache.get(occSymbol);
378
+ let parsed;
379
+ try {
380
+ parsed = parseSymbolFn(occSymbol);
381
+ }
382
+ catch {
383
+ if (!existing)
384
+ return null;
385
+ parsed = {
386
+ symbol: existing.underlying,
387
+ expiration: new Date(existing.expirationTimestamp),
388
+ optionType: existing.optionType,
389
+ strike: existing.strike,
390
+ };
391
+ }
392
+ const option = {
393
+ occSymbol,
394
+ underlying: parsed.symbol,
395
+ strike: parsed.strike,
396
+ expiration: parsed.expiration.toISOString().split('T')[0],
397
+ expirationTimestamp: parsed.expiration.getTime(),
398
+ optionType: parsed.optionType,
399
+ bid,
400
+ bidSize,
401
+ ask,
402
+ askSize,
403
+ mark: bid > 0 && ask > 0 ? (bid + ask) / 2 : existing?.mark ?? 0,
404
+ last: existing?.last ?? 0,
405
+ volume: existing?.volume ?? 0,
406
+ openInterest: existing?.openInterest ?? 0,
407
+ liveOpenInterest: this.calculateLiveOpenInterest(occSymbol),
408
+ impliedVolatility: existing?.impliedVolatility ?? 0,
409
+ timestamp,
410
+ };
411
+ this.optionCache.set(occSymbol, option);
412
+ this.emit('optionUpdate', option);
413
+ return option;
414
+ }
415
+ /**
416
+ * Updates or creates an option from trade data, including OI tracking.
417
+ * @returns The updated option, or null if symbol cannot be parsed
418
+ */
419
+ updateOptionFromTradeData(occSymbol, price, size, dayVolume, timestamp, parseSymbolFn) {
420
+ const existing = this.optionCache.get(occSymbol);
421
+ let parsed;
422
+ try {
423
+ parsed = parseSymbolFn(occSymbol);
424
+ }
425
+ catch {
426
+ if (!existing)
427
+ return null;
428
+ parsed = {
429
+ symbol: existing.underlying,
430
+ expiration: new Date(existing.expirationTimestamp),
431
+ optionType: existing.optionType,
432
+ strike: existing.strike,
433
+ };
434
+ }
435
+ const bid = existing?.bid ?? 0;
436
+ const ask = existing?.ask ?? 0;
437
+ // Record trade for OI tracking
438
+ this.recordTrade(occSymbol, price, size, bid, ask, timestamp, parsed.optionType);
439
+ const option = {
440
+ occSymbol,
441
+ underlying: parsed.symbol,
442
+ strike: parsed.strike,
443
+ expiration: parsed.expiration.toISOString().split('T')[0],
444
+ expirationTimestamp: parsed.expiration.getTime(),
445
+ optionType: parsed.optionType,
446
+ bid,
447
+ bidSize: existing?.bidSize ?? 0,
448
+ ask,
449
+ askSize: existing?.askSize ?? 0,
450
+ mark: bid > 0 && ask > 0 ? (bid + ask) / 2 : price,
451
+ last: price,
452
+ volume: dayVolume !== null ? dayVolume : (existing?.volume ?? 0) + size,
453
+ openInterest: existing?.openInterest ?? 0,
454
+ liveOpenInterest: this.calculateLiveOpenInterest(occSymbol),
455
+ impliedVolatility: existing?.impliedVolatility ?? 0,
456
+ timestamp,
457
+ };
458
+ this.optionCache.set(occSymbol, option);
459
+ this.emit('optionUpdate', option);
460
+ return option;
461
+ }
462
+ /**
463
+ * Updates or creates an option from timesale data (trade with bid/ask at time of sale).
464
+ * This is particularly useful for live OI tracking.
465
+ * @returns The updated option, or null if symbol cannot be parsed
466
+ */
467
+ updateOptionFromTimesaleData(occSymbol, price, size, bid, ask, timestamp, parseSymbolFn) {
468
+ const existing = this.optionCache.get(occSymbol);
469
+ let parsed;
470
+ try {
471
+ parsed = parseSymbolFn(occSymbol);
472
+ }
473
+ catch {
474
+ if (!existing)
475
+ return null;
476
+ parsed = {
477
+ symbol: existing.underlying,
478
+ expiration: new Date(existing.expirationTimestamp),
479
+ optionType: existing.optionType,
480
+ strike: existing.strike,
481
+ };
482
+ }
483
+ // Record trade for OI tracking (timesale has fresh bid/ask)
484
+ this.recordTrade(occSymbol, price, size, bid, ask, timestamp, parsed.optionType);
485
+ const option = {
486
+ occSymbol,
487
+ underlying: parsed.symbol,
488
+ strike: parsed.strike,
489
+ expiration: parsed.expiration.toISOString().split('T')[0],
490
+ expirationTimestamp: parsed.expiration.getTime(),
491
+ optionType: parsed.optionType,
492
+ bid,
493
+ bidSize: existing?.bidSize ?? 0,
494
+ ask,
495
+ askSize: existing?.askSize ?? 0,
496
+ mark: (bid + ask) / 2,
497
+ last: price,
498
+ volume: (existing?.volume ?? 0) + size,
499
+ openInterest: existing?.openInterest ?? 0,
500
+ liveOpenInterest: this.calculateLiveOpenInterest(occSymbol),
501
+ impliedVolatility: existing?.impliedVolatility ?? 0,
502
+ timestamp,
503
+ };
504
+ this.optionCache.set(occSymbol, option);
505
+ this.emit('optionUpdate', option);
506
+ return option;
507
+ }
508
+ }
509
+ exports.BaseBrokerClient = BaseBrokerClient;