@fullstackcraftllc/floe 0.0.2 → 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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ![npm](https://img.shields.io/npm/v/@fullstackcraftllc/floe?style=flat-square) ![License](https://img.shields.io/npm/l/@fullstackcraftllc/floe?style=flat-square) ![TypeScript](https://img.shields.io/badge/TypeScript-4.9-blue?style=flat-square&logo=typescript)
4
4
 
5
- Browser-only TypeScript functions for calculating Black-Scholes, Greeks, and dealer exposures with a clean, type-safe API. Built for use in trading platforms and fintech applications.
5
+ Zero-dependency TypeScript functions for options flow: Black-Scholes, Greeks, and dealer exposures, and more, with a clean, type-safe API. Built for use in trading platforms and fintech applications.
6
6
 
7
7
  The same library that is used in Full Stack Craft's various fintech products including [The Wheel Screener](https://wheelscreener.com), [LEAPS Screener](https://leapsscreener.com), [Option Screener](https://option-screener.com), [AMT JOY](https://amtjoy.com), and [VannaCharm](https://vannacharm.com).
8
8
 
@@ -200,6 +200,65 @@ export declare class FloeClient {
200
200
  * ```
201
201
  */
202
202
  unsubscribeFromOptions(symbols: Array<string>): void;
203
+ /**
204
+ * Fetches open interest and initial option data via REST API.
205
+ *
206
+ * @param symbols - Array of option symbols in OCC format to fetch data for.
207
+ * If not provided, fetches data for all currently subscribed options.
208
+ * @returns Promise that resolves when all data has been fetched
209
+ *
210
+ * @throws {Error} Throws if no broker connection has been established
211
+ *
212
+ * @remarks
213
+ * Open interest is not available via streaming and must be fetched via REST API.
214
+ * This method should be called after subscribing to options to populate
215
+ * open interest, volume, and initial bid/ask values.
216
+ *
217
+ * The fetched data is automatically merged into the option cache and
218
+ * emitted via 'optionUpdate' events.
219
+ *
220
+ * @example
221
+ * ```typescript
222
+ * // Subscribe to options
223
+ * client.subscribeToOptions(optionSymbols);
224
+ *
225
+ * // Fetch open interest data
226
+ * await client.fetchOpenInterest();
227
+ *
228
+ * // Options now have open interest populated
229
+ * client.on('optionUpdate', (option) => {
230
+ * console.log(`${option.occSymbol}: OI=${option.openInterest}`);
231
+ * });
232
+ * ```
233
+ */
234
+ fetchOpenInterest(symbols?: string[]): Promise<void>;
235
+ /**
236
+ * Returns cached option data for a specific symbol.
237
+ *
238
+ * @param occSymbol - OCC option symbol
239
+ * @returns Cached option data, or undefined if not found
240
+ *
241
+ * @example
242
+ * ```typescript
243
+ * const option = client.getOption('QQQ250117C00530000');
244
+ * console.log(`Open Interest: ${option?.openInterest}`);
245
+ * ```
246
+ */
247
+ getOption(occSymbol: string): NormalizedOption | undefined;
248
+ /**
249
+ * Returns all cached options.
250
+ *
251
+ * @returns Map of OCC symbols to option data
252
+ *
253
+ * @example
254
+ * ```typescript
255
+ * const allOptions = client.getAllOptions();
256
+ * for (const [symbol, option] of allOptions) {
257
+ * console.log(`${symbol}: OI=${option.openInterest}`);
258
+ * }
259
+ * ```
260
+ */
261
+ getAllOptions(): Map<string, NormalizedOption>;
203
262
  /**
204
263
  * Registers an event listener for the specified event type.
205
264
  *
@@ -260,6 +260,91 @@ class FloeClient {
260
260
  throw new Error(`Unsupported broker: ${this.currentBroker}`);
261
261
  }
262
262
  }
263
+ /**
264
+ * Fetches open interest and initial option data via REST API.
265
+ *
266
+ * @param symbols - Array of option symbols in OCC format to fetch data for.
267
+ * If not provided, fetches data for all currently subscribed options.
268
+ * @returns Promise that resolves when all data has been fetched
269
+ *
270
+ * @throws {Error} Throws if no broker connection has been established
271
+ *
272
+ * @remarks
273
+ * Open interest is not available via streaming and must be fetched via REST API.
274
+ * This method should be called after subscribing to options to populate
275
+ * open interest, volume, and initial bid/ask values.
276
+ *
277
+ * The fetched data is automatically merged into the option cache and
278
+ * emitted via 'optionUpdate' events.
279
+ *
280
+ * @example
281
+ * ```typescript
282
+ * // Subscribe to options
283
+ * client.subscribeToOptions(optionSymbols);
284
+ *
285
+ * // Fetch open interest data
286
+ * await client.fetchOpenInterest();
287
+ *
288
+ * // Options now have open interest populated
289
+ * client.on('optionUpdate', (option) => {
290
+ * console.log(`${option.occSymbol}: OI=${option.openInterest}`);
291
+ * });
292
+ * ```
293
+ */
294
+ async fetchOpenInterest(symbols) {
295
+ const symbolsToFetch = symbols ?? this.currentSubscribedOptions;
296
+ if (symbolsToFetch.length === 0) {
297
+ return;
298
+ }
299
+ switch (this.currentBroker) {
300
+ case Broker.TRADIER:
301
+ await this.tradierClient?.fetchOpenInterest(symbolsToFetch);
302
+ break;
303
+ default:
304
+ throw new Error(`Unsupported broker: ${this.currentBroker}`);
305
+ }
306
+ }
307
+ /**
308
+ * Returns cached option data for a specific symbol.
309
+ *
310
+ * @param occSymbol - OCC option symbol
311
+ * @returns Cached option data, or undefined if not found
312
+ *
313
+ * @example
314
+ * ```typescript
315
+ * const option = client.getOption('QQQ250117C00530000');
316
+ * console.log(`Open Interest: ${option?.openInterest}`);
317
+ * ```
318
+ */
319
+ getOption(occSymbol) {
320
+ switch (this.currentBroker) {
321
+ case Broker.TRADIER:
322
+ return this.tradierClient?.getOption(occSymbol);
323
+ default:
324
+ return undefined;
325
+ }
326
+ }
327
+ /**
328
+ * Returns all cached options.
329
+ *
330
+ * @returns Map of OCC symbols to option data
331
+ *
332
+ * @example
333
+ * ```typescript
334
+ * const allOptions = client.getAllOptions();
335
+ * for (const [symbol, option] of allOptions) {
336
+ * console.log(`${symbol}: OI=${option.openInterest}`);
337
+ * }
338
+ * ```
339
+ */
340
+ getAllOptions() {
341
+ switch (this.currentBroker) {
342
+ case Broker.TRADIER:
343
+ return this.tradierClient?.getAllOptions() ?? new Map();
344
+ default:
345
+ return new Map();
346
+ }
347
+ }
263
348
  // ==================== Event Emitter Pattern ====================
264
349
  /**
265
350
  * Registers an event listener for the specified event type.
@@ -0,0 +1,2 @@
1
+ export declare class SchwabClient {
2
+ }
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SchwabClient = void 0;
4
+ class SchwabClient {
5
+ }
6
+ exports.SchwabClient = SchwabClient;
@@ -1,7 +1,86 @@
1
+ import { NormalizedOption } from '../../types';
2
+ /**
3
+ * Aggressor side of a trade - determined by comparing trade price to NBBO
4
+ */
5
+ export type AggressorSide = 'buy' | 'sell' | 'unknown';
6
+ /**
7
+ * Intraday trade information with aggressor classification
8
+ */
9
+ export interface IntradayTrade {
10
+ /** OCC option symbol */
11
+ occSymbol: string;
12
+ /** Trade price */
13
+ price: number;
14
+ /** Trade size (number of contracts) */
15
+ size: number;
16
+ /** Bid at time of trade */
17
+ bid: number;
18
+ /** Ask at time of trade */
19
+ ask: number;
20
+ /** Aggressor side determined from price vs NBBO */
21
+ aggressorSide: AggressorSide;
22
+ /** Timestamp of the trade */
23
+ timestamp: number;
24
+ /** Estimated OI change: +size for buy aggressor (new longs), -size for sell aggressor (closing longs/new shorts) */
25
+ estimatedOIChange: number;
26
+ }
27
+ /**
28
+ * Tradier option chain item from REST API
29
+ * GET /v1/markets/options/chains
30
+ */
31
+ interface TradierOptionChainItem {
32
+ symbol: string;
33
+ description: string;
34
+ exch: string;
35
+ type: string;
36
+ last: number | null;
37
+ change: number | null;
38
+ volume: number;
39
+ open: number | null;
40
+ high: number | null;
41
+ low: number | null;
42
+ close: number | null;
43
+ bid: number;
44
+ ask: number;
45
+ underlying: string;
46
+ strike: number;
47
+ change_percentage: number | null;
48
+ average_volume: number;
49
+ last_volume: number;
50
+ trade_date: number;
51
+ prevclose: number | null;
52
+ week_52_high: number;
53
+ week_52_low: number;
54
+ bidsize: number;
55
+ bidexch: string;
56
+ bid_date: number;
57
+ asksize: number;
58
+ askexch: string;
59
+ ask_date: number;
60
+ open_interest: number;
61
+ contract_size: number;
62
+ expiration_date: string;
63
+ expiration_type: string;
64
+ option_type: 'call' | 'put';
65
+ root_symbol: string;
66
+ greeks?: {
67
+ delta: number;
68
+ gamma: number;
69
+ theta: number;
70
+ vega: number;
71
+ rho: number;
72
+ phi: number;
73
+ bid_iv: number;
74
+ mid_iv: number;
75
+ ask_iv: number;
76
+ smv_vol: number;
77
+ updated_at: string;
78
+ };
79
+ }
1
80
  /**
2
81
  * Event types emitted by TradierClient
3
82
  */
4
- type TradierClientEventType = 'tickerUpdate' | 'optionUpdate' | 'connected' | 'disconnected' | 'error';
83
+ type TradierClientEventType = 'tickerUpdate' | 'optionUpdate' | 'optionTrade' | 'connected' | 'disconnected' | 'error';
5
84
  /**
6
85
  * Event listener callback type
7
86
  */
@@ -41,6 +120,21 @@ export declare class TradierClient {
41
120
  private tickerCache;
42
121
  /** Cached option data (for merging quote and trade events) */
43
122
  private optionCache;
123
+ /**
124
+ * Base open interest from REST API - used as t=0 reference for live OI calculation
125
+ * Key: OCC symbol, Value: open interest at start of day / time of fetch
126
+ */
127
+ private baseOpenInterest;
128
+ /**
129
+ * Cumulative estimated OI change from intraday trades
130
+ * Key: OCC symbol, Value: net estimated change (positive = more contracts opened)
131
+ */
132
+ private cumulativeOIChange;
133
+ /**
134
+ * History of intraday trades with aggressor classification
135
+ * Key: OCC symbol, Value: array of trades
136
+ */
137
+ private intradayTrades;
44
138
  /** Event listeners */
45
139
  private eventListeners;
46
140
  /** Reconnection attempt counter */
@@ -91,6 +185,40 @@ export declare class TradierClient {
91
185
  * Returns whether the client is currently connected.
92
186
  */
93
187
  isConnected(): boolean;
188
+ /**
189
+ * Fetches options chain data from Tradier REST API.
190
+ *
191
+ * @param symbol - Underlying symbol (e.g., 'QQQ')
192
+ * @param expiration - Expiration date in YYYY-MM-DD format
193
+ * @param greeks - Whether to include Greeks data (default: true)
194
+ * @returns Array of option chain items, or empty array on failure
195
+ */
196
+ fetchOptionsChain(symbol: string, expiration: string, greeks?: boolean): Promise<TradierOptionChainItem[]>;
197
+ /**
198
+ * Fetches open interest and other static data for subscribed options via REST API.
199
+ * Call this after subscribing to options to populate open interest.
200
+ *
201
+ * @param occSymbols - Array of OCC option symbols to fetch data for
202
+ * @returns Promise that resolves when all data is fetched
203
+ *
204
+ * @remarks
205
+ * Open interest is only available via the REST API, not streaming.
206
+ * This method groups options by underlying and expiration to minimize API calls.
207
+ */
208
+ fetchOpenInterest(occSymbols: string[]): Promise<void>;
209
+ /**
210
+ * Returns the cached option data for a symbol.
211
+ *
212
+ * @param occSymbol - OCC option symbol
213
+ * @returns Cached option data or undefined
214
+ */
215
+ getOption(occSymbol: string): NormalizedOption | undefined;
216
+ /**
217
+ * Returns all cached options.
218
+ *
219
+ * @returns Map of OCC symbols to option data
220
+ */
221
+ getAllOptions(): Map<string, NormalizedOption>;
94
222
  /**
95
223
  * Registers an event listener.
96
224
  *
@@ -129,6 +257,11 @@ export declare class TradierClient {
129
257
  * Handles trade events (last price/volume updates).
130
258
  */
131
259
  private handleTradeEvent;
260
+ /**
261
+ * Handles timesale events (trade with bid/ask at time of sale).
262
+ * This is particularly useful for options where quote events may be sparse.
263
+ */
264
+ private handleTimesaleEvent;
132
265
  /**
133
266
  * Updates ticker data from a quote event.
134
267
  */
@@ -145,6 +278,105 @@ export declare class TradierClient {
145
278
  * Updates option data from a trade event.
146
279
  */
147
280
  private updateOptionFromTrade;
281
+ /**
282
+ * Updates ticker data from a timesale event.
283
+ * Timesale events include bid/ask at the time of the trade.
284
+ */
285
+ private updateTickerFromTimesale;
286
+ /**
287
+ * Updates option data from a timesale event.
288
+ * Timesale events include bid/ask at the time of the trade, enabling aggressor side detection.
289
+ *
290
+ * This is the primary method for calculating live open interest:
291
+ * - Aggressor side is determined by comparing trade price to NBBO
292
+ * - Buy aggressor (lifting ask) typically indicates new long positions → OI increases
293
+ * - Sell aggressor (hitting bid) typically indicates closing longs or new shorts → OI decreases
294
+ */
295
+ private updateOptionFromTimesale;
296
+ /**
297
+ * Determines the aggressor side of a trade by comparing trade price to NBBO.
298
+ *
299
+ * @param tradePrice - The executed trade price
300
+ * @param bid - The bid price at time of trade
301
+ * @param ask - The ask price at time of trade
302
+ * @returns The aggressor side: 'buy' if lifting offer, 'sell' if hitting bid, 'unknown' if mid
303
+ *
304
+ * @remarks
305
+ * The aggressor is the party that initiated the trade by crossing the spread:
306
+ * - Buy aggressor: Buyer lifts the offer (trades at or above ask) → bullish intent
307
+ * - Sell aggressor: Seller hits the bid (trades at or below bid) → bearish intent
308
+ * - Unknown: Trade occurred mid-market (could be internalized, crossed, or negotiated)
309
+ */
310
+ private determineAggressorSide;
311
+ /**
312
+ * Calculates the estimated open interest change from a single trade.
313
+ *
314
+ * @param aggressorSide - The aggressor side of the trade
315
+ * @param size - Number of contracts traded
316
+ * @param optionType - Whether this is a call or put
317
+ * @returns Estimated OI change (positive = OI increase, negative = OI decrease)
318
+ *
319
+ * @remarks
320
+ * This uses a simplified heuristic based on typical market behavior:
321
+ *
322
+ * For CALLS:
323
+ * - Buy aggressor (lifting offer) → typically bullish, opening new longs → +OI
324
+ * - Sell aggressor (hitting bid) → typically closing longs or bearish new shorts → -OI
325
+ *
326
+ * For PUTS:
327
+ * - Buy aggressor (lifting offer) → typically bearish/hedging, opening new longs → +OI
328
+ * - Sell aggressor (hitting bid) → typically closing longs → -OI
329
+ *
330
+ * Note: This is an estimate. Without knowing if trades are opening or closing,
331
+ * we use aggressor side as a proxy. SpotGamma and similar providers use
332
+ * more sophisticated models that may incorporate position sizing, strike
333
+ * selection patterns, and other heuristics.
334
+ */
335
+ private calculateOIChangeFromTrade;
336
+ /**
337
+ * Calculates the live (intraday) open interest estimate for an option.
338
+ *
339
+ * @param occSymbol - OCC option symbol
340
+ * @returns Live OI estimate = base OI + cumulative estimated changes
341
+ *
342
+ * @remarks
343
+ * Live Open Interest = Base OI (from REST at t=0) + Cumulative OI Changes (from trades)
344
+ *
345
+ * This provides a real-time estimate of open interest that updates throughout
346
+ * the trading day as trades occur. The accuracy depends on:
347
+ * 1. The accuracy of aggressor side detection
348
+ * 2. The assumption that aggressors are typically opening new positions
349
+ *
350
+ * The official OI is only updated overnight by the OCC clearing house,
351
+ * so this estimate fills the gap during trading hours.
352
+ */
353
+ private calculateLiveOpenInterest;
354
+ /**
355
+ * Returns the intraday trades for an option with aggressor classification.
356
+ *
357
+ * @param occSymbol - OCC option symbol
358
+ * @returns Array of intraday trades, or empty array if none
359
+ */
360
+ getIntradayTrades(occSymbol: string): IntradayTrade[];
361
+ /**
362
+ * Returns summary statistics for intraday option flow.
363
+ *
364
+ * @param occSymbol - OCC option symbol
365
+ * @returns Object with buy/sell volume, net OI change, and trade count
366
+ */
367
+ getFlowSummary(occSymbol: string): {
368
+ buyVolume: number;
369
+ sellVolume: number;
370
+ unknownVolume: number;
371
+ netOIChange: number;
372
+ tradeCount: number;
373
+ };
374
+ /**
375
+ * Resets intraday tracking data. Call this at market open or when re-fetching base OI.
376
+ *
377
+ * @param occSymbols - Optional specific symbols to reset. If not provided, resets all.
378
+ */
379
+ resetIntradayData(occSymbols?: string[]): void;
148
380
  /**
149
381
  * Checks if a symbol is an OCC option symbol.
150
382
  */
@@ -48,6 +48,21 @@ class TradierClient {
48
48
  this.tickerCache = new Map();
49
49
  /** Cached option data (for merging quote and trade events) */
50
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();
51
66
  /** Event listeners */
52
67
  this.eventListeners = new Map();
53
68
  /** Reconnection attempt counter */
@@ -64,6 +79,7 @@ class TradierClient {
64
79
  // Initialize event listener maps
65
80
  this.eventListeners.set('tickerUpdate', new Set());
66
81
  this.eventListeners.set('optionUpdate', new Set());
82
+ this.eventListeners.set('optionTrade', new Set());
67
83
  this.eventListeners.set('connected', new Set());
68
84
  this.eventListeners.set('disconnected', new Set());
69
85
  this.eventListeners.set('error', new Set());
@@ -138,6 +154,165 @@ class TradierClient {
138
154
  isConnected() {
139
155
  return this.connected;
140
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
+ }
141
316
  /**
142
317
  * Registers an event listener.
143
318
  *
@@ -256,6 +431,10 @@ class TradierClient {
256
431
  else if (event.type === 'trade') {
257
432
  this.handleTradeEvent(event);
258
433
  }
434
+ else if (event.type === 'timesale') {
435
+ this.handleTimesaleEvent(event);
436
+ }
437
+ // 'summary' events don't have data we need for NormalizedTicker/Option
259
438
  }
260
439
  catch (error) {
261
440
  // Ignore parse errors for heartbeat/status messages
@@ -289,6 +468,21 @@ class TradierClient {
289
468
  this.updateTickerFromTrade(symbol, event, timestamp);
290
469
  }
291
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
+ }
292
486
  /**
293
487
  * Updates ticker data from a quote event.
294
488
  */
@@ -401,6 +595,247 @@ class TradierClient {
401
595
  this.optionCache.set(occSymbol, option);
402
596
  this.emit('optionUpdate', option);
403
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
+ }
404
839
  /**
405
840
  * Checks if a symbol is an OCC option symbol.
406
841
  */
package/dist/index.d.ts CHANGED
@@ -14,4 +14,5 @@ export { buildOCCSymbol, parseOCCSymbol, generateStrikesAroundSpot, generateOCCS
14
14
  export type { OCCSymbolParams, ParsedOCCSymbol, StrikeGenerationParams, } from './utils/occ';
15
15
  export { FloeClient, Broker } from './client/FloeClient';
16
16
  export { TradierClient } from './client/brokers/TradierClient';
17
+ export type { AggressorSide, IntradayTrade } from './client/brokers/TradierClient';
17
18
  export { genericAdapter, schwabAdapter, ibkrAdapter, tdaAdapter, brokerAdapters, getAdapter, createOptionChain, } from './adapters';
@@ -48,7 +48,7 @@ export interface StrikeGenerationParams {
48
48
  /** Number of strikes below spot to include */
49
49
  strikesBelow?: number;
50
50
  /** Strike increment (e.g., 1 for $1 increments, 5 for $5) */
51
- strikeIncrement?: number;
51
+ strikeIncrementInDollars?: number;
52
52
  }
53
53
  /**
54
54
  * Builds an OCC-formatted option symbol.
package/dist/utils/occ.js CHANGED
@@ -48,7 +48,8 @@ function buildOCCSymbol(params) {
48
48
  ? symbol.toUpperCase().padEnd(6, ' ')
49
49
  : symbol.toUpperCase();
50
50
  // Format expiration date as YYMMDD
51
- const expirationDate = typeof expiration === 'string' ? new Date(expiration) : expiration;
51
+ // Use UTC methods to avoid timezone shifts when parsing ISO date strings
52
+ const expirationDate = typeof expiration === 'string' ? new Date(expiration + 'T12:00:00') : expiration;
52
53
  const year = expirationDate.getFullYear().toString().slice(-2);
53
54
  const month = (expirationDate.getMonth() + 1).toString().padStart(2, '0');
54
55
  const day = expirationDate.getDate().toString().padStart(2, '0');
@@ -100,11 +101,11 @@ function parseOCCSymbol(occSymbol) {
100
101
  if (symbol.length === 0) {
101
102
  throw new Error(`Invalid OCC symbol: no ticker found in ${occSymbol}`);
102
103
  }
103
- // Parse date
104
+ // Parse date - use noon to avoid timezone edge cases
104
105
  const year = 2000 + parseInt(dateString.slice(0, 2), 10);
105
106
  const month = parseInt(dateString.slice(2, 4), 10) - 1; // 0-indexed
106
107
  const day = parseInt(dateString.slice(4, 6), 10);
107
- const expiration = new Date(year, month, day);
108
+ const expiration = new Date(year, month, day, 12, 0, 0);
108
109
  // Validate date
109
110
  if (isNaN(expiration.getTime())) {
110
111
  throw new Error(`Invalid date in OCC symbol: ${dateString}`);
@@ -133,7 +134,7 @@ function parseOCCSymbol(occSymbol) {
133
134
  * ```
134
135
  */
135
136
  function generateStrikesAroundSpot(params) {
136
- const { spot, strikesAbove = 10, strikesBelow = 10, strikeIncrement = 1 } = params;
137
+ const { spot, strikesAbove = 10, strikesBelow = 10, strikeIncrementInDollars: strikeIncrement = 1 } = params;
137
138
  // Find the nearest strike at or below spot
138
139
  const baseStrike = Math.floor(spot / strikeIncrement) * strikeIncrement;
139
140
  const strikes = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackcraftllc/floe",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Production-ready options analytics toolkit. Normalize broker data structures and calculate Black-Scholes, Greeks, and exposures with a clean, type-safe API. Built for trading platforms and fintech applications.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",