@fullstackcraftllc/floe 0.0.3 → 0.0.5
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 +23 -0
- package/dist/client/FloeClient.d.ts +27 -6
- package/dist/client/FloeClient.js +163 -7
- package/dist/client/brokers/SchwabClient.d.ts +448 -0
- package/dist/client/brokers/SchwabClient.js +1052 -0
- package/dist/client/brokers/TastyTradeClient.d.ts +388 -0
- package/dist/client/brokers/TastyTradeClient.js +1100 -0
- package/dist/client/brokers/TradeStationClient.d.ts +361 -0
- package/dist/client/brokers/TradeStationClient.js +880 -0
- package/dist/client/brokers/TradierClient.d.ts +7 -1
- package/dist/client/brokers/TradierClient.js +18 -1
- package/dist/impliedpdf/index.d.ts +148 -0
- package/dist/impliedpdf/index.js +277 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +12 -1
- package/dist/volatility/index.d.ts +1 -1
- package/dist/volatility/index.js +1 -1
- package/package.json +3 -2
|
@@ -1,6 +1,1058 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SchwabClient = void 0;
|
|
4
|
+
const occ_1 = require("../../utils/occ");
|
|
5
|
+
/**
|
|
6
|
+
* Schwab streaming field enums for LEVELONE_OPTIONS
|
|
7
|
+
*/
|
|
8
|
+
var LevelOneOptionFields;
|
|
9
|
+
(function (LevelOneOptionFields) {
|
|
10
|
+
LevelOneOptionFields[LevelOneOptionFields["SYMBOL"] = 0] = "SYMBOL";
|
|
11
|
+
LevelOneOptionFields[LevelOneOptionFields["DESCRIPTION"] = 1] = "DESCRIPTION";
|
|
12
|
+
LevelOneOptionFields[LevelOneOptionFields["BID_PRICE"] = 2] = "BID_PRICE";
|
|
13
|
+
LevelOneOptionFields[LevelOneOptionFields["ASK_PRICE"] = 3] = "ASK_PRICE";
|
|
14
|
+
LevelOneOptionFields[LevelOneOptionFields["LAST_PRICE"] = 4] = "LAST_PRICE";
|
|
15
|
+
LevelOneOptionFields[LevelOneOptionFields["HIGH_PRICE"] = 5] = "HIGH_PRICE";
|
|
16
|
+
LevelOneOptionFields[LevelOneOptionFields["LOW_PRICE"] = 6] = "LOW_PRICE";
|
|
17
|
+
LevelOneOptionFields[LevelOneOptionFields["CLOSE_PRICE"] = 7] = "CLOSE_PRICE";
|
|
18
|
+
LevelOneOptionFields[LevelOneOptionFields["TOTAL_VOLUME"] = 8] = "TOTAL_VOLUME";
|
|
19
|
+
LevelOneOptionFields[LevelOneOptionFields["OPEN_INTEREST"] = 9] = "OPEN_INTEREST";
|
|
20
|
+
LevelOneOptionFields[LevelOneOptionFields["VOLATILITY"] = 10] = "VOLATILITY";
|
|
21
|
+
LevelOneOptionFields[LevelOneOptionFields["INTRINSIC_VALUE"] = 11] = "INTRINSIC_VALUE";
|
|
22
|
+
LevelOneOptionFields[LevelOneOptionFields["EXTRINSIC_VALUE"] = 12] = "EXTRINSIC_VALUE";
|
|
23
|
+
LevelOneOptionFields[LevelOneOptionFields["OPTION_ROOT"] = 13] = "OPTION_ROOT";
|
|
24
|
+
LevelOneOptionFields[LevelOneOptionFields["STRIKE_TYPE"] = 14] = "STRIKE_TYPE";
|
|
25
|
+
LevelOneOptionFields[LevelOneOptionFields["CONTRACT_TYPE"] = 15] = "CONTRACT_TYPE";
|
|
26
|
+
LevelOneOptionFields[LevelOneOptionFields["UNDERLYING"] = 16] = "UNDERLYING";
|
|
27
|
+
LevelOneOptionFields[LevelOneOptionFields["EXPIRATION_MONTH"] = 17] = "EXPIRATION_MONTH";
|
|
28
|
+
LevelOneOptionFields[LevelOneOptionFields["DELIVERABLES"] = 18] = "DELIVERABLES";
|
|
29
|
+
LevelOneOptionFields[LevelOneOptionFields["TIME_VALUE"] = 19] = "TIME_VALUE";
|
|
30
|
+
LevelOneOptionFields[LevelOneOptionFields["EXPIRATION_DAY"] = 20] = "EXPIRATION_DAY";
|
|
31
|
+
LevelOneOptionFields[LevelOneOptionFields["DAYS_TO_EXPIRATION"] = 21] = "DAYS_TO_EXPIRATION";
|
|
32
|
+
LevelOneOptionFields[LevelOneOptionFields["DELTA"] = 22] = "DELTA";
|
|
33
|
+
LevelOneOptionFields[LevelOneOptionFields["GAMMA"] = 23] = "GAMMA";
|
|
34
|
+
LevelOneOptionFields[LevelOneOptionFields["THETA"] = 24] = "THETA";
|
|
35
|
+
LevelOneOptionFields[LevelOneOptionFields["VEGA"] = 25] = "VEGA";
|
|
36
|
+
LevelOneOptionFields[LevelOneOptionFields["RHO"] = 26] = "RHO";
|
|
37
|
+
LevelOneOptionFields[LevelOneOptionFields["SECURITY_STATUS"] = 27] = "SECURITY_STATUS";
|
|
38
|
+
LevelOneOptionFields[LevelOneOptionFields["THEORETICAL_OPTION_VALUE"] = 28] = "THEORETICAL_OPTION_VALUE";
|
|
39
|
+
LevelOneOptionFields[LevelOneOptionFields["UNDERLYING_PRICE"] = 29] = "UNDERLYING_PRICE";
|
|
40
|
+
LevelOneOptionFields[LevelOneOptionFields["UV_EXPIRATION_TYPE"] = 30] = "UV_EXPIRATION_TYPE";
|
|
41
|
+
LevelOneOptionFields[LevelOneOptionFields["MARK"] = 31] = "MARK";
|
|
42
|
+
LevelOneOptionFields[LevelOneOptionFields["QUOTE_TIME_MILLIS"] = 32] = "QUOTE_TIME_MILLIS";
|
|
43
|
+
LevelOneOptionFields[LevelOneOptionFields["TRADE_TIME_MILLIS"] = 33] = "TRADE_TIME_MILLIS";
|
|
44
|
+
LevelOneOptionFields[LevelOneOptionFields["EXCHANGE_ID"] = 34] = "EXCHANGE_ID";
|
|
45
|
+
LevelOneOptionFields[LevelOneOptionFields["EXCHANGE_NAME"] = 35] = "EXCHANGE_NAME";
|
|
46
|
+
LevelOneOptionFields[LevelOneOptionFields["LAST_TRADING_DAY"] = 36] = "LAST_TRADING_DAY";
|
|
47
|
+
LevelOneOptionFields[LevelOneOptionFields["SETTLEMENT_TYPE"] = 37] = "SETTLEMENT_TYPE";
|
|
48
|
+
LevelOneOptionFields[LevelOneOptionFields["NET_CHANGE"] = 38] = "NET_CHANGE";
|
|
49
|
+
LevelOneOptionFields[LevelOneOptionFields["NET_PERCENT_CHANGE"] = 39] = "NET_PERCENT_CHANGE";
|
|
50
|
+
LevelOneOptionFields[LevelOneOptionFields["MARK_CHANGE"] = 40] = "MARK_CHANGE";
|
|
51
|
+
LevelOneOptionFields[LevelOneOptionFields["MARK_PERCENT_CHANGE"] = 41] = "MARK_PERCENT_CHANGE";
|
|
52
|
+
LevelOneOptionFields[LevelOneOptionFields["IMPLIED_YIELD"] = 42] = "IMPLIED_YIELD";
|
|
53
|
+
LevelOneOptionFields[LevelOneOptionFields["IS_PENNY_PILOT"] = 43] = "IS_PENNY_PILOT";
|
|
54
|
+
LevelOneOptionFields[LevelOneOptionFields["OPTION_TYPE"] = 44] = "OPTION_TYPE";
|
|
55
|
+
LevelOneOptionFields[LevelOneOptionFields["STRIKE_PRICE"] = 45] = "STRIKE_PRICE";
|
|
56
|
+
LevelOneOptionFields[LevelOneOptionFields["BID_SIZE"] = 46] = "BID_SIZE";
|
|
57
|
+
LevelOneOptionFields[LevelOneOptionFields["ASK_SIZE"] = 47] = "ASK_SIZE";
|
|
58
|
+
LevelOneOptionFields[LevelOneOptionFields["LAST_SIZE"] = 48] = "LAST_SIZE";
|
|
59
|
+
LevelOneOptionFields[LevelOneOptionFields["NET_CHANGE_DIR"] = 49] = "NET_CHANGE_DIR";
|
|
60
|
+
LevelOneOptionFields[LevelOneOptionFields["OPEN_PRICE"] = 50] = "OPEN_PRICE";
|
|
61
|
+
LevelOneOptionFields[LevelOneOptionFields["TICK"] = 51] = "TICK";
|
|
62
|
+
LevelOneOptionFields[LevelOneOptionFields["TICK_AMOUNT"] = 52] = "TICK_AMOUNT";
|
|
63
|
+
LevelOneOptionFields[LevelOneOptionFields["FUTURE_MULTIPLIER"] = 53] = "FUTURE_MULTIPLIER";
|
|
64
|
+
LevelOneOptionFields[LevelOneOptionFields["FUTURE_SETTLEMENT_PRICE"] = 54] = "FUTURE_SETTLEMENT_PRICE";
|
|
65
|
+
LevelOneOptionFields[LevelOneOptionFields["EXCHANGE_CHARACTER"] = 55] = "EXCHANGE_CHARACTER";
|
|
66
|
+
})(LevelOneOptionFields || (LevelOneOptionFields = {}));
|
|
67
|
+
/**
|
|
68
|
+
* Schwab streaming field enums for OPTIONS_BOOK
|
|
69
|
+
*/
|
|
70
|
+
var OptionsBookFields;
|
|
71
|
+
(function (OptionsBookFields) {
|
|
72
|
+
OptionsBookFields[OptionsBookFields["SYMBOL"] = 0] = "SYMBOL";
|
|
73
|
+
OptionsBookFields[OptionsBookFields["BOOK_TIME"] = 1] = "BOOK_TIME";
|
|
74
|
+
OptionsBookFields[OptionsBookFields["BIDS"] = 2] = "BIDS";
|
|
75
|
+
OptionsBookFields[OptionsBookFields["ASKS"] = 3] = "ASKS";
|
|
76
|
+
})(OptionsBookFields || (OptionsBookFields = {}));
|
|
77
|
+
/**
|
|
78
|
+
* Regex pattern to identify OCC option symbols
|
|
79
|
+
* Schwab uses space-padded format: "AAPL 240517C00170000"
|
|
80
|
+
*/
|
|
81
|
+
const OCC_OPTION_PATTERN = /^.{1,6}\s*\d{6}[CP]\d{8}$/;
|
|
82
|
+
/**
|
|
83
|
+
* SchwabClient handles real-time streaming connections to the Charles Schwab API
|
|
84
|
+
* via WebSockets.
|
|
85
|
+
*
|
|
86
|
+
* @remarks
|
|
87
|
+
* This client manages WebSocket connections to Schwab's streaming API,
|
|
88
|
+
* normalizes incoming quote and book data, and emits events for upstream
|
|
89
|
+
* consumption by the FloeClient.
|
|
90
|
+
*
|
|
91
|
+
* Authentication flow:
|
|
92
|
+
* 1. Use OAuth access token to call get_user_preferences endpoint
|
|
93
|
+
* 2. Extract streaming credentials (customerId, correlId, channel, functionId, socketUrl)
|
|
94
|
+
* 3. Connect to WebSocket and send ADMIN/LOGIN request with access token
|
|
95
|
+
* 4. Subscribe to LEVELONE_OPTIONS for quotes and OPTIONS_BOOK for order book
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```typescript
|
|
99
|
+
* const client = new SchwabClient({
|
|
100
|
+
* accessToken: 'your-oauth-access-token'
|
|
101
|
+
* });
|
|
102
|
+
*
|
|
103
|
+
* client.on('tickerUpdate', (ticker) => {
|
|
104
|
+
* console.log(`${ticker.symbol}: ${ticker.spot}`);
|
|
105
|
+
* });
|
|
106
|
+
*
|
|
107
|
+
* await client.connect();
|
|
108
|
+
* client.subscribe(['SPY', 'SPY 240517C00500000']); // Equity and option
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
4
111
|
class SchwabClient {
|
|
112
|
+
/**
|
|
113
|
+
* Creates a new SchwabClient instance.
|
|
114
|
+
*
|
|
115
|
+
* @param options - Client configuration options
|
|
116
|
+
* @param options.accessToken - Schwab OAuth access token (required)
|
|
117
|
+
* @param options.verbose - Whether to log verbose debug information (default: false)
|
|
118
|
+
*/
|
|
119
|
+
constructor(options) {
|
|
120
|
+
/** WebSocket connection */
|
|
121
|
+
this.ws = null;
|
|
122
|
+
/** Connection state */
|
|
123
|
+
this.connected = false;
|
|
124
|
+
/** Logged in state */
|
|
125
|
+
this.loggedIn = false;
|
|
126
|
+
/** Streaming credentials */
|
|
127
|
+
this.streamCustomerId = null;
|
|
128
|
+
this.streamCorrelId = null;
|
|
129
|
+
this.streamChannel = null;
|
|
130
|
+
this.streamFunctionId = null;
|
|
131
|
+
this.streamSocketUrl = null;
|
|
132
|
+
/** Request ID counter */
|
|
133
|
+
this.requestId = 0;
|
|
134
|
+
/** Currently subscribed symbols */
|
|
135
|
+
this.subscribedSymbols = new Set();
|
|
136
|
+
/** Map from Schwab symbol to OCC symbol */
|
|
137
|
+
this.schwabToOccMap = new Map();
|
|
138
|
+
/** Map from OCC symbol to Schwab symbol */
|
|
139
|
+
this.occToSchwabMap = new Map();
|
|
140
|
+
/** Cached ticker data */
|
|
141
|
+
this.tickerCache = new Map();
|
|
142
|
+
/** Cached option data */
|
|
143
|
+
this.optionCache = new Map();
|
|
144
|
+
/** Base open interest from REST API */
|
|
145
|
+
this.baseOpenInterest = new Map();
|
|
146
|
+
/** Cumulative estimated OI change from intraday trades */
|
|
147
|
+
this.cumulativeOIChange = new Map();
|
|
148
|
+
/** History of intraday trades */
|
|
149
|
+
this.intradayTrades = new Map();
|
|
150
|
+
/** Event listeners */
|
|
151
|
+
this.eventListeners = new Map();
|
|
152
|
+
/** Reconnection attempt counter */
|
|
153
|
+
this.reconnectAttempts = 0;
|
|
154
|
+
/** Maximum reconnection attempts */
|
|
155
|
+
this.maxReconnectAttempts = 5;
|
|
156
|
+
/** Reconnection delay in ms */
|
|
157
|
+
this.baseReconnectDelay = 1000;
|
|
158
|
+
/** Keepalive interval handle */
|
|
159
|
+
this.keepaliveInterval = null;
|
|
160
|
+
/** Schwab API base URL */
|
|
161
|
+
this.apiBaseUrl = 'https://api.schwabapi.com';
|
|
162
|
+
this.accessToken = options.accessToken;
|
|
163
|
+
this.verbose = options.verbose ?? false;
|
|
164
|
+
// Initialize event listener maps
|
|
165
|
+
this.eventListeners.set('tickerUpdate', new Set());
|
|
166
|
+
this.eventListeners.set('optionUpdate', new Set());
|
|
167
|
+
this.eventListeners.set('optionTrade', new Set());
|
|
168
|
+
this.eventListeners.set('connected', new Set());
|
|
169
|
+
this.eventListeners.set('disconnected', new Set());
|
|
170
|
+
this.eventListeners.set('error', new Set());
|
|
171
|
+
}
|
|
172
|
+
// ==================== Public API ====================
|
|
173
|
+
/**
|
|
174
|
+
* Establishes a streaming connection to Schwab.
|
|
175
|
+
*
|
|
176
|
+
* @returns Promise that resolves when connected and logged in
|
|
177
|
+
* @throws {Error} If credentials retrieval or WebSocket connection fails
|
|
178
|
+
*/
|
|
179
|
+
async connect() {
|
|
180
|
+
// Get streaming credentials from user preferences
|
|
181
|
+
const preferences = await this.getUserPreferences();
|
|
182
|
+
if (!preferences || !preferences.streamerInfo?.[0]) {
|
|
183
|
+
throw new Error('Failed to get Schwab streaming credentials');
|
|
184
|
+
}
|
|
185
|
+
const streamInfo = preferences.streamerInfo[0];
|
|
186
|
+
this.streamCustomerId = streamInfo.schwabClientCustomerId;
|
|
187
|
+
this.streamCorrelId = streamInfo.schwabClientCorrelId;
|
|
188
|
+
this.streamChannel = streamInfo.schwabClientChannel;
|
|
189
|
+
this.streamFunctionId = streamInfo.schwabClientFunctionId;
|
|
190
|
+
this.streamSocketUrl = streamInfo.streamerSocketUrl;
|
|
191
|
+
// Connect to WebSocket
|
|
192
|
+
await this.connectWebSocket();
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Disconnects from the Schwab streaming API.
|
|
196
|
+
*/
|
|
197
|
+
disconnect() {
|
|
198
|
+
if (this.keepaliveInterval) {
|
|
199
|
+
clearInterval(this.keepaliveInterval);
|
|
200
|
+
this.keepaliveInterval = null;
|
|
201
|
+
}
|
|
202
|
+
if (this.ws && this.loggedIn) {
|
|
203
|
+
// Send logout request
|
|
204
|
+
this.sendLogout();
|
|
205
|
+
}
|
|
206
|
+
if (this.ws) {
|
|
207
|
+
this.ws.close(1000, 'Client disconnect');
|
|
208
|
+
this.ws = null;
|
|
209
|
+
}
|
|
210
|
+
this.connected = false;
|
|
211
|
+
this.loggedIn = false;
|
|
212
|
+
this.subscribedSymbols.clear();
|
|
213
|
+
this.schwabToOccMap.clear();
|
|
214
|
+
this.occToSchwabMap.clear();
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Subscribes to real-time updates for the specified symbols.
|
|
218
|
+
*
|
|
219
|
+
* @param symbols - Array of ticker symbols and/or OCC option symbols
|
|
220
|
+
*
|
|
221
|
+
* @remarks
|
|
222
|
+
* For options, you can pass OCC format symbols (e.g., 'SPY240119C00500000')
|
|
223
|
+
* or Schwab format with spaces (e.g., 'SPY 240119C00500000').
|
|
224
|
+
* The client will handle conversion automatically.
|
|
225
|
+
*/
|
|
226
|
+
subscribe(symbols) {
|
|
227
|
+
// Add to tracked symbols
|
|
228
|
+
symbols.forEach(s => this.subscribedSymbols.add(s));
|
|
229
|
+
if (!this.connected || !this.loggedIn) {
|
|
230
|
+
// Will subscribe when logged in
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const tickers = [];
|
|
234
|
+
const options = [];
|
|
235
|
+
for (const symbol of symbols) {
|
|
236
|
+
if (this.isOptionSymbol(symbol)) {
|
|
237
|
+
options.push(this.toSchwabOptionSymbol(symbol));
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
tickers.push(symbol);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (tickers.length > 0) {
|
|
244
|
+
this.subscribeLevelOneEquity(tickers);
|
|
245
|
+
}
|
|
246
|
+
if (options.length > 0) {
|
|
247
|
+
this.subscribeLevelOneOptions(options);
|
|
248
|
+
this.subscribeOptionsBook(options);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Unsubscribes from real-time updates for the specified symbols.
|
|
253
|
+
*
|
|
254
|
+
* @param symbols - Array of symbols to unsubscribe from
|
|
255
|
+
*/
|
|
256
|
+
unsubscribe(symbols) {
|
|
257
|
+
symbols.forEach(s => this.subscribedSymbols.delete(s));
|
|
258
|
+
if (!this.connected || !this.loggedIn) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const tickers = [];
|
|
262
|
+
const options = [];
|
|
263
|
+
for (const symbol of symbols) {
|
|
264
|
+
if (this.isOptionSymbol(symbol)) {
|
|
265
|
+
options.push(this.toSchwabOptionSymbol(symbol));
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
tickers.push(symbol);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (tickers.length > 0) {
|
|
272
|
+
this.unsubscribeLevelOneEquity(tickers);
|
|
273
|
+
}
|
|
274
|
+
if (options.length > 0) {
|
|
275
|
+
this.unsubscribeLevelOneOptions(options);
|
|
276
|
+
this.unsubscribeOptionsBook(options);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Returns whether the client is currently connected.
|
|
281
|
+
*/
|
|
282
|
+
isConnected() {
|
|
283
|
+
return this.connected && this.loggedIn;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Fetches option chain from Schwab REST API.
|
|
287
|
+
* This provides the base open interest data.
|
|
288
|
+
*
|
|
289
|
+
* @param symbol - Underlying symbol (e.g., 'SPY')
|
|
290
|
+
* @param options - Optional parameters for filtering the chain
|
|
291
|
+
* @returns Promise resolving to the option chain response
|
|
292
|
+
*/
|
|
293
|
+
async fetchOptionChain(symbol, options) {
|
|
294
|
+
try {
|
|
295
|
+
const params = new URLSearchParams({ symbol });
|
|
296
|
+
if (options?.contractType) {
|
|
297
|
+
params.append('contractType', options.contractType);
|
|
298
|
+
}
|
|
299
|
+
if (options?.strikeCount) {
|
|
300
|
+
params.append('strikeCount', options.strikeCount.toString());
|
|
301
|
+
}
|
|
302
|
+
if (options?.includeUnderlyingQuote) {
|
|
303
|
+
params.append('includeUnderlyingQuote', 'true');
|
|
304
|
+
}
|
|
305
|
+
if (options?.fromDate) {
|
|
306
|
+
params.append('fromDate', options.fromDate);
|
|
307
|
+
}
|
|
308
|
+
if (options?.toDate) {
|
|
309
|
+
params.append('toDate', options.toDate);
|
|
310
|
+
}
|
|
311
|
+
const response = await fetch(`${this.apiBaseUrl}/marketdata/v1/chains?${params.toString()}`, {
|
|
312
|
+
method: 'GET',
|
|
313
|
+
headers: this.getAuthHeaders(),
|
|
314
|
+
});
|
|
315
|
+
if (!response.ok) {
|
|
316
|
+
this.emit('error', new Error(`Failed to fetch option chain: ${response.statusText}`));
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
return await response.json();
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Fetches open interest and other static data for subscribed options.
|
|
328
|
+
*
|
|
329
|
+
* @param occSymbols - Array of OCC option symbols to fetch data for
|
|
330
|
+
*/
|
|
331
|
+
async fetchOpenInterest(occSymbols) {
|
|
332
|
+
// Group by underlying
|
|
333
|
+
const groups = new Map();
|
|
334
|
+
for (const occSymbol of occSymbols) {
|
|
335
|
+
try {
|
|
336
|
+
const parsed = (0, occ_1.parseOCCSymbol)(this.normalizeOccSymbol(occSymbol));
|
|
337
|
+
if (!groups.has(parsed.symbol)) {
|
|
338
|
+
groups.set(parsed.symbol, new Set());
|
|
339
|
+
}
|
|
340
|
+
groups.get(parsed.symbol).add(this.normalizeOccSymbol(occSymbol));
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// Fetch chains for each underlying
|
|
347
|
+
const fetchPromises = Array.from(groups.entries()).map(async ([underlying, targetSymbols]) => {
|
|
348
|
+
const chain = await this.fetchOptionChain(underlying, {
|
|
349
|
+
contractType: 'ALL',
|
|
350
|
+
includeUnderlyingQuote: true,
|
|
351
|
+
});
|
|
352
|
+
if (!chain)
|
|
353
|
+
return;
|
|
354
|
+
// Process calls
|
|
355
|
+
for (const [_expDate, strikes] of Object.entries(chain.callExpDateMap || {})) {
|
|
356
|
+
for (const [_strike, contracts] of Object.entries(strikes)) {
|
|
357
|
+
for (const contract of contracts) {
|
|
358
|
+
this.processChainContract(contract, targetSymbols);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// Process puts
|
|
363
|
+
for (const [_expDate, strikes] of Object.entries(chain.putExpDateMap || {})) {
|
|
364
|
+
for (const [_strike, contracts] of Object.entries(strikes)) {
|
|
365
|
+
for (const contract of contracts) {
|
|
366
|
+
this.processChainContract(contract, targetSymbols);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
await Promise.all(fetchPromises);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Returns cached option data for a symbol.
|
|
375
|
+
*/
|
|
376
|
+
getOption(occSymbol) {
|
|
377
|
+
return this.optionCache.get(this.normalizeOccSymbol(occSymbol));
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Returns all cached options.
|
|
381
|
+
*/
|
|
382
|
+
getAllOptions() {
|
|
383
|
+
return new Map(this.optionCache);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Registers an event listener.
|
|
387
|
+
*/
|
|
388
|
+
on(event, listener) {
|
|
389
|
+
const listeners = this.eventListeners.get(event);
|
|
390
|
+
if (listeners) {
|
|
391
|
+
listeners.add(listener);
|
|
392
|
+
}
|
|
393
|
+
return this;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Removes an event listener.
|
|
397
|
+
*/
|
|
398
|
+
off(event, listener) {
|
|
399
|
+
const listeners = this.eventListeners.get(event);
|
|
400
|
+
if (listeners) {
|
|
401
|
+
listeners.delete(listener);
|
|
402
|
+
}
|
|
403
|
+
return this;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Returns intraday trades for an option.
|
|
407
|
+
*/
|
|
408
|
+
getIntradayTrades(occSymbol) {
|
|
409
|
+
return this.intradayTrades.get(this.normalizeOccSymbol(occSymbol)) ?? [];
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Returns flow summary for an option.
|
|
413
|
+
*/
|
|
414
|
+
getFlowSummary(occSymbol) {
|
|
415
|
+
const normalizedSymbol = this.normalizeOccSymbol(occSymbol);
|
|
416
|
+
const trades = this.intradayTrades.get(normalizedSymbol) ?? [];
|
|
417
|
+
let buyVolume = 0;
|
|
418
|
+
let sellVolume = 0;
|
|
419
|
+
let unknownVolume = 0;
|
|
420
|
+
for (const trade of trades) {
|
|
421
|
+
switch (trade.aggressorSide) {
|
|
422
|
+
case 'buy':
|
|
423
|
+
buyVolume += trade.size;
|
|
424
|
+
break;
|
|
425
|
+
case 'sell':
|
|
426
|
+
sellVolume += trade.size;
|
|
427
|
+
break;
|
|
428
|
+
case 'unknown':
|
|
429
|
+
unknownVolume += trade.size;
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
buyVolume,
|
|
435
|
+
sellVolume,
|
|
436
|
+
unknownVolume,
|
|
437
|
+
netOIChange: this.cumulativeOIChange.get(normalizedSymbol) ?? 0,
|
|
438
|
+
tradeCount: trades.length,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Resets intraday tracking data.
|
|
443
|
+
*/
|
|
444
|
+
resetIntradayData(occSymbols) {
|
|
445
|
+
const symbolsToReset = occSymbols?.map(s => this.normalizeOccSymbol(s))
|
|
446
|
+
?? Array.from(this.intradayTrades.keys());
|
|
447
|
+
for (const symbol of symbolsToReset) {
|
|
448
|
+
this.intradayTrades.delete(symbol);
|
|
449
|
+
this.cumulativeOIChange.set(symbol, 0);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// ==================== Private Methods ====================
|
|
453
|
+
/**
|
|
454
|
+
* Gets user preferences containing streaming info from Schwab.
|
|
455
|
+
*/
|
|
456
|
+
async getUserPreferences() {
|
|
457
|
+
try {
|
|
458
|
+
const response = await fetch(`${this.apiBaseUrl}/trader/v1/userPreference`, {
|
|
459
|
+
method: 'GET',
|
|
460
|
+
headers: this.getAuthHeaders(),
|
|
461
|
+
});
|
|
462
|
+
if (!response.ok) {
|
|
463
|
+
const errorText = await response.text();
|
|
464
|
+
this.emit('error', new Error(`Failed to get user preferences: ${response.statusText} - ${errorText}`));
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
return await response.json();
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Returns authorization headers for API calls.
|
|
476
|
+
*/
|
|
477
|
+
getAuthHeaders() {
|
|
478
|
+
return {
|
|
479
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
480
|
+
'Accept': 'application/json',
|
|
481
|
+
'Content-Type': 'application/json',
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Connects to Schwab WebSocket.
|
|
486
|
+
*/
|
|
487
|
+
connectWebSocket() {
|
|
488
|
+
return new Promise((resolve, reject) => {
|
|
489
|
+
if (!this.streamSocketUrl) {
|
|
490
|
+
reject(new Error('Stream socket URL not available'));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
this.ws = new WebSocket(this.streamSocketUrl);
|
|
494
|
+
this.ws.onopen = () => {
|
|
495
|
+
this.connected = true;
|
|
496
|
+
this.reconnectAttempts = 0;
|
|
497
|
+
// Send login request
|
|
498
|
+
this.sendLogin();
|
|
499
|
+
};
|
|
500
|
+
this.ws.onmessage = (event) => {
|
|
501
|
+
this.handleMessage(event.data, resolve);
|
|
502
|
+
};
|
|
503
|
+
this.ws.onclose = (event) => {
|
|
504
|
+
this.connected = false;
|
|
505
|
+
this.loggedIn = false;
|
|
506
|
+
this.emit('disconnected', { reason: event.reason });
|
|
507
|
+
if (event.code !== 1000) {
|
|
508
|
+
this.attemptReconnect();
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
this.ws.onerror = () => {
|
|
512
|
+
this.emit('error', new Error('Schwab WebSocket error'));
|
|
513
|
+
reject(new Error('WebSocket connection failed'));
|
|
514
|
+
};
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Sends login request to Schwab streaming.
|
|
519
|
+
*/
|
|
520
|
+
sendLogin() {
|
|
521
|
+
const request = this.makeRequest('ADMIN', 'LOGIN', {
|
|
522
|
+
'Authorization': this.accessToken,
|
|
523
|
+
'SchwabClientChannel': this.streamChannel,
|
|
524
|
+
'SchwabClientFunctionId': this.streamFunctionId,
|
|
525
|
+
});
|
|
526
|
+
this.sendMessage({ requests: [request] });
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Sends logout request to Schwab streaming.
|
|
530
|
+
*/
|
|
531
|
+
sendLogout() {
|
|
532
|
+
const request = this.makeRequest('ADMIN', 'LOGOUT', {});
|
|
533
|
+
this.sendMessage({ requests: [request] });
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Subscribes to LEVELONE_EQUITIES service.
|
|
537
|
+
*/
|
|
538
|
+
subscribeLevelOneEquity(symbols) {
|
|
539
|
+
const fields = '0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,' +
|
|
540
|
+
'20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49';
|
|
541
|
+
const request = this.makeRequest('LEVELONE_EQUITIES', 'SUBS', {
|
|
542
|
+
keys: symbols.join(','),
|
|
543
|
+
fields,
|
|
544
|
+
});
|
|
545
|
+
this.sendMessage({ requests: [request] });
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Unsubscribes from LEVELONE_EQUITIES service.
|
|
549
|
+
*/
|
|
550
|
+
unsubscribeLevelOneEquity(symbols) {
|
|
551
|
+
const request = this.makeRequest('LEVELONE_EQUITIES', 'UNSUBS', {
|
|
552
|
+
keys: symbols.join(','),
|
|
553
|
+
});
|
|
554
|
+
this.sendMessage({ requests: [request] });
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Subscribes to LEVELONE_OPTIONS service for level 1 option quotes.
|
|
558
|
+
*/
|
|
559
|
+
subscribeLevelOneOptions(schwabSymbols) {
|
|
560
|
+
// Request all available fields (0-55)
|
|
561
|
+
const fields = Array.from({ length: 56 }, (_, i) => i).join(',');
|
|
562
|
+
const request = this.makeRequest('LEVELONE_OPTIONS', 'SUBS', {
|
|
563
|
+
keys: schwabSymbols.join(','),
|
|
564
|
+
fields,
|
|
565
|
+
});
|
|
566
|
+
this.sendMessage({ requests: [request] });
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Unsubscribes from LEVELONE_OPTIONS service.
|
|
570
|
+
*/
|
|
571
|
+
unsubscribeLevelOneOptions(schwabSymbols) {
|
|
572
|
+
const request = this.makeRequest('LEVELONE_OPTIONS', 'UNSUBS', {
|
|
573
|
+
keys: schwabSymbols.join(','),
|
|
574
|
+
});
|
|
575
|
+
this.sendMessage({ requests: [request] });
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Subscribes to OPTIONS_BOOK service for level 2 order book.
|
|
579
|
+
* This provides "live" open interest intraday.
|
|
580
|
+
*/
|
|
581
|
+
subscribeOptionsBook(schwabSymbols) {
|
|
582
|
+
const request = this.makeRequest('OPTIONS_BOOK', 'SUBS', {
|
|
583
|
+
keys: schwabSymbols.join(','),
|
|
584
|
+
fields: '0,1,2,3',
|
|
585
|
+
});
|
|
586
|
+
this.sendMessage({ requests: [request] });
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Unsubscribes from OPTIONS_BOOK service.
|
|
590
|
+
*/
|
|
591
|
+
unsubscribeOptionsBook(schwabSymbols) {
|
|
592
|
+
const request = this.makeRequest('OPTIONS_BOOK', 'UNSUBS', {
|
|
593
|
+
keys: schwabSymbols.join(','),
|
|
594
|
+
});
|
|
595
|
+
this.sendMessage({ requests: [request] });
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Makes a streaming request object.
|
|
599
|
+
*/
|
|
600
|
+
makeRequest(service, command, parameters) {
|
|
601
|
+
const requestId = this.requestId++;
|
|
602
|
+
return {
|
|
603
|
+
service,
|
|
604
|
+
requestid: requestId.toString(),
|
|
605
|
+
command,
|
|
606
|
+
SchwabClientCustomerId: this.streamCustomerId || '',
|
|
607
|
+
SchwabClientCorrelId: this.streamCorrelId || '',
|
|
608
|
+
parameters,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Handles incoming WebSocket messages.
|
|
613
|
+
*/
|
|
614
|
+
handleMessage(data, connectResolve) {
|
|
615
|
+
try {
|
|
616
|
+
const message = JSON.parse(data);
|
|
617
|
+
// Handle response messages (to commands)
|
|
618
|
+
if (message.response) {
|
|
619
|
+
for (const response of message.response) {
|
|
620
|
+
this.handleResponse(response, connectResolve);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// Handle data messages (streaming data)
|
|
624
|
+
if (message.data) {
|
|
625
|
+
for (const dataItem of message.data) {
|
|
626
|
+
this.handleDataMessage(dataItem);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
// Handle heartbeat/notify messages
|
|
630
|
+
if (message.notify) {
|
|
631
|
+
// Heartbeat received, connection is alive
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
// Ignore parse errors
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Handles a response message (command acknowledgment).
|
|
640
|
+
*/
|
|
641
|
+
handleResponse(response, connectResolve) {
|
|
642
|
+
if (response.service === 'ADMIN' && response.command === 'LOGIN') {
|
|
643
|
+
if (response.content.code === 0) {
|
|
644
|
+
this.loggedIn = true;
|
|
645
|
+
this.startKeepalive();
|
|
646
|
+
if (this.verbose) {
|
|
647
|
+
console.log('[Schwab:WS] Logged in and connected to streaming API');
|
|
648
|
+
}
|
|
649
|
+
this.emit('connected', undefined);
|
|
650
|
+
connectResolve?.();
|
|
651
|
+
// Subscribe to queued symbols
|
|
652
|
+
if (this.subscribedSymbols.size > 0) {
|
|
653
|
+
this.subscribe(Array.from(this.subscribedSymbols));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
this.emit('error', new Error(`Schwab login failed: ${response.content.msg}`));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
else if (response.content.code !== 0) {
|
|
661
|
+
this.emit('error', new Error(`Schwab ${response.service}/${response.command} failed: ${response.content.msg}`));
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Handles streaming data messages.
|
|
666
|
+
*/
|
|
667
|
+
handleDataMessage(dataItem) {
|
|
668
|
+
const { service, content, timestamp } = dataItem;
|
|
669
|
+
for (const item of content) {
|
|
670
|
+
switch (service) {
|
|
671
|
+
case 'LEVELONE_EQUITIES':
|
|
672
|
+
this.handleLevelOneEquity(item, timestamp);
|
|
673
|
+
break;
|
|
674
|
+
case 'LEVELONE_OPTIONS':
|
|
675
|
+
this.handleLevelOneOption(item, timestamp);
|
|
676
|
+
break;
|
|
677
|
+
case 'OPTIONS_BOOK':
|
|
678
|
+
this.handleOptionsBook(item, timestamp);
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Handles LEVELONE_EQUITIES data.
|
|
685
|
+
*/
|
|
686
|
+
handleLevelOneEquity(data, timestamp) {
|
|
687
|
+
const symbol = data.key;
|
|
688
|
+
if (!symbol)
|
|
689
|
+
return;
|
|
690
|
+
const existing = this.tickerCache.get(symbol);
|
|
691
|
+
const ticker = {
|
|
692
|
+
symbol,
|
|
693
|
+
spot: this.toNumber(data['3']) || this.toNumber(data['2']) || existing?.spot || 0, // Last or Bid
|
|
694
|
+
bid: this.toNumber(data['2']) || existing?.bid || 0,
|
|
695
|
+
bidSize: this.toNumber(data['46']) || existing?.bidSize || 0,
|
|
696
|
+
ask: this.toNumber(data['4']) || existing?.ask || 0,
|
|
697
|
+
askSize: this.toNumber(data['47']) || existing?.askSize || 0,
|
|
698
|
+
last: this.toNumber(data['3']) || existing?.last || 0,
|
|
699
|
+
volume: this.toNumber(data['8']) || existing?.volume || 0,
|
|
700
|
+
timestamp,
|
|
701
|
+
};
|
|
702
|
+
this.tickerCache.set(symbol, ticker);
|
|
703
|
+
this.emit('tickerUpdate', ticker);
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Handles LEVELONE_OPTIONS data.
|
|
707
|
+
*/
|
|
708
|
+
handleLevelOneOption(data, timestamp) {
|
|
709
|
+
const schwabSymbol = data.key;
|
|
710
|
+
if (!schwabSymbol)
|
|
711
|
+
return;
|
|
712
|
+
const occSymbol = this.schwabToOcc(schwabSymbol);
|
|
713
|
+
const existing = this.optionCache.get(occSymbol);
|
|
714
|
+
// Parse symbol for underlying info if not cached
|
|
715
|
+
let parsed;
|
|
716
|
+
try {
|
|
717
|
+
parsed = (0, occ_1.parseOCCSymbol)(occSymbol);
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
if (!existing)
|
|
721
|
+
return;
|
|
722
|
+
parsed = {
|
|
723
|
+
symbol: existing.underlying,
|
|
724
|
+
expiration: new Date(existing.expirationTimestamp),
|
|
725
|
+
optionType: existing.optionType,
|
|
726
|
+
strike: existing.strike,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
const bidPrice = this.toNumber(data[LevelOneOptionFields.BID_PRICE.toString()]) || existing?.bid || 0;
|
|
730
|
+
const askPrice = this.toNumber(data[LevelOneOptionFields.ASK_PRICE.toString()]) || existing?.ask || 0;
|
|
731
|
+
const lastPrice = this.toNumber(data[LevelOneOptionFields.LAST_PRICE.toString()]) || existing?.last || 0;
|
|
732
|
+
const volume = this.toNumber(data[LevelOneOptionFields.TOTAL_VOLUME.toString()]) || existing?.volume || 0;
|
|
733
|
+
const openInterest = this.toNumber(data[LevelOneOptionFields.OPEN_INTEREST.toString()]) || existing?.openInterest || 0;
|
|
734
|
+
const volatility = this.toNumber(data[LevelOneOptionFields.VOLATILITY.toString()]) || existing?.impliedVolatility || 0;
|
|
735
|
+
const mark = this.toNumber(data[LevelOneOptionFields.MARK.toString()]) ||
|
|
736
|
+
(bidPrice > 0 && askPrice > 0 ? (bidPrice + askPrice) / 2 : existing?.mark || 0);
|
|
737
|
+
// Update base OI if not set
|
|
738
|
+
if (openInterest > 0 && !this.baseOpenInterest.has(occSymbol)) {
|
|
739
|
+
this.baseOpenInterest.set(occSymbol, openInterest);
|
|
740
|
+
this.cumulativeOIChange.set(occSymbol, 0);
|
|
741
|
+
if (this.verbose) {
|
|
742
|
+
console.log(`[Schwab:OI] Base OI set from stream for ${occSymbol}: ${openInterest}`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
// Detect trade by comparing last price and volume changes
|
|
746
|
+
if (existing && lastPrice > 0 && lastPrice !== existing.last) {
|
|
747
|
+
const volumeChange = volume - (existing.volume || 0);
|
|
748
|
+
if (volumeChange > 0) {
|
|
749
|
+
this.recordTrade(occSymbol, lastPrice, volumeChange, existing.bid, existing.ask, timestamp);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const option = {
|
|
753
|
+
occSymbol,
|
|
754
|
+
underlying: parsed.symbol,
|
|
755
|
+
strike: this.toNumber(data[LevelOneOptionFields.STRIKE_PRICE.toString()]) || parsed.strike,
|
|
756
|
+
expiration: parsed.expiration.toISOString().split('T')[0],
|
|
757
|
+
expirationTimestamp: parsed.expiration.getTime(),
|
|
758
|
+
optionType: parsed.optionType,
|
|
759
|
+
bid: bidPrice,
|
|
760
|
+
bidSize: this.toNumber(data[LevelOneOptionFields.BID_SIZE.toString()]) || existing?.bidSize || 0,
|
|
761
|
+
ask: askPrice,
|
|
762
|
+
askSize: this.toNumber(data[LevelOneOptionFields.ASK_SIZE.toString()]) || existing?.askSize || 0,
|
|
763
|
+
mark,
|
|
764
|
+
last: lastPrice,
|
|
765
|
+
volume,
|
|
766
|
+
openInterest,
|
|
767
|
+
liveOpenInterest: this.calculateLiveOpenInterest(occSymbol),
|
|
768
|
+
impliedVolatility: volatility,
|
|
769
|
+
timestamp,
|
|
770
|
+
};
|
|
771
|
+
this.optionCache.set(occSymbol, option);
|
|
772
|
+
this.emit('optionUpdate', option);
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Handles OPTIONS_BOOK data (level 2 order book).
|
|
776
|
+
* This provides depth of market which can indicate intraday activity.
|
|
777
|
+
*/
|
|
778
|
+
handleOptionsBook(data, _timestamp) {
|
|
779
|
+
const schwabSymbol = data.key;
|
|
780
|
+
if (!schwabSymbol)
|
|
781
|
+
return;
|
|
782
|
+
// OPTIONS_BOOK provides bid/ask book depth
|
|
783
|
+
// We can use changes in the book to infer trade activity
|
|
784
|
+
// For now, we'll update the best bid/ask from the book
|
|
785
|
+
const occSymbol = this.schwabToOcc(schwabSymbol);
|
|
786
|
+
const existing = this.optionCache.get(occSymbol);
|
|
787
|
+
if (!existing)
|
|
788
|
+
return;
|
|
789
|
+
const bids = data[OptionsBookFields.BIDS.toString()];
|
|
790
|
+
const asks = data[OptionsBookFields.ASKS.toString()];
|
|
791
|
+
if (bids && bids.length > 0) {
|
|
792
|
+
const bestBid = bids[0];
|
|
793
|
+
existing.bid = bestBid['0'] || existing.bid;
|
|
794
|
+
existing.bidSize = bestBid['1'] || existing.bidSize;
|
|
795
|
+
}
|
|
796
|
+
if (asks && asks.length > 0) {
|
|
797
|
+
const bestAsk = asks[0];
|
|
798
|
+
existing.ask = bestAsk['0'] || existing.ask;
|
|
799
|
+
existing.askSize = bestAsk['1'] || existing.askSize;
|
|
800
|
+
}
|
|
801
|
+
existing.mark = (existing.bid + existing.ask) / 2;
|
|
802
|
+
existing.timestamp = Date.now();
|
|
803
|
+
this.optionCache.set(occSymbol, existing);
|
|
804
|
+
this.emit('optionUpdate', existing);
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Records a trade and updates OI tracking.
|
|
808
|
+
*/
|
|
809
|
+
recordTrade(occSymbol, price, size, bid, ask, timestamp) {
|
|
810
|
+
const aggressorSide = this.determineAggressorSide(price, bid, ask);
|
|
811
|
+
// Get option type for OI calculation
|
|
812
|
+
let optionType = 'call';
|
|
813
|
+
try {
|
|
814
|
+
const parsed = (0, occ_1.parseOCCSymbol)(occSymbol);
|
|
815
|
+
optionType = parsed.optionType;
|
|
816
|
+
}
|
|
817
|
+
catch {
|
|
818
|
+
const existing = this.optionCache.get(occSymbol);
|
|
819
|
+
if (existing)
|
|
820
|
+
optionType = existing.optionType;
|
|
821
|
+
}
|
|
822
|
+
const estimatedOIChange = this.calculateOIChangeFromTrade(aggressorSide, size, optionType);
|
|
823
|
+
const currentChange = this.cumulativeOIChange.get(occSymbol) ?? 0;
|
|
824
|
+
this.cumulativeOIChange.set(occSymbol, currentChange + estimatedOIChange);
|
|
825
|
+
if (this.verbose && estimatedOIChange !== 0) {
|
|
826
|
+
const baseOI = this.baseOpenInterest.get(occSymbol) ?? 0;
|
|
827
|
+
const newLiveOI = Math.max(0, baseOI + currentChange + estimatedOIChange);
|
|
828
|
+
console.log(`[Schwab:OI] ${occSymbol} trade: price=${price.toFixed(2)}, size=${size}, aggressor=${aggressorSide}, OI change=${estimatedOIChange > 0 ? '+' : ''}${estimatedOIChange}, liveOI=${newLiveOI} (base=${baseOI}, cumulative=${currentChange + estimatedOIChange})`);
|
|
829
|
+
}
|
|
830
|
+
const trade = {
|
|
831
|
+
occSymbol,
|
|
832
|
+
price,
|
|
833
|
+
size,
|
|
834
|
+
bid,
|
|
835
|
+
ask,
|
|
836
|
+
aggressorSide,
|
|
837
|
+
timestamp,
|
|
838
|
+
estimatedOIChange,
|
|
839
|
+
};
|
|
840
|
+
if (!this.intradayTrades.has(occSymbol)) {
|
|
841
|
+
this.intradayTrades.set(occSymbol, []);
|
|
842
|
+
}
|
|
843
|
+
this.intradayTrades.get(occSymbol).push(trade);
|
|
844
|
+
this.emit('optionTrade', trade);
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Processes a contract from the option chain response.
|
|
848
|
+
*/
|
|
849
|
+
processChainContract(contract, targetSymbols) {
|
|
850
|
+
const occSymbol = this.schwabToOcc(contract.symbol);
|
|
851
|
+
if (!targetSymbols.has(occSymbol))
|
|
852
|
+
return;
|
|
853
|
+
// Store mapping
|
|
854
|
+
this.schwabToOccMap.set(contract.symbol, occSymbol);
|
|
855
|
+
this.occToSchwabMap.set(occSymbol, contract.symbol);
|
|
856
|
+
// Store base OI
|
|
857
|
+
if (contract.openInterest > 0) {
|
|
858
|
+
this.baseOpenInterest.set(occSymbol, contract.openInterest);
|
|
859
|
+
if (this.verbose) {
|
|
860
|
+
console.log(`[Schwab:OI] Base OI set from chain for ${occSymbol}: ${contract.openInterest}`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (!this.cumulativeOIChange.has(occSymbol)) {
|
|
864
|
+
this.cumulativeOIChange.set(occSymbol, 0);
|
|
865
|
+
}
|
|
866
|
+
// Create option in cache
|
|
867
|
+
const option = {
|
|
868
|
+
occSymbol,
|
|
869
|
+
underlying: contract.optionRoot,
|
|
870
|
+
strike: contract.strikePrice,
|
|
871
|
+
expiration: contract.expirationDate.split('T')[0],
|
|
872
|
+
expirationTimestamp: new Date(contract.expirationDate).getTime(),
|
|
873
|
+
optionType: contract.putCall === 'CALL' ? 'call' : 'put',
|
|
874
|
+
bid: contract.bid,
|
|
875
|
+
bidSize: contract.bidSize,
|
|
876
|
+
ask: contract.ask,
|
|
877
|
+
askSize: contract.askSize,
|
|
878
|
+
mark: contract.mark,
|
|
879
|
+
last: contract.last,
|
|
880
|
+
volume: contract.totalVolume,
|
|
881
|
+
openInterest: contract.openInterest,
|
|
882
|
+
liveOpenInterest: this.calculateLiveOpenInterest(occSymbol),
|
|
883
|
+
impliedVolatility: contract.volatility,
|
|
884
|
+
timestamp: Date.now(),
|
|
885
|
+
};
|
|
886
|
+
this.optionCache.set(occSymbol, option);
|
|
887
|
+
this.emit('optionUpdate', option);
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Starts keepalive interval.
|
|
891
|
+
*/
|
|
892
|
+
startKeepalive() {
|
|
893
|
+
if (this.keepaliveInterval) {
|
|
894
|
+
clearInterval(this.keepaliveInterval);
|
|
895
|
+
}
|
|
896
|
+
// Schwab streaming uses implicit keepalive via activity
|
|
897
|
+
// Send a QOS request periodically to keep connection alive
|
|
898
|
+
this.keepaliveInterval = setInterval(() => {
|
|
899
|
+
if (this.connected && this.loggedIn && this.ws) {
|
|
900
|
+
// QOS request to keep connection alive
|
|
901
|
+
const request = this.makeRequest('ADMIN', 'QOS', {
|
|
902
|
+
qoslevel: '0', // Express (fastest)
|
|
903
|
+
});
|
|
904
|
+
this.sendMessage({ requests: [request] });
|
|
905
|
+
}
|
|
906
|
+
}, 60000); // Every 60 seconds
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Determines aggressor side from trade price vs NBBO.
|
|
910
|
+
*/
|
|
911
|
+
determineAggressorSide(tradePrice, bid, ask) {
|
|
912
|
+
if (bid <= 0 || ask <= 0)
|
|
913
|
+
return 'unknown';
|
|
914
|
+
const spread = ask - bid;
|
|
915
|
+
const tolerance = spread > 0 ? spread * 0.001 : 0.001;
|
|
916
|
+
if (tradePrice >= ask - tolerance) {
|
|
917
|
+
return 'buy';
|
|
918
|
+
}
|
|
919
|
+
else if (tradePrice <= bid + tolerance) {
|
|
920
|
+
return 'sell';
|
|
921
|
+
}
|
|
922
|
+
return 'unknown';
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Calculates estimated OI change from trade.
|
|
926
|
+
*/
|
|
927
|
+
calculateOIChangeFromTrade(aggressorSide, size, _optionType) {
|
|
928
|
+
if (aggressorSide === 'unknown')
|
|
929
|
+
return 0;
|
|
930
|
+
return aggressorSide === 'buy' ? size : -size;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Calculates live open interest.
|
|
934
|
+
*/
|
|
935
|
+
calculateLiveOpenInterest(occSymbol) {
|
|
936
|
+
const baseOI = this.baseOpenInterest.get(occSymbol) ?? 0;
|
|
937
|
+
const cumulativeChange = this.cumulativeOIChange.get(occSymbol) ?? 0;
|
|
938
|
+
return Math.max(0, baseOI + cumulativeChange);
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Converts Schwab option symbol to OCC format.
|
|
942
|
+
* Schwab format: "AAPL 240517C00170000" (6-char padded underlying)
|
|
943
|
+
*/
|
|
944
|
+
schwabToOcc(schwabSymbol) {
|
|
945
|
+
// Check cache
|
|
946
|
+
const cached = this.schwabToOccMap.get(schwabSymbol);
|
|
947
|
+
if (cached)
|
|
948
|
+
return cached;
|
|
949
|
+
// Schwab symbols are already close to OCC format
|
|
950
|
+
// Just normalize by removing extra spaces and ensuring proper padding
|
|
951
|
+
return this.normalizeOccSymbol(schwabSymbol);
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Normalizes an OCC symbol to consistent format.
|
|
955
|
+
* Removes extra spaces, ensures proper formatting.
|
|
956
|
+
*/
|
|
957
|
+
normalizeOccSymbol(symbol) {
|
|
958
|
+
// Remove all spaces and reformat
|
|
959
|
+
const stripped = symbol.replace(/\s+/g, '');
|
|
960
|
+
// Match the parts: ROOT + YYMMDD + C/P + 8-digit strike
|
|
961
|
+
const match = stripped.match(/^([A-Z]+)(\d{6})([CP])(\d{8})$/);
|
|
962
|
+
if (match) {
|
|
963
|
+
return `${match[1]}${match[2]}${match[3]}${match[4]}`;
|
|
964
|
+
}
|
|
965
|
+
return stripped;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Converts OCC symbol to Schwab format (space-padded).
|
|
969
|
+
*/
|
|
970
|
+
toSchwabOptionSymbol(occSymbol) {
|
|
971
|
+
// Check cache
|
|
972
|
+
const cached = this.occToSchwabMap.get(occSymbol);
|
|
973
|
+
if (cached)
|
|
974
|
+
return cached;
|
|
975
|
+
const normalized = this.normalizeOccSymbol(occSymbol);
|
|
976
|
+
// Parse and rebuild with space padding
|
|
977
|
+
const match = normalized.match(/^([A-Z]+)(\d{6})([CP])(\d{8})$/);
|
|
978
|
+
if (match) {
|
|
979
|
+
const [, root, date, type, strike] = match;
|
|
980
|
+
// Schwab uses 6-character padded root
|
|
981
|
+
const paddedRoot = root.padEnd(6, ' ');
|
|
982
|
+
return `${paddedRoot}${date}${type}${strike}`;
|
|
983
|
+
}
|
|
984
|
+
return occSymbol;
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Checks if symbol is an option symbol.
|
|
988
|
+
*/
|
|
989
|
+
isOptionSymbol(symbol) {
|
|
990
|
+
return OCC_OPTION_PATTERN.test(symbol) || /\d{6}[CP]\d{8}/.test(symbol.replace(/\s+/g, ''));
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Attempts to reconnect with exponential backoff.
|
|
994
|
+
*/
|
|
995
|
+
async attemptReconnect() {
|
|
996
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
997
|
+
this.emit('error', new Error('Max reconnection attempts reached'));
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
this.reconnectAttempts++;
|
|
1001
|
+
const delay = this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
1002
|
+
if (this.verbose) {
|
|
1003
|
+
console.log(`[Schwab:WS] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`);
|
|
1004
|
+
}
|
|
1005
|
+
await this.sleep(delay);
|
|
1006
|
+
try {
|
|
1007
|
+
await this.connect();
|
|
1008
|
+
}
|
|
1009
|
+
catch {
|
|
1010
|
+
// Will try again via onclose
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Sends a message to the WebSocket.
|
|
1015
|
+
*/
|
|
1016
|
+
sendMessage(message) {
|
|
1017
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
1018
|
+
this.ws.send(JSON.stringify(message));
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Emits an event to all listeners.
|
|
1023
|
+
*/
|
|
1024
|
+
emit(event, data) {
|
|
1025
|
+
const listeners = this.eventListeners.get(event);
|
|
1026
|
+
if (listeners) {
|
|
1027
|
+
listeners.forEach(listener => {
|
|
1028
|
+
try {
|
|
1029
|
+
listener(data);
|
|
1030
|
+
}
|
|
1031
|
+
catch (error) {
|
|
1032
|
+
console.error('Event listener error:', error);
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Converts value to number, handling NaN and null.
|
|
1039
|
+
*/
|
|
1040
|
+
toNumber(value) {
|
|
1041
|
+
if (value === null || value === undefined)
|
|
1042
|
+
return 0;
|
|
1043
|
+
if (typeof value === 'number')
|
|
1044
|
+
return isNaN(value) ? 0 : value;
|
|
1045
|
+
if (typeof value === 'string') {
|
|
1046
|
+
const num = parseFloat(value);
|
|
1047
|
+
return isNaN(num) ? 0 : num;
|
|
1048
|
+
}
|
|
1049
|
+
return 0;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Sleep utility.
|
|
1053
|
+
*/
|
|
1054
|
+
sleep(ms) {
|
|
1055
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1056
|
+
}
|
|
5
1057
|
}
|
|
6
1058
|
exports.SchwabClient = SchwabClient;
|