@fullstackcraftllc/floe 0.0.1 → 0.0.3
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 +10 -210
- package/dist/adapters/index.js +64 -16
- package/dist/client/FloeClient.d.ts +411 -0
- package/dist/client/FloeClient.js +550 -0
- package/dist/client/brokers/SchwabClient.d.ts +2 -0
- package/dist/client/brokers/SchwabClient.js +6 -0
- package/dist/client/brokers/TradierClient.d.ts +393 -0
- package/dist/client/brokers/TradierClient.js +869 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +14 -1
- package/dist/types/index.d.ts +35 -0
- package/dist/utils/occ.d.ts +164 -0
- package/dist/utils/occ.js +203 -0
- package/package.json +3 -3
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TradierClient = void 0;
|
|
4
|
+
const occ_1 = require("../../utils/occ");
|
|
5
|
+
/**
|
|
6
|
+
* Regex pattern to identify OCC option symbols
|
|
7
|
+
* Matches both compact format (e.g., AAPL230120C00150000) and
|
|
8
|
+
* padded format (e.g., 'AAPL 230120C00150000')
|
|
9
|
+
* Pattern: 1-6 char root + YYMMDD + C/P + 8-digit strike
|
|
10
|
+
*/
|
|
11
|
+
const OCC_OPTION_PATTERN = /^.{1,6}\d{6}[CP]\d{8}$/;
|
|
12
|
+
/**
|
|
13
|
+
* TradierClient handles real-time streaming connections to the Tradier API.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* This client manages WebSocket connections to Tradier's streaming API,
|
|
17
|
+
* normalizes incoming quote and trade data, and emits events for upstream
|
|
18
|
+
* consumption by the FloeClient.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* const client = new TradierClient('your-api-key');
|
|
23
|
+
*
|
|
24
|
+
* client.on('tickerUpdate', (ticker) => {
|
|
25
|
+
* console.log(`${ticker.symbol}: ${ticker.spot}`);
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* await client.connect();
|
|
29
|
+
* client.subscribe(['QQQ', 'AAPL 240119C00500000']);
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
class TradierClient {
|
|
33
|
+
/**
|
|
34
|
+
* Creates a new TradierClient instance.
|
|
35
|
+
*
|
|
36
|
+
* @param authKey - Tradier API access token
|
|
37
|
+
*/
|
|
38
|
+
constructor(authKey) {
|
|
39
|
+
/** Current streaming session */
|
|
40
|
+
this.streamSession = null;
|
|
41
|
+
/** WebSocket connection */
|
|
42
|
+
this.ws = null;
|
|
43
|
+
/** Connection state */
|
|
44
|
+
this.connected = false;
|
|
45
|
+
/** Currently subscribed symbols (tickers and options) */
|
|
46
|
+
this.subscribedSymbols = new Set();
|
|
47
|
+
/** Cached ticker data (for merging quote and trade events) */
|
|
48
|
+
this.tickerCache = new Map();
|
|
49
|
+
/** Cached option data (for merging quote and trade events) */
|
|
50
|
+
this.optionCache = new Map();
|
|
51
|
+
/**
|
|
52
|
+
* Base open interest from REST API - used as t=0 reference for live OI calculation
|
|
53
|
+
* Key: OCC symbol, Value: open interest at start of day / time of fetch
|
|
54
|
+
*/
|
|
55
|
+
this.baseOpenInterest = new Map();
|
|
56
|
+
/**
|
|
57
|
+
* Cumulative estimated OI change from intraday trades
|
|
58
|
+
* Key: OCC symbol, Value: net estimated change (positive = more contracts opened)
|
|
59
|
+
*/
|
|
60
|
+
this.cumulativeOIChange = new Map();
|
|
61
|
+
/**
|
|
62
|
+
* History of intraday trades with aggressor classification
|
|
63
|
+
* Key: OCC symbol, Value: array of trades
|
|
64
|
+
*/
|
|
65
|
+
this.intradayTrades = new Map();
|
|
66
|
+
/** Event listeners */
|
|
67
|
+
this.eventListeners = new Map();
|
|
68
|
+
/** Reconnection attempt counter */
|
|
69
|
+
this.reconnectAttempts = 0;
|
|
70
|
+
/** Maximum reconnection attempts */
|
|
71
|
+
this.maxReconnectAttempts = 5;
|
|
72
|
+
/** Reconnection delay in ms (doubles with each attempt) */
|
|
73
|
+
this.baseReconnectDelay = 1000;
|
|
74
|
+
/** Tradier API base URL */
|
|
75
|
+
this.apiBaseUrl = 'https://api.tradier.com/v1';
|
|
76
|
+
/** Tradier WebSocket URL */
|
|
77
|
+
this.wsUrl = 'wss://ws.tradier.com/v1/markets/events';
|
|
78
|
+
this.authKey = authKey;
|
|
79
|
+
// Initialize event listener maps
|
|
80
|
+
this.eventListeners.set('tickerUpdate', new Set());
|
|
81
|
+
this.eventListeners.set('optionUpdate', new Set());
|
|
82
|
+
this.eventListeners.set('optionTrade', new Set());
|
|
83
|
+
this.eventListeners.set('connected', new Set());
|
|
84
|
+
this.eventListeners.set('disconnected', new Set());
|
|
85
|
+
this.eventListeners.set('error', new Set());
|
|
86
|
+
}
|
|
87
|
+
// ==================== Public API ====================
|
|
88
|
+
/**
|
|
89
|
+
* Establishes a streaming connection to Tradier.
|
|
90
|
+
*
|
|
91
|
+
* @returns Promise that resolves when connected
|
|
92
|
+
* @throws {Error} If session creation or WebSocket connection fails
|
|
93
|
+
*/
|
|
94
|
+
async connect() {
|
|
95
|
+
// Create streaming session
|
|
96
|
+
const session = await this.createStreamSession();
|
|
97
|
+
if (!session) {
|
|
98
|
+
throw new Error('Failed to create Tradier streaming session');
|
|
99
|
+
}
|
|
100
|
+
this.streamSession = session;
|
|
101
|
+
// Connect WebSocket
|
|
102
|
+
await this.connectWebSocket();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Disconnects from the Tradier streaming API.
|
|
106
|
+
*/
|
|
107
|
+
disconnect() {
|
|
108
|
+
if (this.ws) {
|
|
109
|
+
this.ws.close(1000, 'Client disconnect');
|
|
110
|
+
this.ws = null;
|
|
111
|
+
}
|
|
112
|
+
this.connected = false;
|
|
113
|
+
this.streamSession = null;
|
|
114
|
+
this.subscribedSymbols.clear();
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Subscribes to real-time updates for the specified symbols.
|
|
118
|
+
*
|
|
119
|
+
* @param symbols - Array of ticker symbols and/or OCC option symbols
|
|
120
|
+
*/
|
|
121
|
+
subscribe(symbols) {
|
|
122
|
+
if (!this.connected || !this.ws || !this.streamSession) {
|
|
123
|
+
// Queue symbols for subscription when connected
|
|
124
|
+
symbols.forEach(s => this.subscribedSymbols.add(s));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Add to tracked symbols
|
|
128
|
+
symbols.forEach(s => this.subscribedSymbols.add(s));
|
|
129
|
+
// Send subscription message
|
|
130
|
+
const payload = {
|
|
131
|
+
sessionid: this.streamSession.sessionid,
|
|
132
|
+
symbols: symbols,
|
|
133
|
+
};
|
|
134
|
+
this.ws.send(JSON.stringify(payload));
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Unsubscribes from real-time updates for the specified symbols.
|
|
138
|
+
*
|
|
139
|
+
* @param symbols - Array of symbols to unsubscribe from
|
|
140
|
+
*
|
|
141
|
+
* @remarks
|
|
142
|
+
* Note: Tradier's streaming API doesn't support unsubscription for individual
|
|
143
|
+
* symbols. This method removes them from local tracking. To fully unsubscribe,
|
|
144
|
+
* you would need to disconnect and reconnect with the new symbol list.
|
|
145
|
+
*/
|
|
146
|
+
unsubscribe(symbols) {
|
|
147
|
+
symbols.forEach(s => this.subscribedSymbols.delete(s));
|
|
148
|
+
// Note: Tradier doesn't support selective unsubscribe
|
|
149
|
+
// Would need to reconnect with new symbol list for full effect
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Returns whether the client is currently connected.
|
|
153
|
+
*/
|
|
154
|
+
isConnected() {
|
|
155
|
+
return this.connected;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Fetches options chain data from Tradier REST API.
|
|
159
|
+
*
|
|
160
|
+
* @param symbol - Underlying symbol (e.g., 'QQQ')
|
|
161
|
+
* @param expiration - Expiration date in YYYY-MM-DD format
|
|
162
|
+
* @param greeks - Whether to include Greeks data (default: true)
|
|
163
|
+
* @returns Array of option chain items, or empty array on failure
|
|
164
|
+
*/
|
|
165
|
+
async fetchOptionsChain(symbol, expiration, greeks = true) {
|
|
166
|
+
try {
|
|
167
|
+
const params = new URLSearchParams({
|
|
168
|
+
symbol,
|
|
169
|
+
expiration,
|
|
170
|
+
greeks: String(greeks),
|
|
171
|
+
});
|
|
172
|
+
const url = `${this.apiBaseUrl}/markets/options/chains?${params.toString()}`;
|
|
173
|
+
const response = await fetch(url, {
|
|
174
|
+
method: 'GET',
|
|
175
|
+
headers: {
|
|
176
|
+
'Authorization': `Bearer ${this.authKey}`,
|
|
177
|
+
'Accept': 'application/json',
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
// log raw response for debugging
|
|
181
|
+
const rawResponse = await response.clone().text();
|
|
182
|
+
console.log('Raw options chain response:', rawResponse);
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
this.emit('error', new Error(`Failed to fetch options chain: ${response.statusText}`));
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
const data = await response.json();
|
|
188
|
+
if (!data.options || !data.options.option) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
// Handle case where API returns single object instead of array
|
|
192
|
+
const options = Array.isArray(data.options.option)
|
|
193
|
+
? data.options.option
|
|
194
|
+
: [data.options.option];
|
|
195
|
+
return options;
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Fetches open interest and other static data for subscribed options via REST API.
|
|
204
|
+
* Call this after subscribing to options to populate open interest.
|
|
205
|
+
*
|
|
206
|
+
* @param occSymbols - Array of OCC option symbols to fetch data for
|
|
207
|
+
* @returns Promise that resolves when all data is fetched
|
|
208
|
+
*
|
|
209
|
+
* @remarks
|
|
210
|
+
* Open interest is only available via the REST API, not streaming.
|
|
211
|
+
* This method groups options by underlying and expiration to minimize API calls.
|
|
212
|
+
*/
|
|
213
|
+
async fetchOpenInterest(occSymbols) {
|
|
214
|
+
// Group symbols by underlying and expiration to minimize API calls
|
|
215
|
+
const groups = new Map();
|
|
216
|
+
for (const occSymbol of occSymbols) {
|
|
217
|
+
try {
|
|
218
|
+
const parsed = (0, occ_1.parseOCCSymbol)(occSymbol);
|
|
219
|
+
const key = `${parsed.symbol}:${parsed.expiration.toISOString().split('T')[0]}`;
|
|
220
|
+
if (!groups.has(key)) {
|
|
221
|
+
groups.set(key, new Set());
|
|
222
|
+
}
|
|
223
|
+
groups.get(key).add(occSymbol);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Skip invalid OCC symbols
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Fetch chains for each underlying/expiration combination
|
|
231
|
+
const fetchPromises = Array.from(groups.entries()).map(async ([key, symbols]) => {
|
|
232
|
+
const [underlying, expiration] = key.split(':');
|
|
233
|
+
const chain = await this.fetchOptionsChain(underlying, expiration);
|
|
234
|
+
// Update cache with open interest data
|
|
235
|
+
for (const item of chain) {
|
|
236
|
+
// Tradier returns symbols in the same format we use (compact OCC)
|
|
237
|
+
if (symbols.has(item.symbol)) {
|
|
238
|
+
// Store base open interest for live OI calculation (t=0 reference)
|
|
239
|
+
this.baseOpenInterest.set(item.symbol, item.open_interest);
|
|
240
|
+
// Initialize cumulative OI change if not already set
|
|
241
|
+
if (!this.cumulativeOIChange.has(item.symbol)) {
|
|
242
|
+
this.cumulativeOIChange.set(item.symbol, 0);
|
|
243
|
+
}
|
|
244
|
+
const existing = this.optionCache.get(item.symbol);
|
|
245
|
+
if (existing) {
|
|
246
|
+
// Update existing cache entry with REST data
|
|
247
|
+
existing.openInterest = item.open_interest;
|
|
248
|
+
existing.liveOpenInterest = this.calculateLiveOpenInterest(item.symbol);
|
|
249
|
+
existing.volume = item.volume;
|
|
250
|
+
existing.impliedVolatility = item.greeks?.mid_iv ?? existing.impliedVolatility;
|
|
251
|
+
// Also update bid/ask if not yet populated
|
|
252
|
+
if (existing.bid === 0 && item.bid > 0) {
|
|
253
|
+
existing.bid = item.bid;
|
|
254
|
+
existing.bidSize = item.bidsize;
|
|
255
|
+
}
|
|
256
|
+
if (existing.ask === 0 && item.ask > 0) {
|
|
257
|
+
existing.ask = item.ask;
|
|
258
|
+
existing.askSize = item.asksize;
|
|
259
|
+
}
|
|
260
|
+
if (existing.last === 0 && item.last !== null) {
|
|
261
|
+
existing.last = item.last;
|
|
262
|
+
}
|
|
263
|
+
if (existing.mark === 0) {
|
|
264
|
+
existing.mark = (item.bid + item.ask) / 2;
|
|
265
|
+
}
|
|
266
|
+
this.optionCache.set(item.symbol, existing);
|
|
267
|
+
this.emit('optionUpdate', existing);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
// Create new cache entry from REST data
|
|
271
|
+
const parsedSymbol = (0, occ_1.parseOCCSymbol)(item.symbol);
|
|
272
|
+
const option = {
|
|
273
|
+
occSymbol: item.symbol,
|
|
274
|
+
underlying: item.underlying,
|
|
275
|
+
strike: item.strike,
|
|
276
|
+
expiration: item.expiration_date,
|
|
277
|
+
expirationTimestamp: parsedSymbol.expiration.getTime(),
|
|
278
|
+
optionType: item.option_type,
|
|
279
|
+
bid: item.bid,
|
|
280
|
+
bidSize: item.bidsize,
|
|
281
|
+
ask: item.ask,
|
|
282
|
+
askSize: item.asksize,
|
|
283
|
+
mark: (item.bid + item.ask) / 2,
|
|
284
|
+
last: item.last ?? 0,
|
|
285
|
+
volume: item.volume,
|
|
286
|
+
openInterest: item.open_interest,
|
|
287
|
+
liveOpenInterest: this.calculateLiveOpenInterest(item.symbol),
|
|
288
|
+
impliedVolatility: item.greeks?.mid_iv ?? 0,
|
|
289
|
+
timestamp: Date.now(),
|
|
290
|
+
};
|
|
291
|
+
this.optionCache.set(item.symbol, option);
|
|
292
|
+
this.emit('optionUpdate', option);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
await Promise.all(fetchPromises);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Returns the cached option data for a symbol.
|
|
301
|
+
*
|
|
302
|
+
* @param occSymbol - OCC option symbol
|
|
303
|
+
* @returns Cached option data or undefined
|
|
304
|
+
*/
|
|
305
|
+
getOption(occSymbol) {
|
|
306
|
+
return this.optionCache.get(occSymbol);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Returns all cached options.
|
|
310
|
+
*
|
|
311
|
+
* @returns Map of OCC symbols to option data
|
|
312
|
+
*/
|
|
313
|
+
getAllOptions() {
|
|
314
|
+
return new Map(this.optionCache);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Registers an event listener.
|
|
318
|
+
*
|
|
319
|
+
* @param event - Event type to listen for
|
|
320
|
+
* @param listener - Callback function
|
|
321
|
+
*/
|
|
322
|
+
on(event, listener) {
|
|
323
|
+
const listeners = this.eventListeners.get(event);
|
|
324
|
+
if (listeners) {
|
|
325
|
+
listeners.add(listener);
|
|
326
|
+
}
|
|
327
|
+
return this;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Removes an event listener.
|
|
331
|
+
*
|
|
332
|
+
* @param event - Event type
|
|
333
|
+
* @param listener - Callback function to remove
|
|
334
|
+
*/
|
|
335
|
+
off(event, listener) {
|
|
336
|
+
const listeners = this.eventListeners.get(event);
|
|
337
|
+
if (listeners) {
|
|
338
|
+
listeners.delete(listener);
|
|
339
|
+
}
|
|
340
|
+
return this;
|
|
341
|
+
}
|
|
342
|
+
// ==================== Private Methods ====================
|
|
343
|
+
/**
|
|
344
|
+
* Creates a streaming session with Tradier API.
|
|
345
|
+
*/
|
|
346
|
+
async createStreamSession() {
|
|
347
|
+
try {
|
|
348
|
+
const response = await fetch(`${this.apiBaseUrl}/markets/events/session`, {
|
|
349
|
+
method: 'POST',
|
|
350
|
+
headers: {
|
|
351
|
+
'Authorization': `Bearer ${this.authKey}`,
|
|
352
|
+
'Accept': 'application/json',
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
if (!response.ok) {
|
|
356
|
+
this.emit('error', new Error(`Failed to create stream session: ${response.statusText}`));
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
const data = await response.json();
|
|
360
|
+
return data.stream;
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Connects to the Tradier WebSocket.
|
|
369
|
+
*/
|
|
370
|
+
connectWebSocket() {
|
|
371
|
+
return new Promise((resolve, reject) => {
|
|
372
|
+
this.ws = new WebSocket(this.wsUrl);
|
|
373
|
+
this.ws.onopen = () => {
|
|
374
|
+
this.connected = true;
|
|
375
|
+
this.reconnectAttempts = 0;
|
|
376
|
+
this.emit('connected', undefined);
|
|
377
|
+
// Subscribe to any queued symbols
|
|
378
|
+
if (this.subscribedSymbols.size > 0 && this.streamSession) {
|
|
379
|
+
const payload = {
|
|
380
|
+
sessionid: this.streamSession.sessionid,
|
|
381
|
+
symbols: Array.from(this.subscribedSymbols),
|
|
382
|
+
};
|
|
383
|
+
this.ws.send(JSON.stringify(payload));
|
|
384
|
+
}
|
|
385
|
+
resolve();
|
|
386
|
+
};
|
|
387
|
+
this.ws.onmessage = (event) => {
|
|
388
|
+
this.handleMessage(event.data);
|
|
389
|
+
};
|
|
390
|
+
this.ws.onclose = (event) => {
|
|
391
|
+
this.connected = false;
|
|
392
|
+
this.emit('disconnected', { reason: event.reason });
|
|
393
|
+
// Attempt reconnection if not a clean close
|
|
394
|
+
if (event.code !== 1000) {
|
|
395
|
+
this.attemptReconnect();
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
this.ws.onerror = (error) => {
|
|
399
|
+
this.emit('error', new Error('WebSocket error'));
|
|
400
|
+
reject(error);
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Attempts to reconnect with exponential backoff.
|
|
406
|
+
*/
|
|
407
|
+
async attemptReconnect() {
|
|
408
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
409
|
+
this.emit('error', new Error('Max reconnection attempts reached'));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
this.reconnectAttempts++;
|
|
413
|
+
const delay = this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
414
|
+
await this.sleep(delay);
|
|
415
|
+
try {
|
|
416
|
+
await this.connect();
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
// Reconnect attempt failed, will try again via onclose
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Handles incoming WebSocket messages.
|
|
424
|
+
*/
|
|
425
|
+
handleMessage(data) {
|
|
426
|
+
try {
|
|
427
|
+
const event = JSON.parse(data);
|
|
428
|
+
if (event.type === 'quote') {
|
|
429
|
+
this.handleQuoteEvent(event);
|
|
430
|
+
}
|
|
431
|
+
else if (event.type === 'trade') {
|
|
432
|
+
this.handleTradeEvent(event);
|
|
433
|
+
}
|
|
434
|
+
else if (event.type === 'timesale') {
|
|
435
|
+
this.handleTimesaleEvent(event);
|
|
436
|
+
}
|
|
437
|
+
// 'summary' events don't have data we need for NormalizedTicker/Option
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
// Ignore parse errors for heartbeat/status messages
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Handles quote events (bid/ask updates).
|
|
445
|
+
*/
|
|
446
|
+
handleQuoteEvent(event) {
|
|
447
|
+
const { symbol } = event;
|
|
448
|
+
const timestamp = parseInt(event.biddate, 10) || Date.now();
|
|
449
|
+
const isOption = this.isOptionSymbol(symbol);
|
|
450
|
+
if (isOption) {
|
|
451
|
+
this.updateOptionFromQuote(symbol, event, timestamp);
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
this.updateTickerFromQuote(symbol, event, timestamp);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Handles trade events (last price/volume updates).
|
|
459
|
+
*/
|
|
460
|
+
handleTradeEvent(event) {
|
|
461
|
+
const { symbol } = event;
|
|
462
|
+
const timestamp = parseInt(event.date, 10) || Date.now();
|
|
463
|
+
const isOption = this.isOptionSymbol(symbol);
|
|
464
|
+
if (isOption) {
|
|
465
|
+
this.updateOptionFromTrade(symbol, event, timestamp);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
this.updateTickerFromTrade(symbol, event, timestamp);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Handles timesale events (trade with bid/ask at time of sale).
|
|
473
|
+
* This is particularly useful for options where quote events may be sparse.
|
|
474
|
+
*/
|
|
475
|
+
handleTimesaleEvent(event) {
|
|
476
|
+
const { symbol } = event;
|
|
477
|
+
const timestamp = parseInt(event.date, 10) || Date.now();
|
|
478
|
+
const isOption = this.isOptionSymbol(symbol);
|
|
479
|
+
if (isOption) {
|
|
480
|
+
this.updateOptionFromTimesale(symbol, event, timestamp);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
this.updateTickerFromTimesale(symbol, event, timestamp);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Updates ticker data from a quote event.
|
|
488
|
+
*/
|
|
489
|
+
updateTickerFromQuote(symbol, event, timestamp) {
|
|
490
|
+
const existing = this.tickerCache.get(symbol);
|
|
491
|
+
const ticker = {
|
|
492
|
+
symbol,
|
|
493
|
+
spot: (event.bid + event.ask) / 2,
|
|
494
|
+
bid: event.bid,
|
|
495
|
+
bidSize: event.bidsz,
|
|
496
|
+
ask: event.ask,
|
|
497
|
+
askSize: event.asksz,
|
|
498
|
+
last: existing?.last ?? 0,
|
|
499
|
+
volume: existing?.volume ?? 0,
|
|
500
|
+
timestamp,
|
|
501
|
+
};
|
|
502
|
+
this.tickerCache.set(symbol, ticker);
|
|
503
|
+
this.emit('tickerUpdate', ticker);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Updates ticker data from a trade event.
|
|
507
|
+
*/
|
|
508
|
+
updateTickerFromTrade(symbol, event, timestamp) {
|
|
509
|
+
const existing = this.tickerCache.get(symbol);
|
|
510
|
+
const last = parseFloat(event.last);
|
|
511
|
+
const volume = parseInt(event.cvol, 10);
|
|
512
|
+
const ticker = {
|
|
513
|
+
symbol,
|
|
514
|
+
spot: existing?.spot ?? last,
|
|
515
|
+
bid: existing?.bid ?? 0,
|
|
516
|
+
bidSize: existing?.bidSize ?? 0,
|
|
517
|
+
ask: existing?.ask ?? 0,
|
|
518
|
+
askSize: existing?.askSize ?? 0,
|
|
519
|
+
last,
|
|
520
|
+
volume,
|
|
521
|
+
timestamp,
|
|
522
|
+
};
|
|
523
|
+
this.tickerCache.set(symbol, ticker);
|
|
524
|
+
this.emit('tickerUpdate', ticker);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Updates option data from a quote event.
|
|
528
|
+
*/
|
|
529
|
+
updateOptionFromQuote(occSymbol, event, timestamp) {
|
|
530
|
+
const existing = this.optionCache.get(occSymbol);
|
|
531
|
+
// Parse OCC symbol to extract option details
|
|
532
|
+
let parsed;
|
|
533
|
+
try {
|
|
534
|
+
parsed = (0, occ_1.parseOCCSymbol)(occSymbol);
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
// Invalid OCC symbol, skip
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const option = {
|
|
541
|
+
occSymbol,
|
|
542
|
+
underlying: parsed.symbol,
|
|
543
|
+
strike: parsed.strike,
|
|
544
|
+
expiration: parsed.expiration.toISOString().split('T')[0],
|
|
545
|
+
expirationTimestamp: parsed.expiration.getTime(),
|
|
546
|
+
optionType: parsed.optionType,
|
|
547
|
+
bid: event.bid,
|
|
548
|
+
bidSize: event.bidsz,
|
|
549
|
+
ask: event.ask,
|
|
550
|
+
askSize: event.asksz,
|
|
551
|
+
mark: (event.bid + event.ask) / 2,
|
|
552
|
+
last: existing?.last ?? 0,
|
|
553
|
+
volume: existing?.volume ?? 0,
|
|
554
|
+
openInterest: existing?.openInterest ?? 0,
|
|
555
|
+
impliedVolatility: existing?.impliedVolatility ?? 0,
|
|
556
|
+
timestamp,
|
|
557
|
+
};
|
|
558
|
+
this.optionCache.set(occSymbol, option);
|
|
559
|
+
this.emit('optionUpdate', option);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Updates option data from a trade event.
|
|
563
|
+
*/
|
|
564
|
+
updateOptionFromTrade(occSymbol, event, timestamp) {
|
|
565
|
+
const existing = this.optionCache.get(occSymbol);
|
|
566
|
+
// Parse OCC symbol to extract option details
|
|
567
|
+
let parsed;
|
|
568
|
+
try {
|
|
569
|
+
parsed = (0, occ_1.parseOCCSymbol)(occSymbol);
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
// Invalid OCC symbol, skip
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const last = parseFloat(event.last);
|
|
576
|
+
const volume = parseInt(event.cvol, 10);
|
|
577
|
+
const option = {
|
|
578
|
+
occSymbol,
|
|
579
|
+
underlying: parsed.symbol,
|
|
580
|
+
strike: parsed.strike,
|
|
581
|
+
expiration: parsed.expiration.toISOString().split('T')[0],
|
|
582
|
+
expirationTimestamp: parsed.expiration.getTime(),
|
|
583
|
+
optionType: parsed.optionType,
|
|
584
|
+
bid: existing?.bid ?? 0,
|
|
585
|
+
bidSize: existing?.bidSize ?? 0,
|
|
586
|
+
ask: existing?.ask ?? 0,
|
|
587
|
+
askSize: existing?.askSize ?? 0,
|
|
588
|
+
mark: existing?.mark ?? last,
|
|
589
|
+
last,
|
|
590
|
+
volume,
|
|
591
|
+
openInterest: existing?.openInterest ?? 0,
|
|
592
|
+
impliedVolatility: existing?.impliedVolatility ?? 0,
|
|
593
|
+
timestamp,
|
|
594
|
+
};
|
|
595
|
+
this.optionCache.set(occSymbol, option);
|
|
596
|
+
this.emit('optionUpdate', option);
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Updates ticker data from a timesale event.
|
|
600
|
+
* Timesale events include bid/ask at the time of the trade.
|
|
601
|
+
*/
|
|
602
|
+
updateTickerFromTimesale(symbol, event, timestamp) {
|
|
603
|
+
const existing = this.tickerCache.get(symbol);
|
|
604
|
+
const bid = parseFloat(event.bid);
|
|
605
|
+
const ask = parseFloat(event.ask);
|
|
606
|
+
const last = parseFloat(event.last);
|
|
607
|
+
const size = parseInt(event.size, 10);
|
|
608
|
+
const ticker = {
|
|
609
|
+
symbol,
|
|
610
|
+
spot: (bid + ask) / 2,
|
|
611
|
+
bid,
|
|
612
|
+
bidSize: existing?.bidSize ?? 0, // timesale doesn't include bid/ask size
|
|
613
|
+
ask,
|
|
614
|
+
askSize: existing?.askSize ?? 0,
|
|
615
|
+
last,
|
|
616
|
+
volume: (existing?.volume ?? 0) + size, // Accumulate volume
|
|
617
|
+
timestamp,
|
|
618
|
+
};
|
|
619
|
+
this.tickerCache.set(symbol, ticker);
|
|
620
|
+
this.emit('tickerUpdate', ticker);
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Updates option data from a timesale event.
|
|
624
|
+
* Timesale events include bid/ask at the time of the trade, enabling aggressor side detection.
|
|
625
|
+
*
|
|
626
|
+
* This is the primary method for calculating live open interest:
|
|
627
|
+
* - Aggressor side is determined by comparing trade price to NBBO
|
|
628
|
+
* - Buy aggressor (lifting ask) typically indicates new long positions → OI increases
|
|
629
|
+
* - Sell aggressor (hitting bid) typically indicates closing longs or new shorts → OI decreases
|
|
630
|
+
*/
|
|
631
|
+
updateOptionFromTimesale(occSymbol, event, timestamp) {
|
|
632
|
+
const existing = this.optionCache.get(occSymbol);
|
|
633
|
+
// Parse OCC symbol to extract option details
|
|
634
|
+
let parsed;
|
|
635
|
+
try {
|
|
636
|
+
parsed = (0, occ_1.parseOCCSymbol)(occSymbol);
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
// Invalid OCC symbol, skip
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const bid = parseFloat(event.bid);
|
|
643
|
+
const ask = parseFloat(event.ask);
|
|
644
|
+
const last = parseFloat(event.last);
|
|
645
|
+
const size = parseInt(event.size, 10);
|
|
646
|
+
// Determine aggressor side by comparing trade price to NBBO
|
|
647
|
+
const aggressorSide = this.determineAggressorSide(last, bid, ask);
|
|
648
|
+
// Calculate estimated OI change based on aggressor side
|
|
649
|
+
// Buy aggressor (lifting the offer) → typically opening new long positions → +OI
|
|
650
|
+
// Sell aggressor (hitting the bid) → typically closing longs or opening shorts → -OI
|
|
651
|
+
const estimatedOIChange = this.calculateOIChangeFromTrade(aggressorSide, size, parsed.optionType);
|
|
652
|
+
// Update cumulative OI change
|
|
653
|
+
const currentChange = this.cumulativeOIChange.get(occSymbol) ?? 0;
|
|
654
|
+
this.cumulativeOIChange.set(occSymbol, currentChange + estimatedOIChange);
|
|
655
|
+
// Record the trade for analysis
|
|
656
|
+
const trade = {
|
|
657
|
+
occSymbol,
|
|
658
|
+
price: last,
|
|
659
|
+
size,
|
|
660
|
+
bid,
|
|
661
|
+
ask,
|
|
662
|
+
aggressorSide,
|
|
663
|
+
timestamp,
|
|
664
|
+
estimatedOIChange,
|
|
665
|
+
};
|
|
666
|
+
if (!this.intradayTrades.has(occSymbol)) {
|
|
667
|
+
this.intradayTrades.set(occSymbol, []);
|
|
668
|
+
}
|
|
669
|
+
this.intradayTrades.get(occSymbol).push(trade);
|
|
670
|
+
// Emit trade event with aggressor info
|
|
671
|
+
this.emit('optionTrade', trade);
|
|
672
|
+
const option = {
|
|
673
|
+
occSymbol,
|
|
674
|
+
underlying: parsed.symbol,
|
|
675
|
+
strike: parsed.strike,
|
|
676
|
+
expiration: parsed.expiration.toISOString().split('T')[0],
|
|
677
|
+
expirationTimestamp: parsed.expiration.getTime(),
|
|
678
|
+
optionType: parsed.optionType,
|
|
679
|
+
bid,
|
|
680
|
+
bidSize: existing?.bidSize ?? 0, // timesale doesn't include bid/ask size
|
|
681
|
+
ask,
|
|
682
|
+
askSize: existing?.askSize ?? 0,
|
|
683
|
+
mark: (bid + ask) / 2,
|
|
684
|
+
last,
|
|
685
|
+
volume: (existing?.volume ?? 0) + size, // Accumulate volume
|
|
686
|
+
openInterest: existing?.openInterest ?? 0,
|
|
687
|
+
liveOpenInterest: this.calculateLiveOpenInterest(occSymbol),
|
|
688
|
+
impliedVolatility: existing?.impliedVolatility ?? 0,
|
|
689
|
+
timestamp,
|
|
690
|
+
};
|
|
691
|
+
this.optionCache.set(occSymbol, option);
|
|
692
|
+
this.emit('optionUpdate', option);
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Determines the aggressor side of a trade by comparing trade price to NBBO.
|
|
696
|
+
*
|
|
697
|
+
* @param tradePrice - The executed trade price
|
|
698
|
+
* @param bid - The bid price at time of trade
|
|
699
|
+
* @param ask - The ask price at time of trade
|
|
700
|
+
* @returns The aggressor side: 'buy' if lifting offer, 'sell' if hitting bid, 'unknown' if mid
|
|
701
|
+
*
|
|
702
|
+
* @remarks
|
|
703
|
+
* The aggressor is the party that initiated the trade by crossing the spread:
|
|
704
|
+
* - Buy aggressor: Buyer lifts the offer (trades at or above ask) → bullish intent
|
|
705
|
+
* - Sell aggressor: Seller hits the bid (trades at or below bid) → bearish intent
|
|
706
|
+
* - Unknown: Trade occurred mid-market (could be internalized, crossed, or negotiated)
|
|
707
|
+
*/
|
|
708
|
+
determineAggressorSide(tradePrice, bid, ask) {
|
|
709
|
+
// Use a small tolerance for floating point comparison (0.1% of spread)
|
|
710
|
+
const spread = ask - bid;
|
|
711
|
+
const tolerance = spread > 0 ? spread * 0.001 : 0.001;
|
|
712
|
+
if (tradePrice >= ask - tolerance) {
|
|
713
|
+
// Trade at or above ask → buyer lifted the offer
|
|
714
|
+
return 'buy';
|
|
715
|
+
}
|
|
716
|
+
else if (tradePrice <= bid + tolerance) {
|
|
717
|
+
// Trade at or below bid → seller hit the bid
|
|
718
|
+
return 'sell';
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
// Trade mid-market - could be either side or internalized
|
|
722
|
+
return 'unknown';
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Calculates the estimated open interest change from a single trade.
|
|
727
|
+
*
|
|
728
|
+
* @param aggressorSide - The aggressor side of the trade
|
|
729
|
+
* @param size - Number of contracts traded
|
|
730
|
+
* @param optionType - Whether this is a call or put
|
|
731
|
+
* @returns Estimated OI change (positive = OI increase, negative = OI decrease)
|
|
732
|
+
*
|
|
733
|
+
* @remarks
|
|
734
|
+
* This uses a simplified heuristic based on typical market behavior:
|
|
735
|
+
*
|
|
736
|
+
* For CALLS:
|
|
737
|
+
* - Buy aggressor (lifting offer) → typically bullish, opening new longs → +OI
|
|
738
|
+
* - Sell aggressor (hitting bid) → typically closing longs or bearish new shorts → -OI
|
|
739
|
+
*
|
|
740
|
+
* For PUTS:
|
|
741
|
+
* - Buy aggressor (lifting offer) → typically bearish/hedging, opening new longs → +OI
|
|
742
|
+
* - Sell aggressor (hitting bid) → typically closing longs → -OI
|
|
743
|
+
*
|
|
744
|
+
* Note: This is an estimate. Without knowing if trades are opening or closing,
|
|
745
|
+
* we use aggressor side as a proxy. SpotGamma and similar providers use
|
|
746
|
+
* more sophisticated models that may incorporate position sizing, strike
|
|
747
|
+
* selection patterns, and other heuristics.
|
|
748
|
+
*/
|
|
749
|
+
calculateOIChangeFromTrade(aggressorSide, size, optionType) {
|
|
750
|
+
if (aggressorSide === 'unknown') {
|
|
751
|
+
// Mid-market trades are ambiguous - assume neutral impact on OI
|
|
752
|
+
return 0;
|
|
753
|
+
}
|
|
754
|
+
// Simple heuristic: buy aggressor = new positions opening, sell aggressor = positions closing
|
|
755
|
+
// This applies to both calls and puts since we're measuring contract count, not direction
|
|
756
|
+
if (aggressorSide === 'buy') {
|
|
757
|
+
return size; // New positions opening
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
return -size; // Positions closing
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Calculates the live (intraday) open interest estimate for an option.
|
|
765
|
+
*
|
|
766
|
+
* @param occSymbol - OCC option symbol
|
|
767
|
+
* @returns Live OI estimate = base OI + cumulative estimated changes
|
|
768
|
+
*
|
|
769
|
+
* @remarks
|
|
770
|
+
* Live Open Interest = Base OI (from REST at t=0) + Cumulative OI Changes (from trades)
|
|
771
|
+
*
|
|
772
|
+
* This provides a real-time estimate of open interest that updates throughout
|
|
773
|
+
* the trading day as trades occur. The accuracy depends on:
|
|
774
|
+
* 1. The accuracy of aggressor side detection
|
|
775
|
+
* 2. The assumption that aggressors are typically opening new positions
|
|
776
|
+
*
|
|
777
|
+
* The official OI is only updated overnight by the OCC clearing house,
|
|
778
|
+
* so this estimate fills the gap during trading hours.
|
|
779
|
+
*/
|
|
780
|
+
calculateLiveOpenInterest(occSymbol) {
|
|
781
|
+
const baseOI = this.baseOpenInterest.get(occSymbol) ?? 0;
|
|
782
|
+
const cumulativeChange = this.cumulativeOIChange.get(occSymbol) ?? 0;
|
|
783
|
+
// Live OI cannot go negative
|
|
784
|
+
return Math.max(0, baseOI + cumulativeChange);
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Returns the intraday trades for an option with aggressor classification.
|
|
788
|
+
*
|
|
789
|
+
* @param occSymbol - OCC option symbol
|
|
790
|
+
* @returns Array of intraday trades, or empty array if none
|
|
791
|
+
*/
|
|
792
|
+
getIntradayTrades(occSymbol) {
|
|
793
|
+
return this.intradayTrades.get(occSymbol) ?? [];
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Returns summary statistics for intraday option flow.
|
|
797
|
+
*
|
|
798
|
+
* @param occSymbol - OCC option symbol
|
|
799
|
+
* @returns Object with buy/sell volume, net OI change, and trade count
|
|
800
|
+
*/
|
|
801
|
+
getFlowSummary(occSymbol) {
|
|
802
|
+
const trades = this.intradayTrades.get(occSymbol) ?? [];
|
|
803
|
+
let buyVolume = 0;
|
|
804
|
+
let sellVolume = 0;
|
|
805
|
+
let unknownVolume = 0;
|
|
806
|
+
for (const trade of trades) {
|
|
807
|
+
switch (trade.aggressorSide) {
|
|
808
|
+
case 'buy':
|
|
809
|
+
buyVolume += trade.size;
|
|
810
|
+
break;
|
|
811
|
+
case 'sell':
|
|
812
|
+
sellVolume += trade.size;
|
|
813
|
+
break;
|
|
814
|
+
case 'unknown':
|
|
815
|
+
unknownVolume += trade.size;
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return {
|
|
820
|
+
buyVolume,
|
|
821
|
+
sellVolume,
|
|
822
|
+
unknownVolume,
|
|
823
|
+
netOIChange: this.cumulativeOIChange.get(occSymbol) ?? 0,
|
|
824
|
+
tradeCount: trades.length,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Resets intraday tracking data. Call this at market open or when re-fetching base OI.
|
|
829
|
+
*
|
|
830
|
+
* @param occSymbols - Optional specific symbols to reset. If not provided, resets all.
|
|
831
|
+
*/
|
|
832
|
+
resetIntradayData(occSymbols) {
|
|
833
|
+
const symbolsToReset = occSymbols ?? Array.from(this.intradayTrades.keys());
|
|
834
|
+
for (const symbol of symbolsToReset) {
|
|
835
|
+
this.intradayTrades.delete(symbol);
|
|
836
|
+
this.cumulativeOIChange.set(symbol, 0);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Checks if a symbol is an OCC option symbol.
|
|
841
|
+
*/
|
|
842
|
+
isOptionSymbol(symbol) {
|
|
843
|
+
return OCC_OPTION_PATTERN.test(symbol);
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Emits an event to all registered listeners.
|
|
847
|
+
*/
|
|
848
|
+
emit(event, data) {
|
|
849
|
+
const listeners = this.eventListeners.get(event);
|
|
850
|
+
if (listeners) {
|
|
851
|
+
listeners.forEach(listener => {
|
|
852
|
+
try {
|
|
853
|
+
listener(data);
|
|
854
|
+
}
|
|
855
|
+
catch (error) {
|
|
856
|
+
// Don't let listener errors break the stream
|
|
857
|
+
console.error('Event listener error:', error);
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Simple sleep utility.
|
|
864
|
+
*/
|
|
865
|
+
sleep(ms) {
|
|
866
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
exports.TradierClient = TradierClient;
|