@fullstackcraftllc/floe 0.0.4 → 0.0.6
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 +15 -11
- package/dist/client/FloeClient.d.ts +25 -4
- package/dist/client/FloeClient.js +119 -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 +4 -0
- package/dist/client/brokers/TastyTradeClient.js +19 -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 +9 -3
- package/dist/client/brokers/TradierClient.js +22 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -1
- package/package.json +2 -2
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TradeStationClient = void 0;
|
|
4
|
+
const occ_1 = require("../../utils/occ");
|
|
5
|
+
/**
|
|
6
|
+
* Regex pattern to identify TradeStation option symbols
|
|
7
|
+
* TradeStation uses space-padded format: "MSFT 220916C305"
|
|
8
|
+
*/
|
|
9
|
+
const TS_OPTION_PATTERN = /^[A-Z]+\s+\d{6}[CP]\d+(\.\d+)?$/;
|
|
10
|
+
/**
|
|
11
|
+
* Regex pattern to identify OCC option symbols
|
|
12
|
+
* Format: ROOT + YYMMDD + C/P + 8-digit strike
|
|
13
|
+
*/
|
|
14
|
+
const OCC_OPTION_PATTERN = /^.{1,6}\d{6}[CP]\d{8}$/;
|
|
15
|
+
/**
|
|
16
|
+
* TradeStationClient handles real-time streaming connections to the TradeStation API
|
|
17
|
+
* via HTTP chunked transfer encoding.
|
|
18
|
+
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* This client manages HTTP streaming connections to TradeStation's market data API,
|
|
21
|
+
* normalizes incoming quote data, and emits events for upstream consumption by
|
|
22
|
+
* the FloeClient.
|
|
23
|
+
*
|
|
24
|
+
* TradeStation uses HTTP streaming (chunked transfer encoding) instead of WebSockets.
|
|
25
|
+
* Each stream is a long-lived HTTP connection that returns JSON objects separated
|
|
26
|
+
* by newlines.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* const client = new TradeStationClient({
|
|
31
|
+
* accessToken: 'your-oauth-access-token'
|
|
32
|
+
* });
|
|
33
|
+
*
|
|
34
|
+
* client.on('tickerUpdate', (ticker) => {
|
|
35
|
+
* console.log(`${ticker.symbol}: ${ticker.spot}`);
|
|
36
|
+
* });
|
|
37
|
+
*
|
|
38
|
+
* await client.connect();
|
|
39
|
+
* client.subscribe(['MSFT', 'AAPL']);
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
class TradeStationClient {
|
|
43
|
+
/**
|
|
44
|
+
* Creates a new TradeStationClient instance.
|
|
45
|
+
*
|
|
46
|
+
* @param options - Client configuration options
|
|
47
|
+
* @param options.accessToken - TradeStation OAuth access token (required)
|
|
48
|
+
* @param options.refreshToken - OAuth refresh token for automatic token renewal
|
|
49
|
+
* @param options.simulation - Whether to use simulation environment (default: false)
|
|
50
|
+
* @param options.onTokenRefresh - Callback when token is refreshed
|
|
51
|
+
* @param options.verbose - Whether to log verbose debug information (default: false)
|
|
52
|
+
*/
|
|
53
|
+
constructor(options) {
|
|
54
|
+
/** Connection state */
|
|
55
|
+
this.connected = false;
|
|
56
|
+
/** Currently subscribed ticker symbols */
|
|
57
|
+
this.subscribedTickers = new Set();
|
|
58
|
+
/** Currently subscribed option symbols */
|
|
59
|
+
this.subscribedOptions = new Set();
|
|
60
|
+
/** Active AbortControllers for streams */
|
|
61
|
+
this.activeStreams = new Map();
|
|
62
|
+
/** Cached ticker data */
|
|
63
|
+
this.tickerCache = new Map();
|
|
64
|
+
/** Cached option data */
|
|
65
|
+
this.optionCache = new Map();
|
|
66
|
+
/** Base open interest from REST API */
|
|
67
|
+
this.baseOpenInterest = new Map();
|
|
68
|
+
/** Cumulative estimated OI change from intraday trades */
|
|
69
|
+
this.cumulativeOIChange = new Map();
|
|
70
|
+
/** History of intraday trades */
|
|
71
|
+
this.intradayTrades = new Map();
|
|
72
|
+
/** Event listeners */
|
|
73
|
+
this.eventListeners = new Map();
|
|
74
|
+
/** Reconnection attempt counter */
|
|
75
|
+
this.reconnectAttempts = 0;
|
|
76
|
+
/** Maximum reconnection attempts */
|
|
77
|
+
this.maxReconnectAttempts = 5;
|
|
78
|
+
/** Reconnection delay in ms */
|
|
79
|
+
this.baseReconnectDelay = 1000;
|
|
80
|
+
/** TradeStation API base URL */
|
|
81
|
+
this.apiBaseUrl = 'https://api.tradestation.com/v3';
|
|
82
|
+
/** Refresh token for token refresh */
|
|
83
|
+
this.refreshToken = null;
|
|
84
|
+
/** Token refresh callback */
|
|
85
|
+
this.onTokenRefresh = null;
|
|
86
|
+
this.accessToken = options.accessToken;
|
|
87
|
+
this.refreshToken = options.refreshToken ?? null;
|
|
88
|
+
this.simulation = options.simulation ?? false;
|
|
89
|
+
this.onTokenRefresh = options.onTokenRefresh ?? null;
|
|
90
|
+
this.verbose = options.verbose ?? false;
|
|
91
|
+
// Initialize event listener maps
|
|
92
|
+
this.eventListeners.set('tickerUpdate', new Set());
|
|
93
|
+
this.eventListeners.set('optionUpdate', new Set());
|
|
94
|
+
this.eventListeners.set('optionTrade', new Set());
|
|
95
|
+
this.eventListeners.set('connected', new Set());
|
|
96
|
+
this.eventListeners.set('disconnected', new Set());
|
|
97
|
+
this.eventListeners.set('error', new Set());
|
|
98
|
+
}
|
|
99
|
+
// ==================== Public API ====================
|
|
100
|
+
/**
|
|
101
|
+
* Establishes connection state for TradeStation streaming.
|
|
102
|
+
*
|
|
103
|
+
* @returns Promise that resolves when ready to stream
|
|
104
|
+
* @throws {Error} If token validation fails
|
|
105
|
+
*
|
|
106
|
+
* @remarks
|
|
107
|
+
* Unlike WebSocket-based clients, TradeStation uses HTTP streaming.
|
|
108
|
+
* This method validates the access token by making a test API call.
|
|
109
|
+
*/
|
|
110
|
+
async connect() {
|
|
111
|
+
try {
|
|
112
|
+
// Validate token by making a simple API call
|
|
113
|
+
const response = await fetch(`${this.apiBaseUrl}/brokerage/accounts`, {
|
|
114
|
+
method: 'GET',
|
|
115
|
+
headers: this.getAuthHeaders(),
|
|
116
|
+
});
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
if (response.status === 401) {
|
|
119
|
+
throw new Error('TradeStation authentication failed - invalid or expired token');
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`TradeStation connection failed: ${response.statusText}`);
|
|
122
|
+
}
|
|
123
|
+
this.connected = true;
|
|
124
|
+
this.reconnectAttempts = 0;
|
|
125
|
+
if (this.verbose) {
|
|
126
|
+
console.log('[TradeStation:HTTP] Connected to streaming API');
|
|
127
|
+
}
|
|
128
|
+
this.emit('connected', undefined);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Disconnects from all TradeStation streaming APIs.
|
|
137
|
+
*/
|
|
138
|
+
disconnect() {
|
|
139
|
+
// Abort all active streams
|
|
140
|
+
for (const [streamId, controller] of this.activeStreams) {
|
|
141
|
+
controller.abort();
|
|
142
|
+
}
|
|
143
|
+
this.activeStreams.clear();
|
|
144
|
+
this.connected = false;
|
|
145
|
+
this.subscribedTickers.clear();
|
|
146
|
+
this.subscribedOptions.clear();
|
|
147
|
+
this.emit('disconnected', { reason: 'Client disconnect' });
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Subscribes to real-time updates for the specified symbols.
|
|
151
|
+
*
|
|
152
|
+
* @param symbols - Array of ticker symbols and/or option symbols
|
|
153
|
+
*
|
|
154
|
+
* @remarks
|
|
155
|
+
* TradeStation uses different streaming endpoints for equities and options.
|
|
156
|
+
* This method automatically routes symbols to the appropriate endpoint.
|
|
157
|
+
*
|
|
158
|
+
* Option symbols can be in either:
|
|
159
|
+
* - TradeStation format: "MSFT 220916C305"
|
|
160
|
+
* - OCC format: "MSFT220916C00305000"
|
|
161
|
+
*/
|
|
162
|
+
subscribe(symbols) {
|
|
163
|
+
const tickers = [];
|
|
164
|
+
const options = [];
|
|
165
|
+
for (const symbol of symbols) {
|
|
166
|
+
if (this.isOptionSymbol(symbol)) {
|
|
167
|
+
options.push(symbol);
|
|
168
|
+
this.subscribedOptions.add(symbol);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
tickers.push(symbol);
|
|
172
|
+
this.subscribedTickers.add(symbol);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (tickers.length > 0) {
|
|
176
|
+
this.startQuoteStream(tickers);
|
|
177
|
+
}
|
|
178
|
+
if (options.length > 0) {
|
|
179
|
+
this.startOptionQuoteStream(options);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Unsubscribes from real-time updates for the specified symbols.
|
|
184
|
+
*
|
|
185
|
+
* @param symbols - Array of symbols to unsubscribe from
|
|
186
|
+
*
|
|
187
|
+
* @remarks
|
|
188
|
+
* For TradeStation, unsubscribing requires stopping the stream and
|
|
189
|
+
* restarting with the remaining symbols.
|
|
190
|
+
*/
|
|
191
|
+
unsubscribe(symbols) {
|
|
192
|
+
const tickersToRemove = [];
|
|
193
|
+
const optionsToRemove = [];
|
|
194
|
+
for (const symbol of symbols) {
|
|
195
|
+
if (this.isOptionSymbol(symbol)) {
|
|
196
|
+
this.subscribedOptions.delete(symbol);
|
|
197
|
+
optionsToRemove.push(symbol);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
this.subscribedTickers.delete(symbol);
|
|
201
|
+
tickersToRemove.push(symbol);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Restart streams with remaining symbols
|
|
205
|
+
if (tickersToRemove.length > 0 && this.subscribedTickers.size > 0) {
|
|
206
|
+
this.stopStream('quotes');
|
|
207
|
+
this.startQuoteStream(Array.from(this.subscribedTickers));
|
|
208
|
+
}
|
|
209
|
+
else if (tickersToRemove.length > 0) {
|
|
210
|
+
this.stopStream('quotes');
|
|
211
|
+
}
|
|
212
|
+
if (optionsToRemove.length > 0 && this.subscribedOptions.size > 0) {
|
|
213
|
+
this.stopStream('options');
|
|
214
|
+
this.startOptionQuoteStream(Array.from(this.subscribedOptions));
|
|
215
|
+
}
|
|
216
|
+
else if (optionsToRemove.length > 0) {
|
|
217
|
+
this.stopStream('options');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Returns whether the client is currently connected.
|
|
222
|
+
*/
|
|
223
|
+
isConnected() {
|
|
224
|
+
return this.connected;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Fetches quote snapshots for the specified symbols.
|
|
228
|
+
*
|
|
229
|
+
* @param symbols - Array of symbols (max 100)
|
|
230
|
+
* @returns Array of normalized tickers
|
|
231
|
+
*/
|
|
232
|
+
async fetchQuotes(symbols) {
|
|
233
|
+
try {
|
|
234
|
+
const symbolList = symbols.slice(0, 100).join(',');
|
|
235
|
+
const url = `${this.apiBaseUrl}/marketdata/quotes/${encodeURIComponent(symbolList)}`;
|
|
236
|
+
const response = await fetch(url, {
|
|
237
|
+
method: 'GET',
|
|
238
|
+
headers: this.getAuthHeaders(),
|
|
239
|
+
});
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
this.emit('error', new Error(`Failed to fetch quotes: ${response.statusText}`));
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
const data = await response.json();
|
|
245
|
+
const tickers = [];
|
|
246
|
+
for (const quote of data.Quotes) {
|
|
247
|
+
if (!quote.Error) {
|
|
248
|
+
const ticker = this.normalizeQuote(quote);
|
|
249
|
+
this.tickerCache.set(ticker.symbol, ticker);
|
|
250
|
+
tickers.push(ticker);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return tickers;
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Fetches option expirations for an underlying symbol.
|
|
262
|
+
*
|
|
263
|
+
* @param underlying - Underlying symbol (e.g., 'AAPL')
|
|
264
|
+
* @returns Array of expiration dates
|
|
265
|
+
*/
|
|
266
|
+
async fetchOptionExpirations(underlying) {
|
|
267
|
+
try {
|
|
268
|
+
const url = `${this.apiBaseUrl}/marketdata/options/expirations/${encodeURIComponent(underlying)}`;
|
|
269
|
+
const response = await fetch(url, {
|
|
270
|
+
method: 'GET',
|
|
271
|
+
headers: this.getAuthHeaders(),
|
|
272
|
+
});
|
|
273
|
+
if (!response.ok) {
|
|
274
|
+
this.emit('error', new Error(`Failed to fetch option expirations: ${response.statusText}`));
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
const data = await response.json();
|
|
278
|
+
return data.Expirations.map(exp => exp.Date);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
282
|
+
return [];
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Streams option chain data for an underlying symbol.
|
|
287
|
+
*
|
|
288
|
+
* @param underlying - Underlying symbol (e.g., 'AAPL')
|
|
289
|
+
* @param options - Stream options
|
|
290
|
+
* @returns Promise that resolves when stream is established
|
|
291
|
+
*/
|
|
292
|
+
async streamOptionChain(underlying, options) {
|
|
293
|
+
const params = new URLSearchParams();
|
|
294
|
+
if (options?.expiration)
|
|
295
|
+
params.set('expiration', options.expiration);
|
|
296
|
+
if (options?.strikeProximity)
|
|
297
|
+
params.set('strikeProximity', options.strikeProximity.toString());
|
|
298
|
+
if (options?.enableGreeks !== undefined)
|
|
299
|
+
params.set('enableGreeks', options.enableGreeks.toString());
|
|
300
|
+
if (options?.optionType)
|
|
301
|
+
params.set('optionType', options.optionType);
|
|
302
|
+
const url = `${this.apiBaseUrl}/marketdata/stream/options/chains/${encodeURIComponent(underlying)}?${params.toString()}`;
|
|
303
|
+
const streamId = `chain_${underlying}`;
|
|
304
|
+
await this.startHttpStream(streamId, url, (data) => {
|
|
305
|
+
this.handleOptionChainData(underlying, data);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Fetches symbol details for the specified symbols.
|
|
310
|
+
*
|
|
311
|
+
* @param symbols - Array of symbols (max 50)
|
|
312
|
+
* @returns Symbol details response
|
|
313
|
+
*/
|
|
314
|
+
async fetchSymbolDetails(symbols) {
|
|
315
|
+
try {
|
|
316
|
+
const symbolList = symbols.slice(0, 50).join(',');
|
|
317
|
+
const url = `${this.apiBaseUrl}/marketdata/symbols/${encodeURIComponent(symbolList)}`;
|
|
318
|
+
const response = await fetch(url, {
|
|
319
|
+
method: 'GET',
|
|
320
|
+
headers: this.getAuthHeaders(),
|
|
321
|
+
});
|
|
322
|
+
if (!response.ok) {
|
|
323
|
+
this.emit('error', new Error(`Failed to fetch symbol details: ${response.statusText}`));
|
|
324
|
+
return { Symbols: [], Errors: [] };
|
|
325
|
+
}
|
|
326
|
+
return await response.json();
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
330
|
+
return { Symbols: [], Errors: [] };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Returns cached option data for a symbol.
|
|
335
|
+
*/
|
|
336
|
+
getOption(occSymbol) {
|
|
337
|
+
return this.optionCache.get(occSymbol);
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Returns all cached options.
|
|
341
|
+
*/
|
|
342
|
+
getAllOptions() {
|
|
343
|
+
return new Map(this.optionCache);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Returns cached ticker data for a symbol.
|
|
347
|
+
*/
|
|
348
|
+
getTicker(symbol) {
|
|
349
|
+
return this.tickerCache.get(symbol);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Returns all cached tickers.
|
|
353
|
+
*/
|
|
354
|
+
getAllTickers() {
|
|
355
|
+
return new Map(this.tickerCache);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Registers an event listener.
|
|
359
|
+
*/
|
|
360
|
+
on(event, listener) {
|
|
361
|
+
const listeners = this.eventListeners.get(event);
|
|
362
|
+
if (listeners) {
|
|
363
|
+
listeners.add(listener);
|
|
364
|
+
}
|
|
365
|
+
return this;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Removes an event listener.
|
|
369
|
+
*/
|
|
370
|
+
off(event, listener) {
|
|
371
|
+
const listeners = this.eventListeners.get(event);
|
|
372
|
+
if (listeners) {
|
|
373
|
+
listeners.delete(listener);
|
|
374
|
+
}
|
|
375
|
+
return this;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Returns intraday trades for an option.
|
|
379
|
+
*/
|
|
380
|
+
getIntradayTrades(occSymbol) {
|
|
381
|
+
return this.intradayTrades.get(occSymbol) ?? [];
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Returns flow summary for an option.
|
|
385
|
+
*/
|
|
386
|
+
getFlowSummary(occSymbol) {
|
|
387
|
+
const trades = this.intradayTrades.get(occSymbol) ?? [];
|
|
388
|
+
let buyVolume = 0;
|
|
389
|
+
let sellVolume = 0;
|
|
390
|
+
let unknownVolume = 0;
|
|
391
|
+
for (const trade of trades) {
|
|
392
|
+
switch (trade.aggressorSide) {
|
|
393
|
+
case 'buy':
|
|
394
|
+
buyVolume += trade.size;
|
|
395
|
+
break;
|
|
396
|
+
case 'sell':
|
|
397
|
+
sellVolume += trade.size;
|
|
398
|
+
break;
|
|
399
|
+
case 'unknown':
|
|
400
|
+
unknownVolume += trade.size;
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
buyVolume,
|
|
406
|
+
sellVolume,
|
|
407
|
+
unknownVolume,
|
|
408
|
+
netOIChange: this.cumulativeOIChange.get(occSymbol) ?? 0,
|
|
409
|
+
tradeCount: trades.length,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Resets intraday tracking data.
|
|
414
|
+
*/
|
|
415
|
+
resetIntradayData(occSymbols) {
|
|
416
|
+
const symbolsToReset = occSymbols ?? Array.from(this.intradayTrades.keys());
|
|
417
|
+
for (const symbol of symbolsToReset) {
|
|
418
|
+
this.intradayTrades.delete(symbol);
|
|
419
|
+
this.cumulativeOIChange.set(symbol, 0);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Updates the access token (for token refresh scenarios).
|
|
424
|
+
*/
|
|
425
|
+
updateAccessToken(newToken) {
|
|
426
|
+
this.accessToken = newToken;
|
|
427
|
+
}
|
|
428
|
+
// ==================== Private Methods ====================
|
|
429
|
+
/**
|
|
430
|
+
* Gets authorization headers for API requests.
|
|
431
|
+
*/
|
|
432
|
+
getAuthHeaders() {
|
|
433
|
+
return {
|
|
434
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
435
|
+
'Accept': 'application/json',
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Gets headers for streaming requests.
|
|
440
|
+
*/
|
|
441
|
+
getStreamHeaders() {
|
|
442
|
+
return {
|
|
443
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
444
|
+
'Accept': 'application/vnd.tradestation.streams.v2+json',
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Starts a quote stream for ticker symbols.
|
|
449
|
+
*/
|
|
450
|
+
async startQuoteStream(symbols) {
|
|
451
|
+
if (symbols.length === 0)
|
|
452
|
+
return;
|
|
453
|
+
const symbolList = symbols.slice(0, 100).join(',');
|
|
454
|
+
const url = `${this.apiBaseUrl}/marketdata/stream/quotes/${encodeURIComponent(symbolList)}`;
|
|
455
|
+
await this.startHttpStream('quotes', url, (data) => {
|
|
456
|
+
this.handleQuoteData(data);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Starts an option quote stream.
|
|
461
|
+
*/
|
|
462
|
+
async startOptionQuoteStream(symbols) {
|
|
463
|
+
if (symbols.length === 0)
|
|
464
|
+
return;
|
|
465
|
+
// TradeStation option streaming uses legs parameter
|
|
466
|
+
// Build URL with legs for each option
|
|
467
|
+
const params = new URLSearchParams();
|
|
468
|
+
symbols.forEach((symbol, index) => {
|
|
469
|
+
const tsSymbol = this.toTradeStationOptionSymbol(symbol);
|
|
470
|
+
params.set(`legs[${index}].Symbol`, tsSymbol);
|
|
471
|
+
params.set(`legs[${index}].Ratio`, '1');
|
|
472
|
+
});
|
|
473
|
+
params.set('enableGreeks', 'true');
|
|
474
|
+
const url = `${this.apiBaseUrl}/marketdata/stream/options/quotes?${params.toString()}`;
|
|
475
|
+
await this.startHttpStream('options', url, (data) => {
|
|
476
|
+
this.handleOptionQuoteData(data);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Starts an HTTP streaming connection.
|
|
481
|
+
*/
|
|
482
|
+
async startHttpStream(streamId, url, onData) {
|
|
483
|
+
// Stop any existing stream with this ID
|
|
484
|
+
this.stopStream(streamId);
|
|
485
|
+
const controller = new AbortController();
|
|
486
|
+
this.activeStreams.set(streamId, controller);
|
|
487
|
+
try {
|
|
488
|
+
const response = await fetch(url, {
|
|
489
|
+
method: 'GET',
|
|
490
|
+
headers: this.getStreamHeaders(),
|
|
491
|
+
signal: controller.signal,
|
|
492
|
+
});
|
|
493
|
+
if (!response.ok) {
|
|
494
|
+
if (response.status === 401) {
|
|
495
|
+
this.emit('error', new Error('TradeStation stream authentication failed'));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
this.emit('error', new Error(`TradeStation stream failed: ${response.statusText}`));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (!response.body) {
|
|
502
|
+
this.emit('error', new Error('TradeStation stream response has no body'));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
// Process the stream
|
|
506
|
+
this.processStream(streamId, response.body, onData);
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
510
|
+
// Stream was intentionally aborted
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Processes an HTTP chunked stream.
|
|
518
|
+
*/
|
|
519
|
+
async processStream(streamId, body, onData) {
|
|
520
|
+
const reader = body.getReader();
|
|
521
|
+
const decoder = new TextDecoder();
|
|
522
|
+
let buffer = '';
|
|
523
|
+
try {
|
|
524
|
+
while (true) {
|
|
525
|
+
const { done, value } = await reader.read();
|
|
526
|
+
if (done) {
|
|
527
|
+
// Stream ended
|
|
528
|
+
this.handleStreamEnd(streamId);
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
// Decode chunk and add to buffer
|
|
532
|
+
buffer += decoder.decode(value, { stream: true });
|
|
533
|
+
// Process complete JSON objects
|
|
534
|
+
// TradeStation separates objects with newlines
|
|
535
|
+
const lines = buffer.split('\n');
|
|
536
|
+
buffer = lines.pop() ?? ''; // Keep incomplete line in buffer
|
|
537
|
+
for (const line of lines) {
|
|
538
|
+
const trimmed = line.trim();
|
|
539
|
+
if (!trimmed)
|
|
540
|
+
continue;
|
|
541
|
+
try {
|
|
542
|
+
const data = JSON.parse(trimmed);
|
|
543
|
+
onData(data);
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
// May be chunk boundary or malformed JSON, skip
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
556
|
+
this.handleStreamEnd(streamId);
|
|
557
|
+
}
|
|
558
|
+
finally {
|
|
559
|
+
reader.releaseLock();
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Handles stream end/disconnect.
|
|
564
|
+
*/
|
|
565
|
+
handleStreamEnd(streamId) {
|
|
566
|
+
this.activeStreams.delete(streamId);
|
|
567
|
+
if (this.activeStreams.size === 0) {
|
|
568
|
+
this.emit('disconnected', { reason: 'All streams ended' });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Stops a stream by ID.
|
|
573
|
+
*/
|
|
574
|
+
stopStream(streamId) {
|
|
575
|
+
const controller = this.activeStreams.get(streamId);
|
|
576
|
+
if (controller) {
|
|
577
|
+
controller.abort();
|
|
578
|
+
this.activeStreams.delete(streamId);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Handles incoming quote stream data.
|
|
583
|
+
*/
|
|
584
|
+
handleQuoteData(data) {
|
|
585
|
+
// Check for stream status
|
|
586
|
+
if (data.StreamStatus) {
|
|
587
|
+
if (data.StreamStatus === 'GoAway') {
|
|
588
|
+
// Server is terminating stream, need to reconnect
|
|
589
|
+
this.handleStreamGoAway('quotes');
|
|
590
|
+
}
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
// Check for errors
|
|
594
|
+
if (data.Error) {
|
|
595
|
+
this.emit('error', new Error(`TradeStation quote error: ${data.Error}`));
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (!data.Symbol)
|
|
599
|
+
return;
|
|
600
|
+
const ticker = this.normalizeQuote(data);
|
|
601
|
+
this.tickerCache.set(ticker.symbol, ticker);
|
|
602
|
+
this.emit('tickerUpdate', ticker);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Handles incoming option quote stream data.
|
|
606
|
+
*/
|
|
607
|
+
handleOptionQuoteData(data) {
|
|
608
|
+
// Check for stream status
|
|
609
|
+
if (data.StreamStatus) {
|
|
610
|
+
if (data.StreamStatus === 'GoAway') {
|
|
611
|
+
this.handleStreamGoAway('options');
|
|
612
|
+
}
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (data.Error) {
|
|
616
|
+
this.emit('error', new Error(`TradeStation option error: ${data.Error}`));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (!data.Legs || data.Legs.length === 0)
|
|
620
|
+
return;
|
|
621
|
+
// Process each leg
|
|
622
|
+
for (const leg of data.Legs) {
|
|
623
|
+
if (!leg.Symbol)
|
|
624
|
+
continue;
|
|
625
|
+
const option = this.normalizeOptionQuote(data, leg);
|
|
626
|
+
if (option) {
|
|
627
|
+
this.optionCache.set(option.occSymbol, option);
|
|
628
|
+
this.emit('optionUpdate', option);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Handles option chain stream data.
|
|
634
|
+
*/
|
|
635
|
+
handleOptionChainData(underlying, data) {
|
|
636
|
+
if (data.StreamStatus) {
|
|
637
|
+
if (data.StreamStatus === 'GoAway') {
|
|
638
|
+
this.handleStreamGoAway(`chain_${underlying}`);
|
|
639
|
+
}
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (data.Error) {
|
|
643
|
+
this.emit('error', new Error(`TradeStation chain error: ${data.Error}`));
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (!data.Legs || data.Legs.length === 0)
|
|
647
|
+
return;
|
|
648
|
+
for (const leg of data.Legs) {
|
|
649
|
+
if (!leg.Symbol)
|
|
650
|
+
continue;
|
|
651
|
+
const option = this.normalizeOptionQuote(data, leg);
|
|
652
|
+
if (option) {
|
|
653
|
+
// Store base OI
|
|
654
|
+
if (leg.OpenInterest !== undefined) {
|
|
655
|
+
this.baseOpenInterest.set(option.occSymbol, leg.OpenInterest);
|
|
656
|
+
if (this.verbose) {
|
|
657
|
+
console.log(`[TradeStation:OI] Base OI set for ${option.occSymbol}: ${leg.OpenInterest}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (!this.cumulativeOIChange.has(option.occSymbol)) {
|
|
661
|
+
this.cumulativeOIChange.set(option.occSymbol, 0);
|
|
662
|
+
}
|
|
663
|
+
this.optionCache.set(option.occSymbol, option);
|
|
664
|
+
this.emit('optionUpdate', option);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Handles GoAway stream status - server is terminating stream.
|
|
670
|
+
*/
|
|
671
|
+
handleStreamGoAway(streamId) {
|
|
672
|
+
// Remove the stream and attempt to restart after a delay
|
|
673
|
+
this.stopStream(streamId);
|
|
674
|
+
setTimeout(() => {
|
|
675
|
+
if (!this.connected)
|
|
676
|
+
return;
|
|
677
|
+
if (streamId === 'quotes' && this.subscribedTickers.size > 0) {
|
|
678
|
+
this.startQuoteStream(Array.from(this.subscribedTickers));
|
|
679
|
+
}
|
|
680
|
+
else if (streamId === 'options' && this.subscribedOptions.size > 0) {
|
|
681
|
+
this.startOptionQuoteStream(Array.from(this.subscribedOptions));
|
|
682
|
+
}
|
|
683
|
+
else if (streamId.startsWith('chain_')) {
|
|
684
|
+
const underlying = streamId.replace('chain_', '');
|
|
685
|
+
this.streamOptionChain(underlying);
|
|
686
|
+
}
|
|
687
|
+
}, this.baseReconnectDelay);
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Normalizes TradeStation quote to NormalizedTicker.
|
|
691
|
+
*/
|
|
692
|
+
normalizeQuote(quote) {
|
|
693
|
+
const bid = this.parseNumber(quote.Bid);
|
|
694
|
+
const ask = this.parseNumber(quote.Ask);
|
|
695
|
+
const last = this.parseNumber(quote.Last);
|
|
696
|
+
return {
|
|
697
|
+
symbol: quote.Symbol,
|
|
698
|
+
spot: bid > 0 && ask > 0 ? (bid + ask) / 2 : last,
|
|
699
|
+
bid,
|
|
700
|
+
bidSize: this.parseNumber(quote.BidSize),
|
|
701
|
+
ask,
|
|
702
|
+
askSize: this.parseNumber(quote.AskSize),
|
|
703
|
+
last,
|
|
704
|
+
volume: this.parseNumber(quote.Volume),
|
|
705
|
+
timestamp: quote.TradeTime ? new Date(quote.TradeTime).getTime() : Date.now(),
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Normalizes TradeStation option quote to NormalizedOption.
|
|
710
|
+
*/
|
|
711
|
+
normalizeOptionQuote(data, leg) {
|
|
712
|
+
// Convert TradeStation symbol to OCC format
|
|
713
|
+
const occSymbol = this.toOCCSymbol(leg.Symbol);
|
|
714
|
+
if (!occSymbol)
|
|
715
|
+
return null;
|
|
716
|
+
// Parse OCC symbol for details
|
|
717
|
+
let parsed;
|
|
718
|
+
try {
|
|
719
|
+
parsed = (0, occ_1.parseOCCSymbol)(occSymbol);
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
// Try to extract from leg data
|
|
723
|
+
if (!leg.Underlying || !leg.ExpirationDate || !leg.StrikePrice || !leg.OptionType) {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
parsed = {
|
|
727
|
+
symbol: leg.Underlying,
|
|
728
|
+
expiration: new Date(leg.ExpirationDate),
|
|
729
|
+
optionType: leg.OptionType.toLowerCase(),
|
|
730
|
+
strike: parseFloat(leg.StrikePrice),
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
const bid = this.parseNumber(data.Bid);
|
|
734
|
+
const ask = this.parseNumber(data.Ask);
|
|
735
|
+
const last = this.parseNumber(data.Last);
|
|
736
|
+
const existingOI = this.baseOpenInterest.get(occSymbol) ?? 0;
|
|
737
|
+
return {
|
|
738
|
+
occSymbol,
|
|
739
|
+
underlying: leg.Underlying ?? parsed.symbol,
|
|
740
|
+
strike: parseFloat(leg.StrikePrice ?? parsed.strike.toString()),
|
|
741
|
+
expiration: leg.ExpirationDate ?? parsed.expiration.toISOString().split('T')[0],
|
|
742
|
+
expirationTimestamp: parsed.expiration.getTime(),
|
|
743
|
+
optionType: (leg.OptionType?.toLowerCase() ?? parsed.optionType),
|
|
744
|
+
bid,
|
|
745
|
+
bidSize: data.BidSize ?? 0,
|
|
746
|
+
ask,
|
|
747
|
+
askSize: data.AskSize ?? 0,
|
|
748
|
+
mark: bid > 0 && ask > 0 ? (bid + ask) / 2 : last,
|
|
749
|
+
last,
|
|
750
|
+
volume: data.Volume ?? 0,
|
|
751
|
+
openInterest: data.DailyOpenInterest ?? leg.OpenInterest ?? existingOI,
|
|
752
|
+
liveOpenInterest: this.calculateLiveOpenInterest(occSymbol),
|
|
753
|
+
impliedVolatility: this.parseNumber(data.ImpliedVolatility),
|
|
754
|
+
timestamp: Date.now(),
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Converts TradeStation option symbol to OCC format.
|
|
759
|
+
* TradeStation format: "MSFT 220916C305" or "MSFT 220916C305.00"
|
|
760
|
+
* OCC format: "MSFT220916C00305000"
|
|
761
|
+
*/
|
|
762
|
+
toOCCSymbol(tsSymbol) {
|
|
763
|
+
if (!tsSymbol)
|
|
764
|
+
return null;
|
|
765
|
+
// Already in OCC format?
|
|
766
|
+
if (OCC_OPTION_PATTERN.test(tsSymbol.replace(/\s+/g, ''))) {
|
|
767
|
+
return tsSymbol.replace(/\s+/g, '');
|
|
768
|
+
}
|
|
769
|
+
// Parse TradeStation format
|
|
770
|
+
const match = tsSymbol.match(/^([A-Z]+)\s+(\d{6})([CP])(\d+(?:\.\d+)?)$/);
|
|
771
|
+
if (!match)
|
|
772
|
+
return null;
|
|
773
|
+
const [, root, dateStr, optType, strikeStr] = match;
|
|
774
|
+
const strike = parseFloat(strikeStr);
|
|
775
|
+
const strikeFormatted = Math.round(strike * 1000).toString().padStart(8, '0');
|
|
776
|
+
return `${root}${dateStr}${optType}${strikeFormatted}`;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Converts OCC symbol to TradeStation format.
|
|
780
|
+
* OCC format: "MSFT220916C00305000"
|
|
781
|
+
* TradeStation format: "MSFT 220916C305"
|
|
782
|
+
*/
|
|
783
|
+
toTradeStationOptionSymbol(symbol) {
|
|
784
|
+
// If already in TradeStation format, return as-is
|
|
785
|
+
if (TS_OPTION_PATTERN.test(symbol)) {
|
|
786
|
+
return symbol;
|
|
787
|
+
}
|
|
788
|
+
// Parse OCC format
|
|
789
|
+
try {
|
|
790
|
+
const parsed = (0, occ_1.parseOCCSymbol)(symbol);
|
|
791
|
+
const dateStr = [
|
|
792
|
+
parsed.expiration.getFullYear().toString().slice(-2),
|
|
793
|
+
(parsed.expiration.getMonth() + 1).toString().padStart(2, '0'),
|
|
794
|
+
parsed.expiration.getDate().toString().padStart(2, '0'),
|
|
795
|
+
].join('');
|
|
796
|
+
const optType = parsed.optionType === 'call' ? 'C' : 'P';
|
|
797
|
+
// Format strike - remove trailing zeros
|
|
798
|
+
let strikeStr = parsed.strike.toString();
|
|
799
|
+
if (parsed.strike % 1 === 0) {
|
|
800
|
+
strikeStr = parsed.strike.toFixed(0);
|
|
801
|
+
}
|
|
802
|
+
return `${parsed.symbol} ${dateStr}${optType}${strikeStr}`;
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
return symbol;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Determines aggressor side from trade price vs NBBO.
|
|
810
|
+
*/
|
|
811
|
+
determineAggressorSide(tradePrice, bid, ask) {
|
|
812
|
+
if (bid <= 0 || ask <= 0)
|
|
813
|
+
return 'unknown';
|
|
814
|
+
const spread = ask - bid;
|
|
815
|
+
const tolerance = spread > 0 ? spread * 0.001 : 0.001;
|
|
816
|
+
if (tradePrice >= ask - tolerance) {
|
|
817
|
+
return 'buy';
|
|
818
|
+
}
|
|
819
|
+
else if (tradePrice <= bid + tolerance) {
|
|
820
|
+
return 'sell';
|
|
821
|
+
}
|
|
822
|
+
return 'unknown';
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Calculates estimated OI change from trade.
|
|
826
|
+
*/
|
|
827
|
+
calculateOIChangeFromTrade(aggressorSide, size, _optionType) {
|
|
828
|
+
if (aggressorSide === 'unknown')
|
|
829
|
+
return 0;
|
|
830
|
+
return aggressorSide === 'buy' ? size : -size;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Calculates live open interest.
|
|
834
|
+
*/
|
|
835
|
+
calculateLiveOpenInterest(occSymbol) {
|
|
836
|
+
const baseOI = this.baseOpenInterest.get(occSymbol) ?? 0;
|
|
837
|
+
const cumulativeChange = this.cumulativeOIChange.get(occSymbol) ?? 0;
|
|
838
|
+
return Math.max(0, baseOI + cumulativeChange);
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Checks if a symbol is an option symbol.
|
|
842
|
+
*/
|
|
843
|
+
isOptionSymbol(symbol) {
|
|
844
|
+
return TS_OPTION_PATTERN.test(symbol) || OCC_OPTION_PATTERN.test(symbol);
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Parses a numeric string value.
|
|
848
|
+
*/
|
|
849
|
+
parseNumber(value) {
|
|
850
|
+
if (value === undefined || value === null)
|
|
851
|
+
return 0;
|
|
852
|
+
if (typeof value === 'number')
|
|
853
|
+
return isNaN(value) ? 0 : value;
|
|
854
|
+
const num = parseFloat(value);
|
|
855
|
+
return isNaN(num) ? 0 : num;
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Emits an event to all listeners.
|
|
859
|
+
*/
|
|
860
|
+
emit(event, data) {
|
|
861
|
+
const listeners = this.eventListeners.get(event);
|
|
862
|
+
if (listeners) {
|
|
863
|
+
listeners.forEach(listener => {
|
|
864
|
+
try {
|
|
865
|
+
listener(data);
|
|
866
|
+
}
|
|
867
|
+
catch (error) {
|
|
868
|
+
console.error('Event listener error:', error);
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Sleep utility.
|
|
875
|
+
*/
|
|
876
|
+
sleep(ms) {
|
|
877
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
exports.TradeStationClient = TradeStationClient;
|