@adaptic/utils 0.0.1005 → 0.0.1007
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/dist/index.cjs +1992 -1935
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +1992 -1935
- package/dist/index.mjs.map +1 -1
- package/dist/test.js +155 -5
- package/dist/test.js.map +1 -1
- package/dist/types/alpaca-market-data-api.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -2175,1436 +2175,1561 @@ function validateAlphaVantageApiKey(apiKey) {
|
|
|
2175
2175
|
}
|
|
2176
2176
|
}
|
|
2177
2177
|
|
|
2178
|
+
const DEFAULT_RETRY_CONFIG = {
|
|
2179
|
+
maxRetries: 3,
|
|
2180
|
+
baseDelayMs: 1000,
|
|
2181
|
+
maxDelayMs: 30000,
|
|
2182
|
+
retryableStatusCodes: [429, 500, 502, 503, 504],
|
|
2183
|
+
retryOnNetworkError: true,
|
|
2184
|
+
};
|
|
2178
2185
|
/**
|
|
2179
|
-
*
|
|
2180
|
-
*
|
|
2181
|
-
* This module provides a comprehensive error handling system for external API integrations,
|
|
2182
|
-
* including Alpaca, Massive, and AlphaVantage services.
|
|
2186
|
+
* Node.js / undici / system error codes that represent transient network
|
|
2187
|
+
* conditions. Present on `error.code` for net/http/dns/undici errors.
|
|
2183
2188
|
*/
|
|
2189
|
+
const RETRYABLE_ERROR_CODES = new Set([
|
|
2190
|
+
"ETIMEDOUT",
|
|
2191
|
+
"ESOCKETTIMEDOUT",
|
|
2192
|
+
"ECONNRESET",
|
|
2193
|
+
"ECONNREFUSED",
|
|
2194
|
+
"EHOSTUNREACH",
|
|
2195
|
+
"ENETUNREACH",
|
|
2196
|
+
"EAI_AGAIN",
|
|
2197
|
+
"EPIPE",
|
|
2198
|
+
"ECONNABORTED",
|
|
2199
|
+
"ENOTFOUND",
|
|
2200
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
2201
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
2202
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
2203
|
+
"UND_ERR_SOCKET",
|
|
2204
|
+
"UND_ERR_CLOSED",
|
|
2205
|
+
"UND_ERR_REQ_CONTENT_LENGTH_MISMATCH",
|
|
2206
|
+
]);
|
|
2184
2207
|
/**
|
|
2185
|
-
*
|
|
2186
|
-
*
|
|
2208
|
+
* Error constructor names / `error.name` values that indicate transient
|
|
2209
|
+
* abort / timeout conditions.
|
|
2187
2210
|
*/
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
this.service = service;
|
|
2198
|
-
this.isRetryable = isRetryable;
|
|
2199
|
-
this.cause = cause;
|
|
2200
|
-
this.name = this.constructor.name;
|
|
2201
|
-
// Maintains proper stack trace for where error was thrown (only available on V8)
|
|
2202
|
-
if (Error.captureStackTrace) {
|
|
2203
|
-
Error.captureStackTrace(this, this.constructor);
|
|
2204
|
-
}
|
|
2205
|
-
}
|
|
2206
|
-
}
|
|
2211
|
+
const RETRYABLE_ERROR_NAMES = new Set([
|
|
2212
|
+
"AbortError",
|
|
2213
|
+
"TimeoutError",
|
|
2214
|
+
"FetchError",
|
|
2215
|
+
"RequestTimeoutError",
|
|
2216
|
+
"ConnectTimeoutError",
|
|
2217
|
+
"HeadersTimeoutError",
|
|
2218
|
+
"BodyTimeoutError",
|
|
2219
|
+
]);
|
|
2207
2220
|
/**
|
|
2208
|
-
*
|
|
2209
|
-
*
|
|
2221
|
+
* Message-pattern fallback for libraries that discard error codes/names but
|
|
2222
|
+
* preserve text (e.g., some Apollo/axios wrappers).
|
|
2210
2223
|
*/
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2224
|
+
const RETRYABLE_MESSAGE_PATTERNS = [
|
|
2225
|
+
/aborted/i,
|
|
2226
|
+
/timeout/i,
|
|
2227
|
+
/timed out/i,
|
|
2228
|
+
/network error/i,
|
|
2229
|
+
/socket hang up/i,
|
|
2230
|
+
/connection (reset|refused|closed)/i,
|
|
2231
|
+
/ECONNRESET/,
|
|
2232
|
+
/ETIMEDOUT/,
|
|
2233
|
+
/ECONNREFUSED/,
|
|
2234
|
+
/EAI_AGAIN/,
|
|
2235
|
+
/UND_ERR_/,
|
|
2236
|
+
];
|
|
2220
2237
|
/**
|
|
2221
|
-
*
|
|
2222
|
-
*
|
|
2238
|
+
* Walks the `error.cause` chain (capped to avoid cycles) and tests whether
|
|
2239
|
+
* any link along the chain looks like a transient network error. Modern APIs
|
|
2240
|
+
* (undici, fetch, Apollo Client 3.8+) wrap the root network failure as a
|
|
2241
|
+
* `.cause`, so the surface `Error` may report a generic message while the
|
|
2242
|
+
* actionable signal lives one or more levels deeper.
|
|
2243
|
+
*
|
|
2244
|
+
* Exported for use by downstream consumers (engine services, per-call catch
|
|
2245
|
+
* blocks, application-level loggers) that need to demote recoverable
|
|
2246
|
+
* transient errors from ERROR to WARN. Aligns the whole stack on a single
|
|
2247
|
+
* canonical classifier so MassiveAPI, AlpacaAPI, and application-layer
|
|
2248
|
+
* retry handlers all treat the same network blips identically.
|
|
2223
2249
|
*/
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2250
|
+
function isTransientNetworkError(error) {
|
|
2251
|
+
const MAX_CAUSE_DEPTH = 6;
|
|
2252
|
+
let current = error;
|
|
2253
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH && current; depth++) {
|
|
2254
|
+
if (current instanceof Error || typeof current === "object") {
|
|
2255
|
+
const err = current;
|
|
2256
|
+
if (typeof err.name === "string" && RETRYABLE_ERROR_NAMES.has(err.name)) {
|
|
2257
|
+
return true;
|
|
2258
|
+
}
|
|
2259
|
+
if (typeof err.code === "string" && RETRYABLE_ERROR_CODES.has(err.code)) {
|
|
2260
|
+
return true;
|
|
2261
|
+
}
|
|
2262
|
+
if (typeof err.message === "string") {
|
|
2263
|
+
for (const pattern of RETRYABLE_MESSAGE_PATTERNS) {
|
|
2264
|
+
if (pattern.test(err.message)) {
|
|
2265
|
+
return true;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
current = err.cause;
|
|
2270
|
+
}
|
|
2271
|
+
else {
|
|
2272
|
+
break;
|
|
2273
|
+
}
|
|
2231
2274
|
}
|
|
2275
|
+
return false;
|
|
2232
2276
|
}
|
|
2233
2277
|
/**
|
|
2234
|
-
*
|
|
2235
|
-
*
|
|
2278
|
+
* Analyzes an error and determines if it's retryable.
|
|
2279
|
+
* @param error - The error to analyze
|
|
2280
|
+
* @param response - Optional Response object for HTTP errors
|
|
2281
|
+
* @param config - Retry configuration
|
|
2282
|
+
* @returns Structured error details
|
|
2236
2283
|
*/
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2284
|
+
function analyzeError(error, response, config) {
|
|
2285
|
+
// Handle Response objects with error status codes
|
|
2286
|
+
if (response && !response.ok) {
|
|
2287
|
+
const status = response.status;
|
|
2288
|
+
// Rate limit errors - always retryable
|
|
2289
|
+
if (status === 429) {
|
|
2290
|
+
const retryAfterHeader = response.headers.get("Retry-After");
|
|
2291
|
+
const retryAfter = retryAfterHeader
|
|
2292
|
+
? parseInt(retryAfterHeader, 10) * 1000
|
|
2293
|
+
: undefined;
|
|
2294
|
+
return {
|
|
2295
|
+
type: "RATE_LIMIT",
|
|
2296
|
+
reason: "Rate limit exceeded",
|
|
2297
|
+
status,
|
|
2298
|
+
retryAfter,
|
|
2299
|
+
isRetryable: true,
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
// Authentication errors - never retry
|
|
2303
|
+
if (status === 401 || status === 403) {
|
|
2304
|
+
return {
|
|
2305
|
+
type: "AUTH_ERROR",
|
|
2306
|
+
reason: status === 401
|
|
2307
|
+
? "Authentication failed - invalid credentials"
|
|
2308
|
+
: "Access forbidden - insufficient permissions",
|
|
2309
|
+
status,
|
|
2310
|
+
isRetryable: false,
|
|
2311
|
+
};
|
|
2312
|
+
}
|
|
2313
|
+
// Server errors - check if in retryable list
|
|
2314
|
+
if (status >= 500 && status < 600) {
|
|
2315
|
+
return {
|
|
2316
|
+
type: "SERVER_ERROR",
|
|
2317
|
+
reason: `Server error (${status})`,
|
|
2318
|
+
status,
|
|
2319
|
+
isRetryable: config.retryableStatusCodes.includes(status),
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2322
|
+
// Other client errors - never retry
|
|
2323
|
+
if (status >= 400 && status < 500) {
|
|
2324
|
+
return {
|
|
2325
|
+
type: "CLIENT_ERROR",
|
|
2326
|
+
reason: `Client error (${status})`,
|
|
2327
|
+
status,
|
|
2328
|
+
isRetryable: false,
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2244
2331
|
}
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
timeoutMs;
|
|
2254
|
-
constructor(message, service, timeoutMs, cause) {
|
|
2255
|
-
super(message, "TIMEOUT", service, true, // Timeouts are always retryable
|
|
2256
|
-
cause);
|
|
2257
|
-
this.service = service;
|
|
2258
|
-
this.timeoutMs = timeoutMs;
|
|
2332
|
+
// Handle network errors (TypeError from fetch API)
|
|
2333
|
+
if (error instanceof TypeError && error.message.includes("fetch")) {
|
|
2334
|
+
return {
|
|
2335
|
+
type: "NETWORK_ERROR",
|
|
2336
|
+
reason: "Network connectivity issue",
|
|
2337
|
+
status: null,
|
|
2338
|
+
isRetryable: config.retryOnNetworkError,
|
|
2339
|
+
};
|
|
2259
2340
|
}
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
this.service = service;
|
|
2273
|
-
this.invalidField = invalidField;
|
|
2341
|
+
// Handle transient network conditions: AbortError, TimeoutError,
|
|
2342
|
+
// Node/undici error codes (ETIMEDOUT, ECONNRESET, UND_ERR_*), and
|
|
2343
|
+
// wrapped failures exposed via error.cause. This catches the broad class
|
|
2344
|
+
// of infrastructure flakes that the TypeError-only check above misses.
|
|
2345
|
+
if (isTransientNetworkError(error)) {
|
|
2346
|
+
const reason = error instanceof Error ? error.message : "Transient network error";
|
|
2347
|
+
return {
|
|
2348
|
+
type: "NETWORK_ERROR",
|
|
2349
|
+
reason,
|
|
2350
|
+
status: null,
|
|
2351
|
+
isRetryable: config.retryOnNetworkError,
|
|
2352
|
+
};
|
|
2274
2353
|
}
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
}
|
|
2321
|
-
/**
|
|
2322
|
-
* Rate limit errors (429)
|
|
2323
|
-
* Used when API rate limits are exceeded
|
|
2324
|
-
* Always retryable, often with retry-after header information
|
|
2325
|
-
*/
|
|
2326
|
-
class RateLimitError extends AdapticUtilsError {
|
|
2327
|
-
service;
|
|
2328
|
-
retryAfterMs;
|
|
2329
|
-
constructor(message, service, retryAfterMs, cause) {
|
|
2330
|
-
super(message, "RATE_LIMIT", service, true, // Rate limit errors are always retryable
|
|
2331
|
-
cause);
|
|
2332
|
-
this.service = service;
|
|
2333
|
-
this.retryAfterMs = retryAfterMs;
|
|
2334
|
-
}
|
|
2335
|
-
}
|
|
2336
|
-
/**
|
|
2337
|
-
* WebSocket connection errors
|
|
2338
|
-
* Used for WebSocket-specific connection and communication failures
|
|
2339
|
-
* Retryability depends on the specific error condition
|
|
2340
|
-
*/
|
|
2341
|
-
class WebSocketError extends AdapticUtilsError {
|
|
2342
|
-
service;
|
|
2343
|
-
constructor(message, service, isRetryable = true, cause) {
|
|
2344
|
-
super(message, "WEBSOCKET_ERROR", service, isRetryable, cause);
|
|
2345
|
-
this.service = service;
|
|
2346
|
-
}
|
|
2347
|
-
}
|
|
2348
|
-
/**
|
|
2349
|
-
* Network errors (connection failures, DNS issues, etc.)
|
|
2350
|
-
* Used for low-level network failures
|
|
2351
|
-
* Always retryable as network issues are often transient
|
|
2352
|
-
*/
|
|
2353
|
-
class NetworkError extends AdapticUtilsError {
|
|
2354
|
-
service;
|
|
2355
|
-
constructor(message, service, cause) {
|
|
2356
|
-
super(message, "NETWORK_ERROR", service, true, // Network errors are always retryable
|
|
2357
|
-
cause);
|
|
2358
|
-
this.service = service;
|
|
2359
|
-
}
|
|
2360
|
-
}
|
|
2361
|
-
/**
|
|
2362
|
-
* Unsupported brokerage provider errors
|
|
2363
|
-
* Thrown when a broker operation is requested for a provider that has no
|
|
2364
|
-
* implemented integration (e.g. IBKR or COINBASE before their adapters land,
|
|
2365
|
-
* or an unrecognised provider string from an untyped caller).
|
|
2366
|
-
* Never retryable — the caller must route to a supported provider.
|
|
2367
|
-
*/
|
|
2368
|
-
class UnsupportedBrokerError extends AdapticUtilsError {
|
|
2369
|
-
provider;
|
|
2370
|
-
constructor(
|
|
2371
|
-
/** The provider that was requested but is not supported. */
|
|
2372
|
-
provider, cause) {
|
|
2373
|
-
super(`Brokerage provider "${provider}" is not supported. Supported providers: ALPACA`, "UNSUPPORTED_BROKER", "broker", false, // Unsupported providers are never retryable
|
|
2374
|
-
cause);
|
|
2375
|
-
this.provider = provider;
|
|
2354
|
+
// Handle error objects with messages
|
|
2355
|
+
if (error instanceof Error) {
|
|
2356
|
+
// Parse error messages that might contain status information
|
|
2357
|
+
if (error.message.includes("429") || error.message.includes("RATE_LIMIT")) {
|
|
2358
|
+
const match = error.message.match(/RATE_LIMIT: 429:(\d+)/);
|
|
2359
|
+
const retryAfter = match ? parseInt(match[1], 10) : undefined;
|
|
2360
|
+
return {
|
|
2361
|
+
type: "RATE_LIMIT",
|
|
2362
|
+
reason: "Rate limit exceeded",
|
|
2363
|
+
status: 429,
|
|
2364
|
+
retryAfter,
|
|
2365
|
+
isRetryable: true,
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
if (error.message.includes("401") ||
|
|
2369
|
+
error.message.includes("403") ||
|
|
2370
|
+
error.message.includes("AUTH_ERROR")) {
|
|
2371
|
+
const status = error.message.includes("401") ? 401 : 403;
|
|
2372
|
+
return {
|
|
2373
|
+
type: "AUTH_ERROR",
|
|
2374
|
+
reason: `Authentication error (${status})`,
|
|
2375
|
+
status,
|
|
2376
|
+
isRetryable: false,
|
|
2377
|
+
};
|
|
2378
|
+
}
|
|
2379
|
+
if (error.message.includes("SERVER_ERROR") ||
|
|
2380
|
+
error.message.match(/50[0-9]/)) {
|
|
2381
|
+
const statusMatch = error.message.match(/50[0-9]/);
|
|
2382
|
+
const status = statusMatch ? parseInt(statusMatch[0], 10) : 500;
|
|
2383
|
+
return {
|
|
2384
|
+
type: "SERVER_ERROR",
|
|
2385
|
+
reason: `Server error (${status})`,
|
|
2386
|
+
status,
|
|
2387
|
+
isRetryable: config.retryableStatusCodes.includes(status),
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
if (error.message.includes("network") ||
|
|
2391
|
+
error.message.includes("NETWORK_ERROR")) {
|
|
2392
|
+
return {
|
|
2393
|
+
type: "NETWORK_ERROR",
|
|
2394
|
+
reason: error.message,
|
|
2395
|
+
status: null,
|
|
2396
|
+
isRetryable: config.retryOnNetworkError,
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2376
2399
|
}
|
|
2400
|
+
// Unknown error - not retryable by default for safety
|
|
2401
|
+
return {
|
|
2402
|
+
type: "UNKNOWN",
|
|
2403
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
2404
|
+
status: null,
|
|
2405
|
+
isRetryable: false,
|
|
2406
|
+
};
|
|
2377
2407
|
}
|
|
2378
2408
|
/**
|
|
2379
|
-
*
|
|
2380
|
-
*
|
|
2381
|
-
*
|
|
2409
|
+
* Calculates the delay before the next retry attempt using exponential backoff with jitter.
|
|
2410
|
+
* @param attempt - Current attempt number (1-indexed)
|
|
2411
|
+
* @param baseDelay - Base delay in milliseconds
|
|
2412
|
+
* @param maxDelay - Maximum delay in milliseconds
|
|
2413
|
+
* @returns Delay in milliseconds
|
|
2382
2414
|
*/
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2415
|
+
function calculateBackoff(attempt, baseDelay, maxDelay) {
|
|
2416
|
+
// Exponential backoff: baseDelay * 2^(attempt-1)
|
|
2417
|
+
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
|
|
2418
|
+
// Cap at maxDelay
|
|
2419
|
+
const cappedDelay = Math.min(exponentialDelay, maxDelay);
|
|
2420
|
+
// Add jitter (random value between 0% and 25% of the delay)
|
|
2421
|
+
const jitter = Math.random() * cappedDelay * 0.25;
|
|
2422
|
+
return Math.floor(cappedDelay + jitter);
|
|
2390
2423
|
}
|
|
2391
|
-
|
|
2392
2424
|
/**
|
|
2393
|
-
*
|
|
2425
|
+
* Wraps an async function with retry logic and exponential backoff.
|
|
2394
2426
|
*
|
|
2395
|
-
*
|
|
2396
|
-
*
|
|
2427
|
+
* This utility handles transient errors in external API calls by automatically retrying
|
|
2428
|
+
* failed requests with intelligent backoff strategies. It respects rate limit headers,
|
|
2429
|
+
* fails fast on non-retryable errors, and provides detailed logging.
|
|
2430
|
+
*
|
|
2431
|
+
* @template T - The return type of the wrapped function
|
|
2432
|
+
* @param fn - The async function to wrap with retry logic
|
|
2433
|
+
* @param config - Retry configuration (merged with defaults)
|
|
2434
|
+
* @param label - A descriptive label for logging (e.g., 'Massive.fetchTickerInfo')
|
|
2435
|
+
* @returns A promise that resolves to the function's return value
|
|
2436
|
+
* @throws The last error encountered if all retries are exhausted
|
|
2397
2437
|
*
|
|
2398
2438
|
* @example
|
|
2399
2439
|
* ```typescript
|
|
2400
|
-
*
|
|
2440
|
+
* // Basic usage with defaults
|
|
2441
|
+
* const data = await withRetry(
|
|
2442
|
+
* async () => fetch('https://api.example.com/data'),
|
|
2443
|
+
* {},
|
|
2444
|
+
* 'ExampleAPI.fetchData'
|
|
2445
|
+
* );
|
|
2401
2446
|
*
|
|
2402
|
-
* //
|
|
2403
|
-
* await
|
|
2404
|
-
*
|
|
2447
|
+
* // Custom configuration for rate-limited API
|
|
2448
|
+
* const result = await withRetry(
|
|
2449
|
+
* async () => alphaVantageAPI.getQuote(symbol),
|
|
2450
|
+
* {
|
|
2451
|
+
* maxRetries: 5,
|
|
2452
|
+
* baseDelayMs: 5000,
|
|
2453
|
+
* maxDelayMs: 60000,
|
|
2454
|
+
* onRetry: (attempt, error) => {
|
|
2455
|
+
* getLogger().info(`Retry ${attempt} after error:`, error);
|
|
2456
|
+
* }
|
|
2457
|
+
* },
|
|
2458
|
+
* 'AlphaVantage.getQuote'
|
|
2459
|
+
* );
|
|
2405
2460
|
* ```
|
|
2406
2461
|
*/
|
|
2407
|
-
|
|
2408
|
-
const
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
*/
|
|
2419
|
-
const TOKENS_PER_REQUEST = 1;
|
|
2420
|
-
/**
|
|
2421
|
-
* Token bucket rate limiter implementation
|
|
2422
|
-
*
|
|
2423
|
-
* Uses the token bucket algorithm to control the rate of API requests.
|
|
2424
|
-
* Tokens are consumed on each request and refilled at a constant rate.
|
|
2425
|
-
* Requests that exceed the available tokens are queued and processed
|
|
2426
|
-
* when tokens become available.
|
|
2427
|
-
*/
|
|
2428
|
-
class TokenBucketRateLimiter {
|
|
2429
|
-
config;
|
|
2430
|
-
tokens;
|
|
2431
|
-
lastRefill;
|
|
2432
|
-
queue = [];
|
|
2433
|
-
timeoutMs;
|
|
2434
|
-
processingQueue = false;
|
|
2435
|
-
/**
|
|
2436
|
-
* Single pending timer that wakes the limiter to refill tokens and drain the
|
|
2437
|
-
* queue. Without this, a queued request would only be released by a
|
|
2438
|
-
* subsequent {@link acquire} call and would otherwise stall until its own
|
|
2439
|
-
* timeout fired. `null` means no wake-up is currently scheduled.
|
|
2440
|
-
*/
|
|
2441
|
-
wakeTimer = null;
|
|
2442
|
-
/**
|
|
2443
|
-
* Creates a new rate limiter instance
|
|
2444
|
-
*
|
|
2445
|
-
* @param config - Rate limiter configuration
|
|
2446
|
-
*
|
|
2447
|
-
* @example
|
|
2448
|
-
* ```typescript
|
|
2449
|
-
* // Alpaca: 200 requests per minute
|
|
2450
|
-
* const alpacaLimiter = new TokenBucketRateLimiter({
|
|
2451
|
-
* maxTokens: 200,
|
|
2452
|
-
* refillRate: 200 / 60, // ~3.33 per second
|
|
2453
|
-
* label: 'alpaca',
|
|
2454
|
-
* timeoutMs: 60000
|
|
2455
|
-
* });
|
|
2456
|
-
* ```
|
|
2457
|
-
*/
|
|
2458
|
-
constructor(config) {
|
|
2459
|
-
this.config = config;
|
|
2460
|
-
this.tokens = config.maxTokens;
|
|
2461
|
-
this.lastRefill = Date.now();
|
|
2462
|
-
this.timeoutMs = config.timeoutMs ?? 60000; // Default 60 second timeout
|
|
2463
|
-
}
|
|
2464
|
-
/**
|
|
2465
|
-
* Acquires a token for making an API request
|
|
2466
|
-
*
|
|
2467
|
-
* If a token is available, it is consumed immediately.
|
|
2468
|
-
* If no tokens are available, the request is queued and will resolve
|
|
2469
|
-
* when a token becomes available or reject if it times out.
|
|
2470
|
-
*
|
|
2471
|
-
* @throws {RateLimitError} If the request times out waiting for a token
|
|
2472
|
-
*
|
|
2473
|
-
* @example
|
|
2474
|
-
* ```typescript
|
|
2475
|
-
* try {
|
|
2476
|
-
* await limiter.acquire();
|
|
2477
|
-
* // Make API call
|
|
2478
|
-
* } catch (error) {
|
|
2479
|
-
* if (error instanceof RateLimitError) {
|
|
2480
|
-
* // Handle rate limit timeout
|
|
2481
|
-
* }
|
|
2482
|
-
* }
|
|
2483
|
-
* ```
|
|
2484
|
-
*/
|
|
2485
|
-
async acquire() {
|
|
2486
|
-
const logger = getLogger();
|
|
2487
|
-
this.refill();
|
|
2488
|
-
if (this.tokens > 0) {
|
|
2489
|
-
this.tokens--;
|
|
2490
|
-
logger.debug(`Rate limit token acquired for ${this.config.label}`, {
|
|
2491
|
-
remainingTokens: this.tokens,
|
|
2492
|
-
queueLength: this.queue.length,
|
|
2493
|
-
});
|
|
2494
|
-
return;
|
|
2462
|
+
async function withRetry(fn, config = {}, label = "unknown") {
|
|
2463
|
+
const fullConfig = { ...DEFAULT_RETRY_CONFIG, ...config };
|
|
2464
|
+
let lastError;
|
|
2465
|
+
for (let attempt = 1; attempt <= fullConfig.maxRetries; attempt++) {
|
|
2466
|
+
try {
|
|
2467
|
+
const result = await fn();
|
|
2468
|
+
// If we succeeded after retries, log it
|
|
2469
|
+
if (attempt > 1) {
|
|
2470
|
+
getLogger().info(`[${label}] Succeeded on attempt ${attempt}/${fullConfig.maxRetries}`);
|
|
2471
|
+
}
|
|
2472
|
+
return result;
|
|
2495
2473
|
}
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2474
|
+
catch (error) {
|
|
2475
|
+
lastError = error;
|
|
2476
|
+
// If this is the last attempt, throw the error.
|
|
2477
|
+
// Transient network classes (undici/fetch timeouts, ECONNRESET,
|
|
2478
|
+
// AbortError, etc.) are self-healing at the upstream retry layer —
|
|
2479
|
+
// the caller re-invokes on the next refresh/poll tick. Logging them
|
|
2480
|
+
// at ERROR produces alert noise that does not represent actionable
|
|
2481
|
+
// failures. Demote the transient class to WARN with a recovery hint;
|
|
2482
|
+
// reserve ERROR for non-transient final failures (auth, schema,
|
|
2483
|
+
// contract violations, unknown classes).
|
|
2484
|
+
if (attempt === fullConfig.maxRetries) {
|
|
2485
|
+
const isTransient = isTransientNetworkError(error);
|
|
2486
|
+
const logMeta = {
|
|
2487
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2488
|
+
attempts: fullConfig.maxRetries,
|
|
2489
|
+
timestamp: new Date().toISOString(),
|
|
2490
|
+
...(isTransient
|
|
2491
|
+
? {
|
|
2492
|
+
transient: true,
|
|
2493
|
+
recoveryHint: "Upstream caller should retry on next cycle",
|
|
2494
|
+
}
|
|
2495
|
+
: {}),
|
|
2496
|
+
};
|
|
2497
|
+
if (isTransient) {
|
|
2498
|
+
getLogger().warn(`[${label}] Failed after ${fullConfig.maxRetries} attempts (transient)`, logMeta);
|
|
2506
2499
|
}
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2500
|
+
else {
|
|
2501
|
+
getLogger().error(`[${label}] Failed after ${fullConfig.maxRetries} attempts`, logMeta);
|
|
2502
|
+
}
|
|
2503
|
+
throw error;
|
|
2504
|
+
}
|
|
2505
|
+
// Analyze the error to determine if we should retry
|
|
2506
|
+
const response = error instanceof Response ? error : null;
|
|
2507
|
+
const errorDetails = analyzeError(error, response, fullConfig);
|
|
2508
|
+
// If error is not retryable, fail immediately
|
|
2509
|
+
if (!errorDetails.isRetryable) {
|
|
2510
|
+
getLogger().error(`[${label}] Non-retryable error (${errorDetails.type})`, {
|
|
2511
|
+
reason: errorDetails.reason,
|
|
2512
|
+
status: errorDetails.status,
|
|
2513
|
+
timestamp: new Date().toISOString(),
|
|
2511
2514
|
});
|
|
2512
|
-
|
|
2513
|
-
}
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
if (typeof timer.unref === "function") {
|
|
2544
|
-
timer.unref();
|
|
2515
|
+
throw error;
|
|
2516
|
+
}
|
|
2517
|
+
// Calculate delay for next retry
|
|
2518
|
+
let delayMs;
|
|
2519
|
+
if (errorDetails.type === "RATE_LIMIT" && errorDetails.retryAfter) {
|
|
2520
|
+
// Use Retry-After header if available
|
|
2521
|
+
delayMs = errorDetails.retryAfter;
|
|
2522
|
+
}
|
|
2523
|
+
else if (errorDetails.type === "RATE_LIMIT") {
|
|
2524
|
+
// For rate limits without Retry-After, use a longer minimum delay
|
|
2525
|
+
delayMs = Math.max(calculateBackoff(attempt, fullConfig.baseDelayMs, fullConfig.maxDelayMs), 5000);
|
|
2526
|
+
}
|
|
2527
|
+
else {
|
|
2528
|
+
// Standard exponential backoff with jitter
|
|
2529
|
+
delayMs = calculateBackoff(attempt, fullConfig.baseDelayMs, fullConfig.maxDelayMs);
|
|
2530
|
+
}
|
|
2531
|
+
// Log the retry attempt
|
|
2532
|
+
getLogger().warn(`[${label}] Attempt ${attempt}/${fullConfig.maxRetries} failed: ${errorDetails.reason}. Retrying in ${delayMs}ms...`, {
|
|
2533
|
+
attemptNumber: attempt,
|
|
2534
|
+
totalRetries: fullConfig.maxRetries,
|
|
2535
|
+
errorType: errorDetails.type,
|
|
2536
|
+
httpStatus: errorDetails.status,
|
|
2537
|
+
retryDelay: delayMs,
|
|
2538
|
+
timestamp: new Date().toISOString(),
|
|
2539
|
+
});
|
|
2540
|
+
// Call the optional retry callback
|
|
2541
|
+
if (fullConfig.onRetry) {
|
|
2542
|
+
fullConfig.onRetry(attempt, error);
|
|
2543
|
+
}
|
|
2544
|
+
// Wait before retrying
|
|
2545
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
2545
2546
|
}
|
|
2546
|
-
this.wakeTimer = timer;
|
|
2547
2547
|
}
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2548
|
+
// This should never be reached due to the throw in the last attempt,
|
|
2549
|
+
// but TypeScript needs this to satisfy the return type
|
|
2550
|
+
throw lastError;
|
|
2551
|
+
}
|
|
2552
|
+
/**
|
|
2553
|
+
* API-specific retry configurations for different external services.
|
|
2554
|
+
* These configurations are tuned based on each API's rate limits and characteristics.
|
|
2555
|
+
*/
|
|
2556
|
+
const API_RETRY_CONFIGS = {
|
|
2557
|
+
/** Massive.com API - 5 requests/second rate limit */
|
|
2558
|
+
MASSIVE: {
|
|
2559
|
+
maxRetries: 3,
|
|
2560
|
+
baseDelayMs: 1000,
|
|
2561
|
+
maxDelayMs: 30000,
|
|
2562
|
+
retryableStatusCodes: [429, 500, 502, 503, 504],
|
|
2563
|
+
retryOnNetworkError: true,
|
|
2564
|
+
},
|
|
2565
|
+
/** Alpha Vantage API - 5 requests/minute rate limit (more strict) */
|
|
2566
|
+
ALPHA_VANTAGE: {
|
|
2567
|
+
maxRetries: 5,
|
|
2568
|
+
baseDelayMs: 5000,
|
|
2569
|
+
maxDelayMs: 60000,
|
|
2570
|
+
retryableStatusCodes: [429, 500, 502, 503, 504],
|
|
2571
|
+
retryOnNetworkError: true,
|
|
2572
|
+
},
|
|
2573
|
+
/** Alpaca API - generally reliable, shorter retry window */
|
|
2574
|
+
ALPACA: {
|
|
2575
|
+
maxRetries: 3,
|
|
2576
|
+
baseDelayMs: 1000,
|
|
2577
|
+
maxDelayMs: 30000,
|
|
2578
|
+
retryableStatusCodes: [429, 500, 502, 503, 504],
|
|
2579
|
+
retryOnNetworkError: true,
|
|
2580
|
+
},
|
|
2581
|
+
/** Generic crypto API configuration */
|
|
2582
|
+
CRYPTO: {
|
|
2583
|
+
maxRetries: 3,
|
|
2584
|
+
baseDelayMs: 1000,
|
|
2585
|
+
maxDelayMs: 30000,
|
|
2586
|
+
retryableStatusCodes: [429, 500, 502, 503, 504],
|
|
2587
|
+
retryOnNetworkError: true,
|
|
2588
|
+
},
|
|
2589
|
+
};
|
|
2590
|
+
|
|
2591
|
+
/**
|
|
2592
|
+
* Structured error type hierarchy for all API integrations
|
|
2593
|
+
*
|
|
2594
|
+
* This module provides a comprehensive error handling system for external API integrations,
|
|
2595
|
+
* including Alpaca, Massive, and AlphaVantage services.
|
|
2596
|
+
*/
|
|
2597
|
+
/**
|
|
2598
|
+
* Base error class for all @adaptic/utils errors
|
|
2599
|
+
* Extends Error with additional context about service, error code, and retry capability
|
|
2600
|
+
*/
|
|
2601
|
+
class AdapticUtilsError extends Error {
|
|
2602
|
+
code;
|
|
2603
|
+
service;
|
|
2604
|
+
isRetryable;
|
|
2605
|
+
cause;
|
|
2606
|
+
name;
|
|
2607
|
+
constructor(message, code, service, isRetryable = false, cause) {
|
|
2608
|
+
super(message);
|
|
2609
|
+
this.code = code;
|
|
2610
|
+
this.service = service;
|
|
2611
|
+
this.isRetryable = isRetryable;
|
|
2612
|
+
this.cause = cause;
|
|
2613
|
+
this.name = this.constructor.name;
|
|
2614
|
+
// Maintains proper stack trace for where error was thrown (only available on V8)
|
|
2615
|
+
if (Error.captureStackTrace) {
|
|
2616
|
+
Error.captureStackTrace(this, this.constructor);
|
|
2555
2617
|
}
|
|
2556
2618
|
}
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
const
|
|
2567
|
-
|
|
2568
|
-
this.
|
|
2569
|
-
// Process queued requests if we have tokens
|
|
2570
|
-
this.processQueue();
|
|
2571
|
-
}
|
|
2572
|
-
/**
|
|
2573
|
-
* Processes queued requests when tokens are available
|
|
2574
|
-
*
|
|
2575
|
-
* Prevents concurrent queue processing to ensure FIFO order.
|
|
2576
|
-
*/
|
|
2577
|
-
processQueue() {
|
|
2578
|
-
// Prevent concurrent queue processing
|
|
2579
|
-
if (this.processingQueue) {
|
|
2580
|
-
return;
|
|
2581
|
-
}
|
|
2582
|
-
this.processingQueue = true;
|
|
2583
|
-
const logger = getLogger();
|
|
2584
|
-
try {
|
|
2585
|
-
while (this.queue.length > 0 && this.tokens > 0) {
|
|
2586
|
-
this.tokens--;
|
|
2587
|
-
const next = this.queue.shift();
|
|
2588
|
-
if (next) {
|
|
2589
|
-
clearTimeout(next.timeoutHandle);
|
|
2590
|
-
next.resolve();
|
|
2591
|
-
logger.debug(`Processed queued request for ${this.config.label}`, {
|
|
2592
|
-
remainingTokens: this.tokens,
|
|
2593
|
-
remainingQueue: this.queue.length,
|
|
2594
|
-
});
|
|
2595
|
-
}
|
|
2596
|
-
}
|
|
2597
|
-
}
|
|
2598
|
-
finally {
|
|
2599
|
-
this.processingQueue = false;
|
|
2600
|
-
}
|
|
2601
|
-
// Keep the wake-up state consistent with the queue: if requests are still
|
|
2602
|
-
// waiting (tokens ran out mid-drain), ensure a wake-up is armed; otherwise
|
|
2603
|
-
// release any pending timer so it cannot fire needlessly.
|
|
2604
|
-
if (this.queue.length > 0) {
|
|
2605
|
-
this.scheduleQueueWake();
|
|
2606
|
-
}
|
|
2607
|
-
else {
|
|
2608
|
-
this.clearWakeTimer();
|
|
2609
|
-
}
|
|
2619
|
+
}
|
|
2620
|
+
/**
|
|
2621
|
+
* Alpaca API specific errors
|
|
2622
|
+
* Handles all errors from Alpaca trading and market data APIs
|
|
2623
|
+
*/
|
|
2624
|
+
class AlpacaApiError extends AdapticUtilsError {
|
|
2625
|
+
statusCode;
|
|
2626
|
+
constructor(message, code, statusCode, cause) {
|
|
2627
|
+
// Rate limit (429) and server errors (5xx) are retryable
|
|
2628
|
+
const isRetryable = statusCode === 429 || (statusCode !== undefined && statusCode >= 500);
|
|
2629
|
+
super(message, code, "alpaca", isRetryable, cause);
|
|
2630
|
+
this.statusCode = statusCode;
|
|
2610
2631
|
}
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2632
|
+
}
|
|
2633
|
+
/**
|
|
2634
|
+
* Massive.com API specific errors
|
|
2635
|
+
* Handles all errors from Massive market data API
|
|
2636
|
+
*/
|
|
2637
|
+
class MassiveApiError extends AdapticUtilsError {
|
|
2638
|
+
statusCode;
|
|
2639
|
+
constructor(message, code, statusCode, cause) {
|
|
2640
|
+
// Rate limit (429) and server errors (5xx) are retryable
|
|
2641
|
+
const isRetryable = statusCode === 429 || (statusCode !== undefined && statusCode >= 500);
|
|
2642
|
+
super(message, code, "massive", isRetryable, cause);
|
|
2643
|
+
this.statusCode = statusCode;
|
|
2619
2644
|
}
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2645
|
+
}
|
|
2646
|
+
/**
|
|
2647
|
+
* AlphaVantage API specific errors
|
|
2648
|
+
* Handles all errors from AlphaVantage financial data API
|
|
2649
|
+
*/
|
|
2650
|
+
class AlphaVantageError extends AdapticUtilsError {
|
|
2651
|
+
statusCode;
|
|
2652
|
+
constructor(message, code, statusCode, cause) {
|
|
2653
|
+
// Rate limit (429) and server errors (5xx) are retryable
|
|
2654
|
+
const isRetryable = statusCode === 429 || (statusCode !== undefined && statusCode >= 500);
|
|
2655
|
+
super(message, code, "alphavantage", isRetryable, cause);
|
|
2656
|
+
this.statusCode = statusCode;
|
|
2627
2657
|
}
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
this.
|
|
2642
|
-
this.queue = [];
|
|
2643
|
-
this.tokens = this.config.maxTokens;
|
|
2644
|
-
this.lastRefill = Date.now();
|
|
2645
|
-
this.processingQueue = false;
|
|
2646
|
-
logger.info(`Rate limiter reset for ${this.config.label}`, {
|
|
2647
|
-
maxTokens: this.config.maxTokens,
|
|
2648
|
-
});
|
|
2658
|
+
}
|
|
2659
|
+
/**
|
|
2660
|
+
* Network timeout errors
|
|
2661
|
+
* Used when API requests exceed configured timeout limits
|
|
2662
|
+
* Always retryable as timeouts are often transient
|
|
2663
|
+
*/
|
|
2664
|
+
class TimeoutError extends AdapticUtilsError {
|
|
2665
|
+
service;
|
|
2666
|
+
timeoutMs;
|
|
2667
|
+
constructor(message, service, timeoutMs, cause) {
|
|
2668
|
+
super(message, "TIMEOUT", service, true, // Timeouts are always retryable
|
|
2669
|
+
cause);
|
|
2670
|
+
this.service = service;
|
|
2671
|
+
this.timeoutMs = timeoutMs;
|
|
2649
2672
|
}
|
|
2650
2673
|
}
|
|
2651
2674
|
/**
|
|
2652
|
-
*
|
|
2653
|
-
*
|
|
2654
|
-
*
|
|
2655
|
-
* for each service. Adjust the configurations if you have different
|
|
2656
|
-
* tier access or if limits change.
|
|
2657
|
-
*
|
|
2658
|
-
* @example
|
|
2659
|
-
* ```typescript
|
|
2660
|
-
* import { rateLimiters } from '@adaptic/utils';
|
|
2661
|
-
*
|
|
2662
|
-
* // Use before making API calls
|
|
2663
|
-
* await rateLimiters.alpaca.acquire();
|
|
2664
|
-
* await rateLimiters.massive.acquire();
|
|
2665
|
-
* await rateLimiters.alphaVantage.acquire();
|
|
2666
|
-
* ```
|
|
2675
|
+
* Input validation errors
|
|
2676
|
+
* Used when function inputs fail validation checks
|
|
2677
|
+
* Never retryable as the inputs need to be corrected
|
|
2667
2678
|
*/
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
refillRate: 1000 / 60, // 1000 requests per 60 seconds (~16.67/sec)
|
|
2679
|
-
label: "alpaca",
|
|
2680
|
-
timeoutMs: 60000,
|
|
2681
|
-
}),
|
|
2682
|
-
/**
|
|
2683
|
-
* Massive.com API rate limiter
|
|
2684
|
-
*
|
|
2685
|
-
* Configured generously for paid unlimited tier. The bucket exists only as a
|
|
2686
|
-
* safety net against runaway loops — the 1000 token burst and 500/sec refill
|
|
2687
|
-
* should never be hit under normal operation. If the paid plan truly has no
|
|
2688
|
-
* hard limit, this just prevents accidental self-DDoS.
|
|
2689
|
-
*/
|
|
2690
|
-
massive: new TokenBucketRateLimiter({
|
|
2691
|
-
maxTokens: 1000,
|
|
2692
|
-
refillRate: 500, // 500 tokens/sec refill — effectively unlimited for paid tier
|
|
2693
|
-
label: "massive",
|
|
2694
|
-
timeoutMs: 30000,
|
|
2695
|
-
}),
|
|
2696
|
-
/**
|
|
2697
|
-
* AlphaVantage API rate limiter
|
|
2698
|
-
*
|
|
2699
|
-
* Configured for 5 requests per minute (free tier).
|
|
2700
|
-
* For premium tier (75/min), create a custom limiter:
|
|
2701
|
-
*
|
|
2702
|
-
* @example
|
|
2703
|
-
* ```typescript
|
|
2704
|
-
* const premiumAV = new TokenBucketRateLimiter({
|
|
2705
|
-
* maxTokens: 75,
|
|
2706
|
-
* refillRate: 75 / 60,
|
|
2707
|
-
* label: 'alphaVantage-premium',
|
|
2708
|
-
* timeoutMs: 60000,
|
|
2709
|
-
* });
|
|
2710
|
-
* ```
|
|
2711
|
-
*
|
|
2712
|
-
* See: https://www.alphavantage.co/premium/
|
|
2713
|
-
*/
|
|
2714
|
-
alphaVantage: new TokenBucketRateLimiter({
|
|
2715
|
-
maxTokens: 5,
|
|
2716
|
-
refillRate: 5 / 60, // 5 requests per 60 seconds (~0.083/sec)
|
|
2717
|
-
label: "alphaVantage",
|
|
2718
|
-
timeoutMs: 60000,
|
|
2719
|
-
}),
|
|
2720
|
-
};
|
|
2721
|
-
|
|
2722
|
-
const log$l = (message, options = { type: "info" }) => {
|
|
2723
|
-
log$m(message, { ...options, source: "AlpacaMarketDataAPI" });
|
|
2724
|
-
};
|
|
2725
|
-
// Default settings for market data API
|
|
2726
|
-
const DEFAULT_ADJUSTMENT = "all";
|
|
2727
|
-
// Data feed tier. SIP is full US-market-consolidated feed (requires a paid
|
|
2728
|
-
// Alpaca market-data subscription). IEX is the free tier — single-exchange,
|
|
2729
|
-
// delayed. Engine deploys running on IEX-only accounts hit chronic HTTP 403
|
|
2730
|
-
// ("subscription does not permit querying recent SIP data") at ~100/min when
|
|
2731
|
-
// this defaulted to "sip", which saturated Railway's per-replica log ingest
|
|
2732
|
-
// and hid steady-state signal. The ALPACA_MARKET_DATA_FEED env var overrides
|
|
2733
|
-
// the default. Fall back to "iex" so the free tier is safe by default; LIVE
|
|
2734
|
-
// deployments with SIP entitlements set ALPACA_MARKET_DATA_FEED=sip in their
|
|
2735
|
-
// environment to restore full data access. Per-call overrides (the optional
|
|
2736
|
-
// `feed` argument on getOptions*/getLatestBars/etc.) still win.
|
|
2737
|
-
const DEFAULT_FEED$1 = (process.env.ALPACA_MARKET_DATA_FEED || "iex");
|
|
2738
|
-
const DEFAULT_CURRENCY$1 = "USD";
|
|
2679
|
+
class ValidationError extends AdapticUtilsError {
|
|
2680
|
+
service;
|
|
2681
|
+
invalidField;
|
|
2682
|
+
constructor(message, service, invalidField, cause) {
|
|
2683
|
+
super(message, "VALIDATION_ERROR", service, false, // Validation errors are never retryable
|
|
2684
|
+
cause);
|
|
2685
|
+
this.service = service;
|
|
2686
|
+
this.invalidField = invalidField;
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2739
2689
|
/**
|
|
2740
|
-
*
|
|
2741
|
-
*
|
|
2690
|
+
* Authentication and authorization errors
|
|
2691
|
+
* Used when API credentials are invalid, expired, or lack permissions
|
|
2692
|
+
* Never retryable as credentials need to be updated
|
|
2742
2693
|
*/
|
|
2743
|
-
class
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
}
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2694
|
+
class AuthenticationError extends AdapticUtilsError {
|
|
2695
|
+
service;
|
|
2696
|
+
statusCode;
|
|
2697
|
+
constructor(message, service, statusCode, cause) {
|
|
2698
|
+
super(message, "AUTH_ERROR", service, false, // Auth errors are never retryable
|
|
2699
|
+
cause);
|
|
2700
|
+
this.service = service;
|
|
2701
|
+
this.statusCode = statusCode;
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
/**
|
|
2705
|
+
* HTTP client errors (4xx)
|
|
2706
|
+
* Used for client-side errors that are not authentication or validation related
|
|
2707
|
+
* Generally not retryable unless specific status codes indicate otherwise
|
|
2708
|
+
*/
|
|
2709
|
+
class HttpClientError extends AdapticUtilsError {
|
|
2710
|
+
service;
|
|
2711
|
+
statusCode;
|
|
2712
|
+
constructor(message, service, statusCode, cause) {
|
|
2713
|
+
super(message, "CLIENT_ERROR", service, false, // Client errors are generally not retryable
|
|
2714
|
+
cause);
|
|
2715
|
+
this.service = service;
|
|
2716
|
+
this.statusCode = statusCode;
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
/**
|
|
2720
|
+
* HTTP server errors (5xx)
|
|
2721
|
+
* Used for server-side errors from external APIs
|
|
2722
|
+
* Always retryable as server issues are often transient
|
|
2723
|
+
*/
|
|
2724
|
+
class HttpServerError extends AdapticUtilsError {
|
|
2725
|
+
service;
|
|
2726
|
+
statusCode;
|
|
2727
|
+
constructor(message, service, statusCode, cause) {
|
|
2728
|
+
super(message, "SERVER_ERROR", service, true, // Server errors are always retryable
|
|
2729
|
+
cause);
|
|
2730
|
+
this.service = service;
|
|
2731
|
+
this.statusCode = statusCode;
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
/**
|
|
2735
|
+
* Rate limit errors (429)
|
|
2736
|
+
* Used when API rate limits are exceeded
|
|
2737
|
+
* Always retryable, often with retry-after header information
|
|
2738
|
+
*/
|
|
2739
|
+
class RateLimitError extends AdapticUtilsError {
|
|
2740
|
+
service;
|
|
2741
|
+
retryAfterMs;
|
|
2742
|
+
constructor(message, service, retryAfterMs, cause) {
|
|
2743
|
+
super(message, "RATE_LIMIT", service, true, // Rate limit errors are always retryable
|
|
2744
|
+
cause);
|
|
2745
|
+
this.service = service;
|
|
2746
|
+
this.retryAfterMs = retryAfterMs;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
/**
|
|
2750
|
+
* WebSocket connection errors
|
|
2751
|
+
* Used for WebSocket-specific connection and communication failures
|
|
2752
|
+
* Retryability depends on the specific error condition
|
|
2753
|
+
*/
|
|
2754
|
+
class WebSocketError extends AdapticUtilsError {
|
|
2755
|
+
service;
|
|
2756
|
+
constructor(message, service, isRetryable = true, cause) {
|
|
2757
|
+
super(message, "WEBSOCKET_ERROR", service, isRetryable, cause);
|
|
2758
|
+
this.service = service;
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
/**
|
|
2762
|
+
* Network errors (connection failures, DNS issues, etc.)
|
|
2763
|
+
* Used for low-level network failures
|
|
2764
|
+
* Always retryable as network issues are often transient
|
|
2765
|
+
*/
|
|
2766
|
+
class NetworkError extends AdapticUtilsError {
|
|
2767
|
+
service;
|
|
2768
|
+
constructor(message, service, cause) {
|
|
2769
|
+
super(message, "NETWORK_ERROR", service, true, // Network errors are always retryable
|
|
2770
|
+
cause);
|
|
2771
|
+
this.service = service;
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
/**
|
|
2775
|
+
* Unsupported brokerage provider errors
|
|
2776
|
+
* Thrown when a broker operation is requested for a provider that has no
|
|
2777
|
+
* implemented integration (e.g. IBKR or COINBASE before their adapters land,
|
|
2778
|
+
* or an unrecognised provider string from an untyped caller).
|
|
2779
|
+
* Never retryable — the caller must route to a supported provider.
|
|
2780
|
+
*/
|
|
2781
|
+
class UnsupportedBrokerError extends AdapticUtilsError {
|
|
2782
|
+
provider;
|
|
2783
|
+
constructor(
|
|
2784
|
+
/** The provider that was requested but is not supported. */
|
|
2785
|
+
provider, cause) {
|
|
2786
|
+
super(`Brokerage provider "${provider}" is not supported. Supported providers: ALPACA`, "UNSUPPORTED_BROKER", "broker", false, // Unsupported providers are never retryable
|
|
2787
|
+
cause);
|
|
2788
|
+
this.provider = provider;
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
/**
|
|
2792
|
+
* Data parsing and format errors
|
|
2793
|
+
* Used when API responses cannot be parsed or are in unexpected format
|
|
2794
|
+
* Not retryable as the data format issue needs investigation
|
|
2795
|
+
*/
|
|
2796
|
+
class DataFormatError extends AdapticUtilsError {
|
|
2797
|
+
service;
|
|
2798
|
+
constructor(message, service, cause) {
|
|
2799
|
+
super(message, "DATA_FORMAT_ERROR", service, false, // Data format errors are not retryable
|
|
2800
|
+
cause);
|
|
2801
|
+
this.service = service;
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
/**
|
|
2806
|
+
* Token bucket rate limiter for external API integrations
|
|
2807
|
+
*
|
|
2808
|
+
* Implements client-side rate limiting to prevent exceeding API quotas
|
|
2809
|
+
* and ensure fair usage of external services like Alpaca, Massive, and AlphaVantage.
|
|
2810
|
+
*
|
|
2811
|
+
* @example
|
|
2812
|
+
* ```typescript
|
|
2813
|
+
* import { rateLimiters } from '@adaptic/utils';
|
|
2814
|
+
*
|
|
2815
|
+
* // Before making an API call
|
|
2816
|
+
* await rateLimiters.alpaca.acquire();
|
|
2817
|
+
* const result = await makeAlpacaApiCall();
|
|
2818
|
+
* ```
|
|
2819
|
+
*/
|
|
2820
|
+
/** Number of milliseconds in one second, used for token-refill timing math. */
|
|
2821
|
+
const MS_PER_SECOND$1 = 1000;
|
|
2822
|
+
/**
|
|
2823
|
+
* Minimum delay (ms) for a scheduled queue wake-up. Guards against a `0`/`NaN`
|
|
2824
|
+
* delay when the token deficit rounds down, ensuring the timer always makes
|
|
2825
|
+
* forward progress rather than busy-looping on the event loop.
|
|
2826
|
+
*/
|
|
2827
|
+
const MIN_WAKE_DELAY_MS = 1;
|
|
2828
|
+
/**
|
|
2829
|
+
* Number of whole tokens required to release a single queued request. The token
|
|
2830
|
+
* bucket consumes exactly one token per admitted request.
|
|
2831
|
+
*/
|
|
2832
|
+
const TOKENS_PER_REQUEST = 1;
|
|
2833
|
+
/**
|
|
2834
|
+
* Token bucket rate limiter implementation
|
|
2835
|
+
*
|
|
2836
|
+
* Uses the token bucket algorithm to control the rate of API requests.
|
|
2837
|
+
* Tokens are consumed on each request and refilled at a constant rate.
|
|
2838
|
+
* Requests that exceed the available tokens are queued and processed
|
|
2839
|
+
* when tokens become available.
|
|
2840
|
+
*/
|
|
2841
|
+
class TokenBucketRateLimiter {
|
|
2842
|
+
config;
|
|
2843
|
+
tokens;
|
|
2844
|
+
lastRefill;
|
|
2845
|
+
queue = [];
|
|
2846
|
+
timeoutMs;
|
|
2847
|
+
processingQueue = false;
|
|
2791
2848
|
/**
|
|
2792
|
-
*
|
|
2793
|
-
*
|
|
2794
|
-
*
|
|
2795
|
-
*
|
|
2796
|
-
* `MAX_CONNECTIONS_RETRY_DELAY_MS` (engine v1.0.59) so both providers
|
|
2797
|
-
* behave identically under the same failure mode.
|
|
2849
|
+
* Single pending timer that wakes the limiter to refill tokens and drain the
|
|
2850
|
+
* queue. Without this, a queued request would only be released by a
|
|
2851
|
+
* subsequent {@link acquire} call and would otherwise stall until its own
|
|
2852
|
+
* timeout fired. `null` means no wake-up is currently scheduled.
|
|
2798
2853
|
*/
|
|
2799
|
-
|
|
2854
|
+
wakeTimer = null;
|
|
2800
2855
|
/**
|
|
2801
|
-
*
|
|
2802
|
-
*
|
|
2803
|
-
*
|
|
2804
|
-
*
|
|
2805
|
-
*
|
|
2856
|
+
* Creates a new rate limiter instance
|
|
2857
|
+
*
|
|
2858
|
+
* @param config - Rate limiter configuration
|
|
2859
|
+
*
|
|
2860
|
+
* @example
|
|
2861
|
+
* ```typescript
|
|
2862
|
+
* // Alpaca: 200 requests per minute
|
|
2863
|
+
* const alpacaLimiter = new TokenBucketRateLimiter({
|
|
2864
|
+
* maxTokens: 200,
|
|
2865
|
+
* refillRate: 200 / 60, // ~3.33 per second
|
|
2866
|
+
* label: 'alpaca',
|
|
2867
|
+
* timeoutMs: 60000
|
|
2868
|
+
* });
|
|
2869
|
+
* ```
|
|
2806
2870
|
*/
|
|
2807
|
-
|
|
2871
|
+
constructor(config) {
|
|
2872
|
+
this.config = config;
|
|
2873
|
+
this.tokens = config.maxTokens;
|
|
2874
|
+
this.lastRefill = Date.now();
|
|
2875
|
+
this.timeoutMs = config.timeoutMs ?? 60000; // Default 60 second timeout
|
|
2876
|
+
}
|
|
2808
2877
|
/**
|
|
2809
|
-
*
|
|
2810
|
-
*
|
|
2811
|
-
*
|
|
2812
|
-
*
|
|
2813
|
-
*
|
|
2878
|
+
* Acquires a token for making an API request
|
|
2879
|
+
*
|
|
2880
|
+
* If a token is available, it is consumed immediately.
|
|
2881
|
+
* If no tokens are available, the request is queued and will resolve
|
|
2882
|
+
* when a token becomes available or reject if it times out.
|
|
2883
|
+
*
|
|
2884
|
+
* @throws {RateLimitError} If the request times out waiting for a token
|
|
2885
|
+
*
|
|
2886
|
+
* @example
|
|
2887
|
+
* ```typescript
|
|
2888
|
+
* try {
|
|
2889
|
+
* await limiter.acquire();
|
|
2890
|
+
* // Make API call
|
|
2891
|
+
* } catch (error) {
|
|
2892
|
+
* if (error instanceof RateLimitError) {
|
|
2893
|
+
* // Handle rate limit timeout
|
|
2894
|
+
* }
|
|
2895
|
+
* }
|
|
2896
|
+
* ```
|
|
2814
2897
|
*/
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
this.
|
|
2820
|
-
this.
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
this.stockStreamUrl = getStockStreamUrl("TEST");
|
|
2826
|
-
this.optionStreamUrl = getOptionsStreamUrl("PRODUCTION"); // there's no test mode for options
|
|
2827
|
-
this.cryptoStreamUrl = getCryptoStreamUrl("PRODUCTION"); // there's no test mode for crypto
|
|
2828
|
-
}
|
|
2829
|
-
else {
|
|
2830
|
-
// production
|
|
2831
|
-
this.stockStreamUrl = getStockStreamUrl("PRODUCTION");
|
|
2832
|
-
this.optionStreamUrl = getOptionsStreamUrl("PRODUCTION");
|
|
2833
|
-
this.cryptoStreamUrl = getCryptoStreamUrl("PRODUCTION");
|
|
2898
|
+
async acquire() {
|
|
2899
|
+
const logger = getLogger();
|
|
2900
|
+
this.refill();
|
|
2901
|
+
if (this.tokens > 0) {
|
|
2902
|
+
this.tokens--;
|
|
2903
|
+
logger.debug(`Rate limit token acquired for ${this.config.label}`, {
|
|
2904
|
+
remainingTokens: this.tokens,
|
|
2905
|
+
queueLength: this.queue.length,
|
|
2906
|
+
});
|
|
2907
|
+
return;
|
|
2834
2908
|
}
|
|
2909
|
+
// No tokens available, queue the request
|
|
2910
|
+
logger.debug(`Rate limit reached for ${this.config.label}, queuing request`, {
|
|
2911
|
+
queueLength: this.queue.length,
|
|
2912
|
+
});
|
|
2913
|
+
return new Promise((resolve, reject) => {
|
|
2914
|
+
const timeoutHandle = setTimeout(() => {
|
|
2915
|
+
// Remove from queue on timeout
|
|
2916
|
+
const index = this.queue.findIndex((req) => req.timeoutHandle === timeoutHandle);
|
|
2917
|
+
if (index !== -1) {
|
|
2918
|
+
this.queue.splice(index, 1);
|
|
2919
|
+
}
|
|
2920
|
+
const error = new RateLimitError(`Rate limit timeout for ${this.config.label} after ${this.timeoutMs}ms`, this.config.label, undefined);
|
|
2921
|
+
logger.warn(`Rate limit timeout for ${this.config.label}`, {
|
|
2922
|
+
queueLength: this.queue.length,
|
|
2923
|
+
timeoutMs: this.timeoutMs,
|
|
2924
|
+
});
|
|
2925
|
+
reject(error);
|
|
2926
|
+
}, this.timeoutMs);
|
|
2927
|
+
this.queue.push({ resolve, reject, timeoutHandle });
|
|
2928
|
+
// Ensure the queue is actively drained even if no further acquire() calls
|
|
2929
|
+
// arrive: schedule a wake-up to refill tokens and release this request.
|
|
2930
|
+
this.scheduleQueueWake();
|
|
2931
|
+
});
|
|
2835
2932
|
}
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2933
|
+
/**
|
|
2934
|
+
* Schedules a single wake-up timer that refills tokens and drains the queue.
|
|
2935
|
+
*
|
|
2936
|
+
* The delay is the time required to accrue the tokens still needed to release
|
|
2937
|
+
* the next queued request at the configured refill rate. Only one timer is
|
|
2938
|
+
* ever outstanding (guarded by {@link wakeTimer}); the timer is `unref`'d so
|
|
2939
|
+
* it never keeps the Node.js process alive on its own. When it fires it
|
|
2940
|
+
* refills, drains what it can, and re-arms itself if work remains.
|
|
2941
|
+
*/
|
|
2942
|
+
scheduleQueueWake() {
|
|
2943
|
+
// A wake-up is already pending, or there is nothing to wake for.
|
|
2944
|
+
if (this.wakeTimer !== null || this.queue.length === 0) {
|
|
2945
|
+
return;
|
|
2842
2946
|
}
|
|
2843
|
-
|
|
2844
|
-
|
|
2947
|
+
const tokensNeeded = Math.max(0, TOKENS_PER_REQUEST - this.tokens);
|
|
2948
|
+
const deficitMs = Math.max(MIN_WAKE_DELAY_MS, Math.ceil((tokensNeeded / this.config.refillRate) * MS_PER_SECOND$1));
|
|
2949
|
+
const timer = setTimeout(() => {
|
|
2950
|
+
this.wakeTimer = null;
|
|
2951
|
+
// refill() drains the queue via processQueue(); if requests remain
|
|
2952
|
+
// afterwards, processQueue() re-arms the wake-up.
|
|
2953
|
+
this.refill();
|
|
2954
|
+
}, deficitMs);
|
|
2955
|
+
// Do not let a pending rate-limiter wake-up keep the process alive.
|
|
2956
|
+
if (typeof timer.unref === "function") {
|
|
2957
|
+
timer.unref();
|
|
2845
2958
|
}
|
|
2959
|
+
this.wakeTimer = timer;
|
|
2846
2960
|
}
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
const apiSecret = process.env.ALPACA_API_SECRET || process.env.ALPACA_SECRET_KEY || "";
|
|
2855
|
-
this.credentialsValid = validateAlpacaCredentials({
|
|
2856
|
-
apiKey,
|
|
2857
|
-
apiSecret,
|
|
2858
|
-
isPaper: process.env.ALPACA_ACCOUNT_TYPE === "PAPER",
|
|
2859
|
-
}, { throwOnMissing: false });
|
|
2860
|
-
this.dataURL = MARKET_DATA_API.STOCKS;
|
|
2861
|
-
this.apiURL =
|
|
2862
|
-
process.env.ALPACA_ACCOUNT_TYPE === "PAPER"
|
|
2863
|
-
? getTradingApiUrl("PAPER")
|
|
2864
|
-
: getTradingApiUrl("LIVE"); // used by some, e.g. getAssets
|
|
2865
|
-
this.v1beta1url = MARKET_DATA_API.OPTIONS; // used for options endpoints
|
|
2866
|
-
this.setMode("production"); // sets stockStreamUrl and optionStreamUrl
|
|
2867
|
-
this.headers = {
|
|
2868
|
-
"APCA-API-KEY-ID": apiKey,
|
|
2869
|
-
"APCA-API-SECRET-KEY": apiSecret,
|
|
2870
|
-
"Content-Type": "application/json",
|
|
2871
|
-
};
|
|
2872
|
-
}
|
|
2873
|
-
static getInstance() {
|
|
2874
|
-
if (!AlpacaMarketDataAPI.instance) {
|
|
2875
|
-
AlpacaMarketDataAPI.instance = new AlpacaMarketDataAPI();
|
|
2961
|
+
/**
|
|
2962
|
+
* Clears any pending wake-up timer.
|
|
2963
|
+
*/
|
|
2964
|
+
clearWakeTimer() {
|
|
2965
|
+
if (this.wakeTimer !== null) {
|
|
2966
|
+
clearTimeout(this.wakeTimer);
|
|
2967
|
+
this.wakeTimer = null;
|
|
2876
2968
|
}
|
|
2877
|
-
return AlpacaMarketDataAPI.instance;
|
|
2878
|
-
}
|
|
2879
|
-
on(event, listener) {
|
|
2880
|
-
return super.on(event, listener);
|
|
2881
2969
|
}
|
|
2882
|
-
|
|
2883
|
-
|
|
2970
|
+
/**
|
|
2971
|
+
* Refills tokens based on elapsed time and processes queued requests
|
|
2972
|
+
*
|
|
2973
|
+
* Tokens are refilled at the configured rate up to the maximum capacity.
|
|
2974
|
+
* If tokens are available after refilling, queued requests are processed.
|
|
2975
|
+
*/
|
|
2976
|
+
refill() {
|
|
2977
|
+
const now = Date.now();
|
|
2978
|
+
const elapsed = (now - this.lastRefill) / 1000; // Convert to seconds
|
|
2979
|
+
const tokensToAdd = elapsed * this.config.refillRate;
|
|
2980
|
+
this.tokens = Math.min(this.config.maxTokens, this.tokens + tokensToAdd);
|
|
2981
|
+
this.lastRefill = now;
|
|
2982
|
+
// Process queued requests if we have tokens
|
|
2983
|
+
this.processQueue();
|
|
2884
2984
|
}
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
else {
|
|
2894
|
-
url = this.cryptoStreamUrl;
|
|
2895
|
-
}
|
|
2896
|
-
const apiKey = process.env.ALPACA_API_KEY || "";
|
|
2897
|
-
const apiSecret = process.env.ALPACA_API_SECRET || process.env.ALPACA_SECRET_KEY || "";
|
|
2898
|
-
if (!apiKey || !apiSecret) {
|
|
2899
|
-
log$l(`Cannot connect ${streamType} stream: missing Alpaca credentials (ALPACA_API_KEY=${apiKey ? "set" : "MISSING"}, ALPACA_API_SECRET/ALPACA_SECRET_KEY=${apiSecret ? "set" : "MISSING"})`, { type: "error" });
|
|
2985
|
+
/**
|
|
2986
|
+
* Processes queued requests when tokens are available
|
|
2987
|
+
*
|
|
2988
|
+
* Prevents concurrent queue processing to ensure FIFO order.
|
|
2989
|
+
*/
|
|
2990
|
+
processQueue() {
|
|
2991
|
+
// Prevent concurrent queue processing
|
|
2992
|
+
if (this.processingQueue) {
|
|
2900
2993
|
return;
|
|
2901
2994
|
}
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2995
|
+
this.processingQueue = true;
|
|
2996
|
+
const logger = getLogger();
|
|
2997
|
+
try {
|
|
2998
|
+
while (this.queue.length > 0 && this.tokens > 0) {
|
|
2999
|
+
this.tokens--;
|
|
3000
|
+
const next = this.queue.shift();
|
|
3001
|
+
if (next) {
|
|
3002
|
+
clearTimeout(next.timeoutHandle);
|
|
3003
|
+
next.resolve();
|
|
3004
|
+
logger.debug(`Processed queued request for ${this.config.label}`, {
|
|
3005
|
+
remainingTokens: this.tokens,
|
|
3006
|
+
remainingQueue: this.queue.length,
|
|
3007
|
+
});
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
2905
3010
|
}
|
|
2906
|
-
|
|
2907
|
-
this.
|
|
3011
|
+
finally {
|
|
3012
|
+
this.processingQueue = false;
|
|
3013
|
+
}
|
|
3014
|
+
// Keep the wake-up state consistent with the queue: if requests are still
|
|
3015
|
+
// waiting (tokens ran out mid-drain), ensure a wake-up is armed; otherwise
|
|
3016
|
+
// release any pending timer so it cannot fire needlessly.
|
|
3017
|
+
if (this.queue.length > 0) {
|
|
3018
|
+
this.scheduleQueueWake();
|
|
2908
3019
|
}
|
|
2909
3020
|
else {
|
|
2910
|
-
this.
|
|
3021
|
+
this.clearWakeTimer();
|
|
2911
3022
|
}
|
|
2912
|
-
ws.on("open", () => {
|
|
2913
|
-
log$l(`${streamType} stream connected`, { type: "info" });
|
|
2914
|
-
const authMessage = {
|
|
2915
|
-
action: "auth",
|
|
2916
|
-
key: apiKey,
|
|
2917
|
-
secret: apiSecret,
|
|
2918
|
-
};
|
|
2919
|
-
ws.send(JSON.stringify(authMessage));
|
|
2920
|
-
});
|
|
2921
|
-
ws.on("message", (data) => {
|
|
2922
|
-
const rawData = data.toString();
|
|
2923
|
-
let messages;
|
|
2924
|
-
try {
|
|
2925
|
-
messages = JSON.parse(rawData);
|
|
2926
|
-
}
|
|
2927
|
-
catch (e) {
|
|
2928
|
-
log$l(`${streamType} stream received invalid JSON: ${rawData.substring(0, 200)}`, { type: "error" });
|
|
2929
|
-
return;
|
|
2930
|
-
}
|
|
2931
|
-
for (const message of messages) {
|
|
2932
|
-
if (message.T === "success" && message.msg === "authenticated") {
|
|
2933
|
-
log$l(`${streamType} stream authenticated`, { type: "info" });
|
|
2934
|
-
this.reconnectAttempts[streamType] = 0;
|
|
2935
|
-
this.sendSubscription(streamType);
|
|
2936
|
-
}
|
|
2937
|
-
else if (message.T === "success" && message.msg === "connected") {
|
|
2938
|
-
log$l(`${streamType} stream connected message received`, {
|
|
2939
|
-
type: "debug",
|
|
2940
|
-
});
|
|
2941
|
-
}
|
|
2942
|
-
else if (message.T === "subscription") {
|
|
2943
|
-
log$l(`${streamType} subscription confirmed: trades=${message.trades?.length || 0}, quotes=${message.quotes?.length || 0}, bars=${message.bars?.length || 0}`, { type: "info" });
|
|
2944
|
-
}
|
|
2945
|
-
else if (message.T === "error") {
|
|
2946
|
-
log$l(`${streamType} stream error: ${message.msg} (code: ${message.code}, raw: ${JSON.stringify(message)})`, { type: "error" });
|
|
2947
|
-
// Alpaca code 406: "connection limit exceeded" — account-wide
|
|
2948
|
-
// concurrent-WS cap reached. The Alpaca server will close the
|
|
2949
|
-
// socket immediately after this frame, which would normally
|
|
2950
|
-
// trigger our standard sub-second exponential reconnect chain
|
|
2951
|
-
// (1 s, 2 s, 4 s, 8 s, 16 s, 30 s × 5) — exactly the wrong
|
|
2952
|
-
// behaviour against a rate-limit response. Stamp the recency
|
|
2953
|
-
// marker so {@link scheduleReconnect} switches to the
|
|
2954
|
-
// 5-minute jittered backoff instead.
|
|
2955
|
-
if (typeof message.code === "number" &&
|
|
2956
|
-
message.code === 406) {
|
|
2957
|
-
this.lastConnectionLimitAt[streamType] = Date.now();
|
|
2958
|
-
}
|
|
2959
|
-
}
|
|
2960
|
-
else if (message.S) {
|
|
2961
|
-
super.emit(`${streamType}-${message.T}`, message);
|
|
2962
|
-
super.emit(`${streamType}-data`, message);
|
|
2963
|
-
}
|
|
2964
|
-
else {
|
|
2965
|
-
log$l(`${streamType} received unknown message type: ${JSON.stringify(message)}`, { type: "debug" });
|
|
2966
|
-
}
|
|
2967
|
-
}
|
|
2968
|
-
});
|
|
2969
|
-
ws.on("close", (code) => {
|
|
2970
|
-
log$l(`${streamType} stream disconnected (code: ${code})`, {
|
|
2971
|
-
type: "warn",
|
|
2972
|
-
});
|
|
2973
|
-
if (streamType === "stock") {
|
|
2974
|
-
this.stockWs = null;
|
|
2975
|
-
}
|
|
2976
|
-
else if (streamType === "option") {
|
|
2977
|
-
this.optionWs = null;
|
|
2978
|
-
}
|
|
2979
|
-
else {
|
|
2980
|
-
this.cryptoWs = null;
|
|
2981
|
-
}
|
|
2982
|
-
// Reconnect with exponential backoff (unless intentionally closed with code 1000)
|
|
2983
|
-
if (code !== 1000) {
|
|
2984
|
-
this.scheduleReconnect(streamType);
|
|
2985
|
-
}
|
|
2986
|
-
});
|
|
2987
|
-
ws.on("error", (error) => {
|
|
2988
|
-
log$l(`${streamType} stream error: ${error.message}`, { type: "error" });
|
|
2989
|
-
});
|
|
2990
3023
|
}
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
const connectionLimitAt = this.lastConnectionLimitAt[streamType];
|
|
3000
|
-
const isRecentConnectionLimit = typeof connectionLimitAt === "number" &&
|
|
3001
|
-
Date.now() - connectionLimitAt <= this.CONNECTION_LIMIT_RECENCY_MS;
|
|
3002
|
-
if (isRecentConnectionLimit) {
|
|
3003
|
-
const jitter = Math.floor((Math.random() - 0.5) *
|
|
3004
|
-
2 *
|
|
3005
|
-
this.CONNECTION_LIMIT_BACKOFF_JITTER_MS);
|
|
3006
|
-
const delayMs = this.CONNECTION_LIMIT_BACKOFF_MS + jitter;
|
|
3007
|
-
// Reset normal attempt counter so the next 406 retry doesn't
|
|
3008
|
-
// inherit a stale exponential cap.
|
|
3009
|
-
this.reconnectAttempts[streamType] = 0;
|
|
3010
|
-
// Consume the recency marker — subsequent reconnects fall back
|
|
3011
|
-
// to the standard exponential path unless a new 406 arrives.
|
|
3012
|
-
delete this.lastConnectionLimitAt[streamType];
|
|
3013
|
-
log$l(`${streamType} stream: Alpaca 406 connection-limit recovery — backing off ${Math.round(delayMs / 1000)}s before retry to allow account-wide slot release`, { type: "warn" });
|
|
3014
|
-
if (this.reconnectTimers[streamType]) {
|
|
3015
|
-
clearTimeout(this.reconnectTimers[streamType]);
|
|
3016
|
-
}
|
|
3017
|
-
this.reconnectTimers[streamType] = setTimeout(() => {
|
|
3018
|
-
log$l(`${streamType} stream: attempting reconnect after 406-recovery backoff`, { type: "info" });
|
|
3019
|
-
this.connect(streamType);
|
|
3020
|
-
}, delayMs);
|
|
3021
|
-
return;
|
|
3022
|
-
}
|
|
3023
|
-
const attempts = this.reconnectAttempts[streamType] ?? 0;
|
|
3024
|
-
const maxAttempts = 10;
|
|
3025
|
-
if (attempts >= maxAttempts) {
|
|
3026
|
-
log$l(`${streamType} stream: max reconnect attempts (${maxAttempts}) reached, giving up`, { type: "error" });
|
|
3027
|
-
return;
|
|
3028
|
-
}
|
|
3029
|
-
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (capped)
|
|
3030
|
-
const delayMs = Math.min(1000 * Math.pow(2, attempts), 30000);
|
|
3031
|
-
this.reconnectAttempts[streamType] = attempts + 1;
|
|
3032
|
-
log$l(`${streamType} stream: scheduling reconnect attempt ${attempts + 1}/${maxAttempts} in ${delayMs}ms`, { type: "info" });
|
|
3033
|
-
// Clear any existing reconnect timer for this stream
|
|
3034
|
-
if (this.reconnectTimers[streamType]) {
|
|
3035
|
-
clearTimeout(this.reconnectTimers[streamType]);
|
|
3036
|
-
}
|
|
3037
|
-
this.reconnectTimers[streamType] = setTimeout(() => {
|
|
3038
|
-
log$l(`${streamType} stream: reconnecting (attempt ${attempts + 1}/${maxAttempts})`, { type: "info" });
|
|
3039
|
-
this.connect(streamType);
|
|
3040
|
-
}, delayMs);
|
|
3024
|
+
/**
|
|
3025
|
+
* Gets the current number of available tokens
|
|
3026
|
+
*
|
|
3027
|
+
* @returns Number of tokens currently available
|
|
3028
|
+
*/
|
|
3029
|
+
getAvailableTokens() {
|
|
3030
|
+
this.refill();
|
|
3031
|
+
return Math.floor(this.tokens);
|
|
3041
3032
|
}
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3033
|
+
/**
|
|
3034
|
+
* Gets the current queue length
|
|
3035
|
+
*
|
|
3036
|
+
* @returns Number of requests waiting for tokens
|
|
3037
|
+
*/
|
|
3038
|
+
getQueueLength() {
|
|
3039
|
+
return this.queue.length;
|
|
3040
|
+
}
|
|
3041
|
+
/**
|
|
3042
|
+
* Clears all queued requests and resets the token bucket
|
|
3043
|
+
*
|
|
3044
|
+
* All queued requests will be rejected with a RateLimitError.
|
|
3045
|
+
* Useful for cleanup or when changing rate limit configurations.
|
|
3046
|
+
*/
|
|
3047
|
+
reset() {
|
|
3048
|
+
const logger = getLogger();
|
|
3049
|
+
// Reject all queued requests
|
|
3050
|
+
for (const request of this.queue) {
|
|
3051
|
+
clearTimeout(request.timeoutHandle);
|
|
3052
|
+
request.reject(new RateLimitError(`Rate limiter reset for ${this.config.label}`, this.config.label, undefined));
|
|
3056
3053
|
}
|
|
3057
|
-
|
|
3058
|
-
|
|
3054
|
+
this.clearWakeTimer();
|
|
3055
|
+
this.queue = [];
|
|
3056
|
+
this.tokens = this.config.maxTokens;
|
|
3057
|
+
this.lastRefill = Date.now();
|
|
3058
|
+
this.processingQueue = false;
|
|
3059
|
+
logger.info(`Rate limiter reset for ${this.config.label}`, {
|
|
3060
|
+
maxTokens: this.config.maxTokens,
|
|
3059
3061
|
});
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
/**
|
|
3065
|
+
* Pre-configured rate limiters for common external APIs
|
|
3066
|
+
*
|
|
3067
|
+
* These limiters are configured based on the documented rate limits
|
|
3068
|
+
* for each service. Adjust the configurations if you have different
|
|
3069
|
+
* tier access or if limits change.
|
|
3070
|
+
*
|
|
3071
|
+
* @example
|
|
3072
|
+
* ```typescript
|
|
3073
|
+
* import { rateLimiters } from '@adaptic/utils';
|
|
3074
|
+
*
|
|
3075
|
+
* // Use before making API calls
|
|
3076
|
+
* await rateLimiters.alpaca.acquire();
|
|
3077
|
+
* await rateLimiters.massive.acquire();
|
|
3078
|
+
* await rateLimiters.alphaVantage.acquire();
|
|
3079
|
+
* ```
|
|
3080
|
+
*/
|
|
3081
|
+
const rateLimiters = {
|
|
3082
|
+
/**
|
|
3083
|
+
* Alpaca API rate limiter
|
|
3084
|
+
*
|
|
3085
|
+
* Configured for 1000 requests per minute (paid tier).
|
|
3086
|
+
* The token bucket allows burst up to maxTokens, then refills at the steady rate.
|
|
3087
|
+
* See: https://alpaca.markets/docs/api-references/trading-api/#rate-limit
|
|
3088
|
+
*/
|
|
3089
|
+
alpaca: new TokenBucketRateLimiter({
|
|
3090
|
+
maxTokens: 1000,
|
|
3091
|
+
refillRate: 1000 / 60, // 1000 requests per 60 seconds (~16.67/sec)
|
|
3092
|
+
label: "alpaca",
|
|
3093
|
+
timeoutMs: 60000,
|
|
3094
|
+
}),
|
|
3095
|
+
/**
|
|
3096
|
+
* Massive.com API rate limiter
|
|
3097
|
+
*
|
|
3098
|
+
* Configured generously for paid unlimited tier. The bucket exists only as a
|
|
3099
|
+
* safety net against runaway loops — the 1000 token burst and 500/sec refill
|
|
3100
|
+
* should never be hit under normal operation. If the paid plan truly has no
|
|
3101
|
+
* hard limit, this just prevents accidental self-DDoS.
|
|
3102
|
+
*/
|
|
3103
|
+
massive: new TokenBucketRateLimiter({
|
|
3104
|
+
maxTokens: 1000,
|
|
3105
|
+
refillRate: 500, // 500 tokens/sec refill — effectively unlimited for paid tier
|
|
3106
|
+
label: "massive",
|
|
3107
|
+
timeoutMs: 30000,
|
|
3108
|
+
}),
|
|
3109
|
+
/**
|
|
3110
|
+
* AlphaVantage API rate limiter
|
|
3111
|
+
*
|
|
3112
|
+
* Configured for 5 requests per minute (free tier).
|
|
3113
|
+
* For premium tier (75/min), create a custom limiter:
|
|
3114
|
+
*
|
|
3115
|
+
* @example
|
|
3116
|
+
* ```typescript
|
|
3117
|
+
* const premiumAV = new TokenBucketRateLimiter({
|
|
3118
|
+
* maxTokens: 75,
|
|
3119
|
+
* refillRate: 75 / 60,
|
|
3120
|
+
* label: 'alphaVantage-premium',
|
|
3121
|
+
* timeoutMs: 60000,
|
|
3122
|
+
* });
|
|
3123
|
+
* ```
|
|
3124
|
+
*
|
|
3125
|
+
* See: https://www.alphavantage.co/premium/
|
|
3126
|
+
*/
|
|
3127
|
+
alphaVantage: new TokenBucketRateLimiter({
|
|
3128
|
+
maxTokens: 5,
|
|
3129
|
+
refillRate: 5 / 60, // 5 requests per 60 seconds (~0.083/sec)
|
|
3130
|
+
label: "alphaVantage",
|
|
3131
|
+
timeoutMs: 60000,
|
|
3132
|
+
}),
|
|
3133
|
+
};
|
|
3134
|
+
|
|
3135
|
+
/**
|
|
3136
|
+
* Bounded retry for TRANSIENT network faults on Alpaca market-data reads.
|
|
3137
|
+
*
|
|
3138
|
+
* 2026-07-30: the engine logged 637 `TypeError: fetch failed` / `read
|
|
3139
|
+
* ECONNRESET` errors against `/v2/stocks/bars` in a single session (~4 per
|
|
3140
|
+
* minute). Every OTHER Alpaca/Massive client in this package already routes
|
|
3141
|
+
* transient faults through {@link isTransientNetworkError} — this client was
|
|
3142
|
+
* the one that never adopted it, so a single reset socket killed the whole
|
|
3143
|
+
* read. Those failures surface in the engine's market-tape prompt context and
|
|
3144
|
+
* its intraday direction/path resolvers, so the decision model judged with a
|
|
3145
|
+
* blind tape: raw confidence collapsed from a 0.677 median to a degenerate
|
|
3146
|
+
* ~0.504 band, the entire distribution fell below the 0.55 admission floor,
|
|
3147
|
+
* and live participation went to near zero.
|
|
3148
|
+
*
|
|
3149
|
+
* `ECONNRESET` on a keep-alive pool is the classic idle-socket race — the
|
|
3150
|
+
* server closes a pooled connection while the client dispatches onto it. It
|
|
3151
|
+
* is transient by construction and safe to retry on an idempotent GET.
|
|
3152
|
+
*/
|
|
3153
|
+
const TRANSIENT_NETWORK_RETRY_ATTEMPTS = 3;
|
|
3154
|
+
/** Base backoff (ms); doubled per attempt with full jitter. */
|
|
3155
|
+
const TRANSIENT_NETWORK_RETRY_BASE_MS = 120;
|
|
3156
|
+
/** Full-jitter backoff so concurrent fan-out retries do not resonate. */
|
|
3157
|
+
function transientRetryDelayMs(attempt) {
|
|
3158
|
+
const ceiling = TRANSIENT_NETWORK_RETRY_BASE_MS * 2 ** attempt;
|
|
3159
|
+
return Math.floor(Math.random() * ceiling);
|
|
3160
|
+
}
|
|
3161
|
+
const log$l = (message, options = { type: "info" }) => {
|
|
3162
|
+
log$m(message, { ...options, source: "AlpacaMarketDataAPI" });
|
|
3163
|
+
};
|
|
3164
|
+
// Default settings for market data API
|
|
3165
|
+
const DEFAULT_ADJUSTMENT = "all";
|
|
3166
|
+
// Data feed tier. SIP is full US-market-consolidated feed (requires a paid
|
|
3167
|
+
// Alpaca market-data subscription). IEX is the free tier — single-exchange,
|
|
3168
|
+
// delayed. Engine deploys running on IEX-only accounts hit chronic HTTP 403
|
|
3169
|
+
// ("subscription does not permit querying recent SIP data") at ~100/min when
|
|
3170
|
+
// this defaulted to "sip", which saturated Railway's per-replica log ingest
|
|
3171
|
+
// and hid steady-state signal. The ALPACA_MARKET_DATA_FEED env var overrides
|
|
3172
|
+
// the default. Fall back to "iex" so the free tier is safe by default; LIVE
|
|
3173
|
+
// deployments with SIP entitlements set ALPACA_MARKET_DATA_FEED=sip in their
|
|
3174
|
+
// environment to restore full data access. Per-call overrides (the optional
|
|
3175
|
+
// `feed` argument on getOptions*/getLatestBars/etc.) still win.
|
|
3176
|
+
const DEFAULT_FEED$1 = (process.env.ALPACA_MARKET_DATA_FEED || "iex");
|
|
3177
|
+
const DEFAULT_CURRENCY$1 = "USD";
|
|
3178
|
+
/**
|
|
3179
|
+
* Singleton class for interacting with Alpaca Market Data API
|
|
3180
|
+
* Provides methods for fetching historical bars, latest bars, last trades, latest trades, latest quotes, and latest quote for a single symbol
|
|
3181
|
+
*/
|
|
3182
|
+
class AlpacaMarketDataAPI extends EventEmitter {
|
|
3183
|
+
static instance;
|
|
3184
|
+
headers;
|
|
3185
|
+
dataURL;
|
|
3186
|
+
apiURL;
|
|
3187
|
+
v1beta1url;
|
|
3188
|
+
/** Whether API credentials are valid and available. False during build time when env vars are missing. */
|
|
3189
|
+
credentialsValid = false;
|
|
3190
|
+
stockStreamUrl = getStockStreamUrl("PRODUCTION"); // production values
|
|
3191
|
+
optionStreamUrl = getOptionsStreamUrl("PRODUCTION"); // production values
|
|
3192
|
+
cryptoStreamUrl = getCryptoStreamUrl("PRODUCTION"); // production values
|
|
3193
|
+
stockWs = null;
|
|
3194
|
+
optionWs = null;
|
|
3195
|
+
cryptoWs = null;
|
|
3196
|
+
stockSubscriptions = {
|
|
3197
|
+
trades: [],
|
|
3198
|
+
quotes: [],
|
|
3199
|
+
bars: [],
|
|
3200
|
+
};
|
|
3201
|
+
optionSubscriptions = {
|
|
3202
|
+
trades: [],
|
|
3203
|
+
quotes: [],
|
|
3204
|
+
bars: [],
|
|
3205
|
+
};
|
|
3206
|
+
cryptoSubscriptions = {
|
|
3207
|
+
trades: [],
|
|
3208
|
+
quotes: [],
|
|
3209
|
+
bars: [],
|
|
3210
|
+
};
|
|
3211
|
+
reconnectAttempts = {};
|
|
3212
|
+
reconnectTimers = {};
|
|
3213
|
+
/**
|
|
3214
|
+
* Wall-clock timestamp of the most recent Alpaca app-level error code
|
|
3215
|
+
* 406 ("connection limit exceeded") received on each stream. Used by
|
|
3216
|
+
* {@link scheduleReconnect} to apply a long backoff with jitter rather
|
|
3217
|
+
* than the normal sub-second exponential ramp — without this, hitting
|
|
3218
|
+
* Alpaca's account-wide concurrent-connection cap (typical for blue/green
|
|
3219
|
+
* deploy rollovers where the old pod's WS slots haven't been released
|
|
3220
|
+
* yet) produced a 10-attempt retry storm that compounded the slot
|
|
3221
|
+
* pressure and consumed the per-account connection quota across the
|
|
3222
|
+
* organisation.
|
|
3223
|
+
*
|
|
3224
|
+
* Cleared once the long-backoff retry is scheduled so that subsequent
|
|
3225
|
+
* normal failures fall back to the standard sub-second exponential.
|
|
3226
|
+
*
|
|
3227
|
+
* @see CONNECTION_LIMIT_BACKOFF_MS / CONNECTION_LIMIT_BACKOFF_JITTER_MS
|
|
3228
|
+
*/
|
|
3229
|
+
lastConnectionLimitAt = {};
|
|
3230
|
+
/**
|
|
3231
|
+
* Five-minute base backoff after Alpaca's app-level 406. Long enough
|
|
3232
|
+
* for Alpaca's server-side cleanup to release stale slots in typical
|
|
3233
|
+
* rollover scenarios; short enough that an operator doesn't need to
|
|
3234
|
+
* intervene. Mirrors the equivalent MassiveClient
|
|
3235
|
+
* `MAX_CONNECTIONS_RETRY_DELAY_MS` (engine v1.0.59) so both providers
|
|
3236
|
+
* behave identically under the same failure mode.
|
|
3237
|
+
*/
|
|
3238
|
+
CONNECTION_LIMIT_BACKOFF_MS = 5 * 60_000;
|
|
3239
|
+
/**
|
|
3240
|
+
* ±30 s of uniform jitter on the connection-limit backoff. Prevents
|
|
3241
|
+
* a thundering-herd retry when all three streams (stock / option /
|
|
3242
|
+
* crypto) hit 406 simultaneously during a deploy rollover — without
|
|
3243
|
+
* jitter they'd all retry at the same wall-clock instant and could
|
|
3244
|
+
* re-trip the account cap together.
|
|
3245
|
+
*/
|
|
3246
|
+
CONNECTION_LIMIT_BACKOFF_JITTER_MS = 30_000;
|
|
3247
|
+
/**
|
|
3248
|
+
* Recency window within which a 406 is considered "still applicable"
|
|
3249
|
+
* to a subsequent reconnect-schedule call. The 406 message handler
|
|
3250
|
+
* stamps {@link lastConnectionLimitAt} and the `close` handler fires
|
|
3251
|
+
* shortly afterwards (sub-second typically) — the window is wide
|
|
3252
|
+
* enough to absorb scheduling delays without false-positives.
|
|
3253
|
+
*/
|
|
3254
|
+
CONNECTION_LIMIT_RECENCY_MS = 30_000;
|
|
3255
|
+
setMode(mode = "production") {
|
|
3256
|
+
if (mode === "sandbox") {
|
|
3257
|
+
// sandbox mode
|
|
3258
|
+
this.stockStreamUrl = WEBSOCKET_STREAMS.STOCKS.PRODUCTION; // sandbox uses production for stocks
|
|
3259
|
+
this.optionStreamUrl = getOptionsStreamUrl("SANDBOX");
|
|
3260
|
+
this.cryptoStreamUrl = getCryptoStreamUrl("SANDBOX");
|
|
3087
3261
|
}
|
|
3088
|
-
else if (
|
|
3089
|
-
//
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
const queuedTotal = (subscriptions.trades?.length || 0) +
|
|
3094
|
-
(subscriptions.quotes?.length || 0) +
|
|
3095
|
-
(subscriptions.bars?.length || 0);
|
|
3096
|
-
log$l(`${streamType} subscription queued (${queuedTotal} channel entries); will dispatch after WebSocket authenticates`, { type: "debug" });
|
|
3262
|
+
else if (mode === "test") {
|
|
3263
|
+
// test mode, can only use ticker FAKEPACA
|
|
3264
|
+
this.stockStreamUrl = getStockStreamUrl("TEST");
|
|
3265
|
+
this.optionStreamUrl = getOptionsStreamUrl("PRODUCTION"); // there's no test mode for options
|
|
3266
|
+
this.cryptoStreamUrl = getCryptoStreamUrl("PRODUCTION"); // there's no test mode for crypto
|
|
3097
3267
|
}
|
|
3098
3268
|
else {
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
: ws.readyState === WebSocket.CLOSED
|
|
3104
|
-
? "CLOSED"
|
|
3105
|
-
: `unknown(${ws.readyState})`;
|
|
3106
|
-
log$l(`Cannot send ${streamType} subscription: WebSocket in ${stateLabel} state`, { type: "warn" });
|
|
3269
|
+
// production
|
|
3270
|
+
this.stockStreamUrl = getStockStreamUrl("PRODUCTION");
|
|
3271
|
+
this.optionStreamUrl = getOptionsStreamUrl("PRODUCTION");
|
|
3272
|
+
this.cryptoStreamUrl = getCryptoStreamUrl("PRODUCTION");
|
|
3107
3273
|
}
|
|
3108
3274
|
}
|
|
3109
|
-
|
|
3110
|
-
if (
|
|
3111
|
-
|
|
3275
|
+
getMode() {
|
|
3276
|
+
if (this.stockStreamUrl.includes("sandbox")) {
|
|
3277
|
+
return "sandbox";
|
|
3112
3278
|
}
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
if (!this.optionWs) {
|
|
3116
|
-
this.connect("option");
|
|
3279
|
+
else if (this.stockStreamUrl.includes("test")) {
|
|
3280
|
+
return "test";
|
|
3117
3281
|
}
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
if (!this.cryptoWs) {
|
|
3121
|
-
this.connect("crypto");
|
|
3282
|
+
else {
|
|
3283
|
+
return "production";
|
|
3122
3284
|
}
|
|
3123
3285
|
}
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3286
|
+
constructor() {
|
|
3287
|
+
super();
|
|
3288
|
+
// Validate credentials from environment variables before initializing
|
|
3289
|
+
// Use throwOnMissing: false to allow initialization during build time
|
|
3290
|
+
// when env vars are not available. Features will be unavailable until
|
|
3291
|
+
// credentials are provided at runtime.
|
|
3292
|
+
const apiKey = process.env.ALPACA_API_KEY || "";
|
|
3293
|
+
const apiSecret = process.env.ALPACA_API_SECRET || process.env.ALPACA_SECRET_KEY || "";
|
|
3294
|
+
this.credentialsValid = validateAlpacaCredentials({
|
|
3295
|
+
apiKey,
|
|
3296
|
+
apiSecret,
|
|
3297
|
+
isPaper: process.env.ALPACA_ACCOUNT_TYPE === "PAPER",
|
|
3298
|
+
}, { throwOnMissing: false });
|
|
3299
|
+
this.dataURL = MARKET_DATA_API.STOCKS;
|
|
3300
|
+
this.apiURL =
|
|
3301
|
+
process.env.ALPACA_ACCOUNT_TYPE === "PAPER"
|
|
3302
|
+
? getTradingApiUrl("PAPER")
|
|
3303
|
+
: getTradingApiUrl("LIVE"); // used by some, e.g. getAssets
|
|
3304
|
+
this.v1beta1url = MARKET_DATA_API.OPTIONS; // used for options endpoints
|
|
3305
|
+
this.setMode("production"); // sets stockStreamUrl and optionStreamUrl
|
|
3306
|
+
this.headers = {
|
|
3307
|
+
"APCA-API-KEY-ID": apiKey,
|
|
3308
|
+
"APCA-API-SECRET-KEY": apiSecret,
|
|
3309
|
+
"Content-Type": "application/json",
|
|
3310
|
+
};
|
|
3128
3311
|
}
|
|
3129
|
-
|
|
3130
|
-
if (
|
|
3131
|
-
|
|
3312
|
+
static getInstance() {
|
|
3313
|
+
if (!AlpacaMarketDataAPI.instance) {
|
|
3314
|
+
AlpacaMarketDataAPI.instance = new AlpacaMarketDataAPI();
|
|
3132
3315
|
}
|
|
3316
|
+
return AlpacaMarketDataAPI.instance;
|
|
3133
3317
|
}
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
this.cryptoWs.close();
|
|
3137
|
-
}
|
|
3318
|
+
on(event, listener) {
|
|
3319
|
+
return super.on(event, listener);
|
|
3138
3320
|
}
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
* @param streamType - The type of stream to check
|
|
3142
|
-
* @returns True if the stream is connected
|
|
3143
|
-
*/
|
|
3144
|
-
isStreamConnected(streamType) {
|
|
3145
|
-
if (streamType === "stock") {
|
|
3146
|
-
return (this.stockWs !== null && this.stockWs.readyState === WebSocket.OPEN);
|
|
3147
|
-
}
|
|
3148
|
-
else if (streamType === "option") {
|
|
3149
|
-
return (this.optionWs !== null && this.optionWs.readyState === WebSocket.OPEN);
|
|
3150
|
-
}
|
|
3151
|
-
else {
|
|
3152
|
-
return (this.cryptoWs !== null && this.cryptoWs.readyState === WebSocket.OPEN);
|
|
3153
|
-
}
|
|
3321
|
+
emit(event, ...args) {
|
|
3322
|
+
return super.emit(event, ...args);
|
|
3154
3323
|
}
|
|
3155
|
-
|
|
3156
|
-
let
|
|
3324
|
+
connect(streamType) {
|
|
3325
|
+
let url;
|
|
3157
3326
|
if (streamType === "stock") {
|
|
3158
|
-
|
|
3327
|
+
url = this.stockStreamUrl;
|
|
3159
3328
|
}
|
|
3160
3329
|
else if (streamType === "option") {
|
|
3161
|
-
|
|
3330
|
+
url = this.optionStreamUrl;
|
|
3162
3331
|
}
|
|
3163
3332
|
else {
|
|
3164
|
-
|
|
3165
|
-
}
|
|
3166
|
-
Object.entries(subscriptions).forEach(([key, value]) => {
|
|
3167
|
-
if (value) {
|
|
3168
|
-
currentSubscriptions[key] = [
|
|
3169
|
-
...new Set([...(currentSubscriptions[key] || []), ...value]),
|
|
3170
|
-
];
|
|
3171
|
-
}
|
|
3172
|
-
});
|
|
3173
|
-
this.sendSubscription(streamType);
|
|
3174
|
-
}
|
|
3175
|
-
unsubscribe(streamType, subscriptions) {
|
|
3176
|
-
let currentSubscriptions;
|
|
3177
|
-
if (streamType === "stock") {
|
|
3178
|
-
currentSubscriptions = this.stockSubscriptions;
|
|
3179
|
-
}
|
|
3180
|
-
else if (streamType === "option") {
|
|
3181
|
-
currentSubscriptions = this.optionSubscriptions;
|
|
3333
|
+
url = this.cryptoStreamUrl;
|
|
3182
3334
|
}
|
|
3183
|
-
|
|
3184
|
-
|
|
3335
|
+
const apiKey = process.env.ALPACA_API_KEY || "";
|
|
3336
|
+
const apiSecret = process.env.ALPACA_API_SECRET || process.env.ALPACA_SECRET_KEY || "";
|
|
3337
|
+
if (!apiKey || !apiSecret) {
|
|
3338
|
+
log$l(`Cannot connect ${streamType} stream: missing Alpaca credentials (ALPACA_API_KEY=${apiKey ? "set" : "MISSING"}, ALPACA_API_SECRET/ALPACA_SECRET_KEY=${apiSecret ? "set" : "MISSING"})`, { type: "error" });
|
|
3339
|
+
return;
|
|
3185
3340
|
}
|
|
3186
|
-
|
|
3187
|
-
if (value) {
|
|
3188
|
-
currentSubscriptions[key] = (currentSubscriptions[key] || []).filter((s) => !value.includes(s));
|
|
3189
|
-
}
|
|
3190
|
-
});
|
|
3191
|
-
const unsubMessage = {
|
|
3192
|
-
action: "unsubscribe",
|
|
3193
|
-
...subscriptions,
|
|
3194
|
-
};
|
|
3195
|
-
let ws;
|
|
3341
|
+
const ws = new WebSocket(url);
|
|
3196
3342
|
if (streamType === "stock") {
|
|
3197
|
-
|
|
3343
|
+
this.stockWs = ws;
|
|
3198
3344
|
}
|
|
3199
3345
|
else if (streamType === "option") {
|
|
3200
|
-
|
|
3346
|
+
this.optionWs = ws;
|
|
3201
3347
|
}
|
|
3202
3348
|
else {
|
|
3203
|
-
|
|
3204
|
-
}
|
|
3205
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
3206
|
-
ws.send(JSON.stringify(unsubMessage));
|
|
3207
|
-
}
|
|
3208
|
-
}
|
|
3209
|
-
async makeRequest(endpoint, method = "GET", params, baseUrlName = "data") {
|
|
3210
|
-
const baseUrl = baseUrlName === "data"
|
|
3211
|
-
? this.dataURL
|
|
3212
|
-
: baseUrlName === "api"
|
|
3213
|
-
? this.apiURL
|
|
3214
|
-
: this.v1beta1url;
|
|
3215
|
-
const url = new URL(`${baseUrl}${endpoint}`);
|
|
3216
|
-
try {
|
|
3217
|
-
if (params) {
|
|
3218
|
-
Object.entries(params).forEach(([key, value]) => {
|
|
3219
|
-
if (Array.isArray(value)) {
|
|
3220
|
-
url.searchParams.append(key, value.join(","));
|
|
3221
|
-
}
|
|
3222
|
-
else if (value !== undefined && value !== null) {
|
|
3223
|
-
url.searchParams.append(key, value.toString());
|
|
3224
|
-
}
|
|
3225
|
-
});
|
|
3226
|
-
}
|
|
3227
|
-
// Gate all Alpaca market-data calls through the shared 1000/min token
|
|
3228
|
-
// bucket so concurrent callers (bar fetches, quotes, options, snapshots)
|
|
3229
|
-
// can't overrun Alpaca's server-side rate limit. Prior to this, parallel
|
|
3230
|
-
// historical-bar fan-out produced ~125 server-side 429s per minute.
|
|
3231
|
-
await rateLimiters.alpaca.acquire();
|
|
3232
|
-
const response = await fetch(url.toString(), {
|
|
3233
|
-
method,
|
|
3234
|
-
headers: this.headers,
|
|
3235
|
-
signal: createTimeoutSignal(DEFAULT_TIMEOUTS.ALPACA_API),
|
|
3236
|
-
});
|
|
3237
|
-
if (!response.ok) {
|
|
3238
|
-
const errorText = await response.text();
|
|
3239
|
-
log$l(`Market Data API error (${response.status}): ${errorText}`, {
|
|
3240
|
-
type: "error",
|
|
3241
|
-
});
|
|
3242
|
-
throw new Error(`Market Data API error (${response.status}): ${errorText}`);
|
|
3243
|
-
}
|
|
3244
|
-
const data = await response.json();
|
|
3245
|
-
return data;
|
|
3246
|
-
}
|
|
3247
|
-
catch (err) {
|
|
3248
|
-
const error = err;
|
|
3249
|
-
log$l(`Error in makeRequest: ${error.message}. Endpoint: ${endpoint}. Url: ${url.toString()}`, { type: "error" });
|
|
3250
|
-
if (error instanceof TypeError) {
|
|
3251
|
-
log$l(`Network error details: ${error.stack}`, { type: "error" });
|
|
3252
|
-
}
|
|
3253
|
-
throw error;
|
|
3349
|
+
this.cryptoWs = ws;
|
|
3254
3350
|
}
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
*/
|
|
3262
|
-
async getHistoricalBars(params) {
|
|
3263
|
-
const symbols = params.symbols;
|
|
3264
|
-
const symbolsStr = symbols.join(",");
|
|
3265
|
-
const allBars = {};
|
|
3266
|
-
let pageToken = null;
|
|
3267
|
-
let hasMorePages = true;
|
|
3268
|
-
let totalBarsCount = 0;
|
|
3269
|
-
let pageCount = 0;
|
|
3270
|
-
let currency = "";
|
|
3271
|
-
// Initialize bar arrays for each symbol
|
|
3272
|
-
symbols.forEach((symbol) => {
|
|
3273
|
-
allBars[symbol] = [];
|
|
3274
|
-
});
|
|
3275
|
-
log$l(`Starting historical bars fetch for ${symbolsStr} (${params.timeframe}, ${params.start || "no start"} to ${params.end || "no end"})`, {
|
|
3276
|
-
type: "info",
|
|
3277
|
-
});
|
|
3278
|
-
while (hasMorePages) {
|
|
3279
|
-
pageCount++;
|
|
3280
|
-
const requestParams = {
|
|
3281
|
-
...params,
|
|
3282
|
-
adjustment: DEFAULT_ADJUSTMENT,
|
|
3283
|
-
feed: DEFAULT_FEED$1,
|
|
3284
|
-
...(pageToken && { page_token: pageToken }),
|
|
3351
|
+
ws.on("open", () => {
|
|
3352
|
+
log$l(`${streamType} stream connected`, { type: "info" });
|
|
3353
|
+
const authMessage = {
|
|
3354
|
+
action: "auth",
|
|
3355
|
+
key: apiKey,
|
|
3356
|
+
secret: apiSecret,
|
|
3285
3357
|
};
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3358
|
+
ws.send(JSON.stringify(authMessage));
|
|
3359
|
+
});
|
|
3360
|
+
ws.on("message", (data) => {
|
|
3361
|
+
const rawData = data.toString();
|
|
3362
|
+
let messages;
|
|
3363
|
+
try {
|
|
3364
|
+
messages = JSON.parse(rawData);
|
|
3292
3365
|
}
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3366
|
+
catch (e) {
|
|
3367
|
+
log$l(`${streamType} stream received invalid JSON: ${rawData.substring(0, 200)}`, { type: "error" });
|
|
3368
|
+
return;
|
|
3296
3369
|
}
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
bars.forEach((bar) => {
|
|
3307
|
-
const barDate = new Date(bar.t);
|
|
3308
|
-
if (!earliestTimestamp || barDate < earliestTimestamp) {
|
|
3309
|
-
earliestTimestamp = barDate;
|
|
3310
|
-
}
|
|
3311
|
-
if (!latestTimestamp || barDate > latestTimestamp) {
|
|
3312
|
-
latestTimestamp = barDate;
|
|
3313
|
-
}
|
|
3370
|
+
for (const message of messages) {
|
|
3371
|
+
if (message.T === "success" && message.msg === "authenticated") {
|
|
3372
|
+
log$l(`${streamType} stream authenticated`, { type: "info" });
|
|
3373
|
+
this.reconnectAttempts[streamType] = 0;
|
|
3374
|
+
this.sendSubscription(streamType);
|
|
3375
|
+
}
|
|
3376
|
+
else if (message.T === "success" && message.msg === "connected") {
|
|
3377
|
+
log$l(`${streamType} stream connected message received`, {
|
|
3378
|
+
type: "debug",
|
|
3314
3379
|
});
|
|
3315
3380
|
}
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3381
|
+
else if (message.T === "subscription") {
|
|
3382
|
+
log$l(`${streamType} subscription confirmed: trades=${message.trades?.length || 0}, quotes=${message.quotes?.length || 0}, bars=${message.bars?.length || 0}`, { type: "info" });
|
|
3383
|
+
}
|
|
3384
|
+
else if (message.T === "error") {
|
|
3385
|
+
log$l(`${streamType} stream error: ${message.msg} (code: ${message.code}, raw: ${JSON.stringify(message)})`, { type: "error" });
|
|
3386
|
+
// Alpaca code 406: "connection limit exceeded" — account-wide
|
|
3387
|
+
// concurrent-WS cap reached. The Alpaca server will close the
|
|
3388
|
+
// socket immediately after this frame, which would normally
|
|
3389
|
+
// trigger our standard sub-second exponential reconnect chain
|
|
3390
|
+
// (1 s, 2 s, 4 s, 8 s, 16 s, 30 s × 5) — exactly the wrong
|
|
3391
|
+
// behaviour against a rate-limit response. Stamp the recency
|
|
3392
|
+
// marker so {@link scheduleReconnect} switches to the
|
|
3393
|
+
// 5-minute jittered backoff instead.
|
|
3394
|
+
if (typeof message.code === "number" &&
|
|
3395
|
+
message.code === 406) {
|
|
3396
|
+
this.lastConnectionLimitAt[streamType] = Date.now();
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
else if (message.S) {
|
|
3400
|
+
super.emit(`${streamType}-${message.T}`, message);
|
|
3401
|
+
super.emit(`${streamType}-data`, message);
|
|
3402
|
+
}
|
|
3403
|
+
else {
|
|
3404
|
+
log$l(`${streamType} received unknown message type: ${JSON.stringify(message)}`, { type: "debug" });
|
|
3405
|
+
}
|
|
3331
3406
|
}
|
|
3332
|
-
}
|
|
3333
|
-
// Final summary
|
|
3334
|
-
const symbolCounts = Object.entries(allBars)
|
|
3335
|
-
.map(([symbol, bars]) => `${symbol}: ${bars.length}`)
|
|
3336
|
-
.join(", ");
|
|
3337
|
-
log$l(`Historical bars fetch complete: ${totalBarsCount.toLocaleString()} total bars across ${pageCount} pages (${symbolCounts})`, {
|
|
3338
|
-
type: "info",
|
|
3339
|
-
});
|
|
3340
|
-
return {
|
|
3341
|
-
bars: allBars,
|
|
3342
|
-
next_page_token: null, // Always null since we fetch all pages
|
|
3343
|
-
currency: currency || DEFAULT_CURRENCY$1,
|
|
3344
|
-
};
|
|
3345
|
-
}
|
|
3346
|
-
/**
|
|
3347
|
-
* Get the most recent minute bar for requested symbols
|
|
3348
|
-
* @param symbols Array of stock symbols to query
|
|
3349
|
-
* @param currency Optional currency in ISO 4217 format
|
|
3350
|
-
* @returns Latest bar data for each symbol
|
|
3351
|
-
|
|
3352
|
-
*/
|
|
3353
|
-
async getLatestBars(symbols, currency) {
|
|
3354
|
-
return this.makeRequest("/stocks/bars/latest", "GET", {
|
|
3355
|
-
symbols,
|
|
3356
|
-
feed: DEFAULT_FEED$1,
|
|
3357
|
-
currency: currency || DEFAULT_CURRENCY$1,
|
|
3358
|
-
});
|
|
3359
|
-
}
|
|
3360
|
-
/**
|
|
3361
|
-
* Get the last trade for a single symbol
|
|
3362
|
-
* @param symbol The stock symbol to query
|
|
3363
|
-
* @returns Last trade details including price, size, exchange, and conditions
|
|
3364
|
-
*/
|
|
3365
|
-
async getLastTrade(symbol) {
|
|
3366
|
-
return this.makeRequest(`/v1/last/stocks/${symbol}`, "GET");
|
|
3367
|
-
}
|
|
3368
|
-
/**
|
|
3369
|
-
* Get the most recent trades for requested symbols
|
|
3370
|
-
* @param symbols Array of stock symbols to query
|
|
3371
|
-
* @param feed Optional data source (sip/iex/delayed_sip)
|
|
3372
|
-
* @param currency Optional currency in ISO 4217 format
|
|
3373
|
-
* @returns Latest trade data for each symbol
|
|
3374
|
-
|
|
3375
|
-
*/
|
|
3376
|
-
async getLatestTrades(symbols, feed, currency) {
|
|
3377
|
-
return this.makeRequest("/stocks/trades/latest", "GET", {
|
|
3378
|
-
symbols,
|
|
3379
|
-
feed: feed || DEFAULT_FEED$1,
|
|
3380
|
-
currency: currency || DEFAULT_CURRENCY$1,
|
|
3381
3407
|
});
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
* Get the most recent quotes for requested symbols
|
|
3385
|
-
* @param symbols Array of stock symbols to query
|
|
3386
|
-
* @param feed Optional data source (sip/iex/delayed_sip)
|
|
3387
|
-
* @param currency Optional currency in ISO 4217 format
|
|
3388
|
-
* @returns Latest quote data for each symbol
|
|
3389
|
-
*/
|
|
3390
|
-
async getLatestQuotes(symbols, feed, currency) {
|
|
3391
|
-
// Return empty response if symbols array is empty to avoid API error
|
|
3392
|
-
if (!symbols || symbols.length === 0) {
|
|
3393
|
-
log$l("No symbols provided to getLatestQuotes, returning empty response", {
|
|
3408
|
+
ws.on("close", (code) => {
|
|
3409
|
+
log$l(`${streamType} stream disconnected (code: ${code})`, {
|
|
3394
3410
|
type: "warn",
|
|
3395
3411
|
});
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3412
|
+
if (streamType === "stock") {
|
|
3413
|
+
this.stockWs = null;
|
|
3414
|
+
}
|
|
3415
|
+
else if (streamType === "option") {
|
|
3416
|
+
this.optionWs = null;
|
|
3417
|
+
}
|
|
3418
|
+
else {
|
|
3419
|
+
this.cryptoWs = null;
|
|
3420
|
+
}
|
|
3421
|
+
// Reconnect with exponential backoff (unless intentionally closed with code 1000)
|
|
3422
|
+
if (code !== 1000) {
|
|
3423
|
+
this.scheduleReconnect(streamType);
|
|
3424
|
+
}
|
|
3405
3425
|
});
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
* Get the latest quote for a single symbol
|
|
3409
|
-
* @param symbol The stock symbol to query
|
|
3410
|
-
* @param feed Optional data source (sip/iex/delayed_sip)
|
|
3411
|
-
* @param currency Optional currency in ISO 4217 format
|
|
3412
|
-
* @returns Latest quote data with symbol and currency information
|
|
3413
|
-
*/
|
|
3414
|
-
async getLatestQuote(symbol, feed, currency) {
|
|
3415
|
-
return this.makeRequest(`/stocks/${symbol}/quotes/latest`, "GET", {
|
|
3416
|
-
feed: feed || DEFAULT_FEED$1,
|
|
3417
|
-
currency,
|
|
3426
|
+
ws.on("error", (error) => {
|
|
3427
|
+
log$l(`${streamType} stream error: ${error.message}`, { type: "error" });
|
|
3418
3428
|
});
|
|
3419
3429
|
}
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
const
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3430
|
+
scheduleReconnect(streamType) {
|
|
3431
|
+
// 406-recovery fast path. When the most recent close was preceded
|
|
3432
|
+
// by an Alpaca app-level 406 ("connection limit exceeded"), the
|
|
3433
|
+
// standard sub-second exponential ramp is exactly wrong — it
|
|
3434
|
+
// hammers the rate-limit endpoint and prolongs the slot pressure.
|
|
3435
|
+
// Use a 5-minute jittered backoff instead and reset the normal
|
|
3436
|
+
// attempt counter so we don't fall off the end of maxAttempts
|
|
3437
|
+
// and permanently give up on a transient rollover blip.
|
|
3438
|
+
const connectionLimitAt = this.lastConnectionLimitAt[streamType];
|
|
3439
|
+
const isRecentConnectionLimit = typeof connectionLimitAt === "number" &&
|
|
3440
|
+
Date.now() - connectionLimitAt <= this.CONNECTION_LIMIT_RECENCY_MS;
|
|
3441
|
+
if (isRecentConnectionLimit) {
|
|
3442
|
+
const jitter = Math.floor((Math.random() - 0.5) *
|
|
3443
|
+
2 *
|
|
3444
|
+
this.CONNECTION_LIMIT_BACKOFF_JITTER_MS);
|
|
3445
|
+
const delayMs = this.CONNECTION_LIMIT_BACKOFF_MS + jitter;
|
|
3446
|
+
// Reset normal attempt counter so the next 406 retry doesn't
|
|
3447
|
+
// inherit a stale exponential cap.
|
|
3448
|
+
this.reconnectAttempts[streamType] = 0;
|
|
3449
|
+
// Consume the recency marker — subsequent reconnects fall back
|
|
3450
|
+
// to the standard exponential path unless a new 406 arrives.
|
|
3451
|
+
delete this.lastConnectionLimitAt[streamType];
|
|
3452
|
+
log$l(`${streamType} stream: Alpaca 406 connection-limit recovery — backing off ${Math.round(delayMs / 1000)}s before retry to allow account-wide slot release`, { type: "warn" });
|
|
3453
|
+
if (this.reconnectTimers[streamType]) {
|
|
3454
|
+
clearTimeout(this.reconnectTimers[streamType]);
|
|
3455
|
+
}
|
|
3456
|
+
this.reconnectTimers[streamType] = setTimeout(() => {
|
|
3457
|
+
log$l(`${streamType} stream: attempting reconnect after 406-recovery backoff`, { type: "info" });
|
|
3458
|
+
this.connect(streamType);
|
|
3459
|
+
}, delayMs);
|
|
3460
|
+
return;
|
|
3461
|
+
}
|
|
3462
|
+
const attempts = this.reconnectAttempts[streamType] ?? 0;
|
|
3463
|
+
const maxAttempts = 10;
|
|
3464
|
+
if (attempts >= maxAttempts) {
|
|
3465
|
+
log$l(`${streamType} stream: max reconnect attempts (${maxAttempts}) reached, giving up`, { type: "error" });
|
|
3466
|
+
return;
|
|
3467
|
+
}
|
|
3468
|
+
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (capped)
|
|
3469
|
+
const delayMs = Math.min(1000 * Math.pow(2, attempts), 30000);
|
|
3470
|
+
this.reconnectAttempts[streamType] = attempts + 1;
|
|
3471
|
+
log$l(`${streamType} stream: scheduling reconnect attempt ${attempts + 1}/${maxAttempts} in ${delayMs}ms`, { type: "info" });
|
|
3472
|
+
// Clear any existing reconnect timer for this stream
|
|
3473
|
+
if (this.reconnectTimers[streamType]) {
|
|
3474
|
+
clearTimeout(this.reconnectTimers[streamType]);
|
|
3475
|
+
}
|
|
3476
|
+
this.reconnectTimers[streamType] = setTimeout(() => {
|
|
3477
|
+
log$l(`${streamType} stream: reconnecting (attempt ${attempts + 1}/${maxAttempts})`, { type: "info" });
|
|
3478
|
+
this.connect(streamType);
|
|
3479
|
+
}, delayMs);
|
|
3480
|
+
}
|
|
3481
|
+
sendSubscription(streamType) {
|
|
3482
|
+
let ws;
|
|
3483
|
+
let subscriptions;
|
|
3484
|
+
if (streamType === "stock") {
|
|
3485
|
+
ws = this.stockWs;
|
|
3486
|
+
subscriptions = this.stockSubscriptions;
|
|
3487
|
+
}
|
|
3488
|
+
else if (streamType === "option") {
|
|
3489
|
+
ws = this.optionWs;
|
|
3490
|
+
subscriptions = this.optionSubscriptions;
|
|
3491
|
+
}
|
|
3492
|
+
else {
|
|
3493
|
+
ws = this.cryptoWs;
|
|
3494
|
+
subscriptions = this.cryptoSubscriptions;
|
|
3495
|
+
}
|
|
3496
|
+
log$l(`sendSubscription called for ${streamType} (wsReady=${ws?.readyState === WebSocket.OPEN}, trades=${subscriptions.trades?.length || 0}, quotes=${subscriptions.quotes?.length || 0}, bars=${subscriptions.bars?.length || 0})`, {
|
|
3497
|
+
type: "debug",
|
|
3440
3498
|
});
|
|
3441
|
-
if (
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
}
|
|
3446
|
-
|
|
3499
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
3500
|
+
const subMessagePayload = {};
|
|
3501
|
+
if (subscriptions.trades.length > 0) {
|
|
3502
|
+
subMessagePayload.trades = subscriptions.trades;
|
|
3503
|
+
}
|
|
3504
|
+
if (subscriptions.quotes.length > 0) {
|
|
3505
|
+
subMessagePayload.quotes = subscriptions.quotes;
|
|
3506
|
+
}
|
|
3507
|
+
if (subscriptions.bars.length > 0) {
|
|
3508
|
+
subMessagePayload.bars = subscriptions.bars;
|
|
3509
|
+
}
|
|
3510
|
+
if (Object.keys(subMessagePayload).length > 0) {
|
|
3511
|
+
const subMessage = {
|
|
3512
|
+
action: "subscribe",
|
|
3513
|
+
...subMessagePayload,
|
|
3514
|
+
};
|
|
3515
|
+
const messageJson = JSON.stringify(subMessage);
|
|
3516
|
+
log$l(`Sending ${streamType} subscription: ${messageJson}`, {
|
|
3517
|
+
type: "info",
|
|
3518
|
+
});
|
|
3519
|
+
ws.send(messageJson);
|
|
3520
|
+
}
|
|
3521
|
+
else {
|
|
3522
|
+
log$l(`No ${streamType} subscriptions to send (all arrays empty)`, {
|
|
3523
|
+
type: "debug",
|
|
3524
|
+
});
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
else if (ws && ws.readyState === WebSocket.CONNECTING) {
|
|
3528
|
+
// WebSocket is still establishing. Subscriptions are already persisted
|
|
3529
|
+
// in `subscriptions` and will be dispatched automatically by the
|
|
3530
|
+
// auth-success handler once the stream authenticates. This is the
|
|
3531
|
+
// expected transient state during startup / reconnect — not a failure.
|
|
3532
|
+
const queuedTotal = (subscriptions.trades?.length || 0) +
|
|
3533
|
+
(subscriptions.quotes?.length || 0) +
|
|
3534
|
+
(subscriptions.bars?.length || 0);
|
|
3535
|
+
log$l(`${streamType} subscription queued (${queuedTotal} channel entries); will dispatch after WebSocket authenticates`, { type: "debug" });
|
|
3536
|
+
}
|
|
3537
|
+
else {
|
|
3538
|
+
const stateLabel = ws === null
|
|
3539
|
+
? "null"
|
|
3540
|
+
: ws.readyState === WebSocket.CLOSING
|
|
3541
|
+
? "CLOSING"
|
|
3542
|
+
: ws.readyState === WebSocket.CLOSED
|
|
3543
|
+
? "CLOSED"
|
|
3544
|
+
: `unknown(${ws.readyState})`;
|
|
3545
|
+
log$l(`Cannot send ${streamType} subscription: WebSocket in ${stateLabel} state`, { type: "warn" });
|
|
3447
3546
|
}
|
|
3448
|
-
return response.bars[symbol][0];
|
|
3449
3547
|
}
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
* @param end End time in milliseconds
|
|
3455
|
-
* @returns Array of hourly price bars
|
|
3456
|
-
*/
|
|
3457
|
-
async getHourlyPrices(symbol, start, end) {
|
|
3458
|
-
const response = await this.getHistoricalBars({
|
|
3459
|
-
symbols: [symbol],
|
|
3460
|
-
timeframe: "1Hour",
|
|
3461
|
-
start: new Date(start).toISOString(),
|
|
3462
|
-
end: new Date(end).toISOString(),
|
|
3463
|
-
limit: 96, // Last 96 hours (4 days)
|
|
3464
|
-
});
|
|
3465
|
-
return response.bars[symbol] || [];
|
|
3548
|
+
connectStockStream() {
|
|
3549
|
+
if (!this.stockWs) {
|
|
3550
|
+
this.connect("stock");
|
|
3551
|
+
}
|
|
3466
3552
|
}
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
* @param end End time in milliseconds
|
|
3472
|
-
* @returns Array of half-hourly price bars
|
|
3473
|
-
*/
|
|
3474
|
-
async getHalfHourlyPrices(symbol, start, end) {
|
|
3475
|
-
const response = await this.getHistoricalBars({
|
|
3476
|
-
symbols: [symbol],
|
|
3477
|
-
timeframe: "30Min",
|
|
3478
|
-
start: new Date(start).toISOString(),
|
|
3479
|
-
end: new Date(end).toISOString(),
|
|
3480
|
-
limit: 16 * 2 * 4, // last 4 days, 16 hours per day, 2 bars per hour
|
|
3481
|
-
});
|
|
3482
|
-
return response.bars[symbol] || [];
|
|
3553
|
+
connectOptionStream() {
|
|
3554
|
+
if (!this.optionWs) {
|
|
3555
|
+
this.connect("option");
|
|
3556
|
+
}
|
|
3483
3557
|
}
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
* @param end End time in milliseconds
|
|
3489
|
-
* @returns Array of daily price bars
|
|
3490
|
-
*/
|
|
3491
|
-
async getDailyPrices(symbol, start, end) {
|
|
3492
|
-
const response = await this.getHistoricalBars({
|
|
3493
|
-
symbols: [symbol],
|
|
3494
|
-
timeframe: "1Day",
|
|
3495
|
-
start: new Date(start).toISOString(),
|
|
3496
|
-
end: new Date(end).toISOString(),
|
|
3497
|
-
limit: 100, // Last 100 days
|
|
3498
|
-
});
|
|
3499
|
-
return response.bars[symbol] || [];
|
|
3558
|
+
connectCryptoStream() {
|
|
3559
|
+
if (!this.cryptoWs) {
|
|
3560
|
+
this.connect("crypto");
|
|
3561
|
+
}
|
|
3500
3562
|
}
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
* @param start Start time in milliseconds
|
|
3506
|
-
* @param end End time in milliseconds
|
|
3507
|
-
* @returns Array of intraday price bars
|
|
3508
|
-
*/
|
|
3509
|
-
async getIntradayPrices(symbol, minutePeriod, start, end) {
|
|
3510
|
-
const timeframe = `${minutePeriod}Min`;
|
|
3511
|
-
const response = await this.getHistoricalBars({
|
|
3512
|
-
symbols: [symbol],
|
|
3513
|
-
timeframe,
|
|
3514
|
-
start: new Date(start).toISOString(),
|
|
3515
|
-
end: new Date(end).toISOString(),
|
|
3516
|
-
});
|
|
3517
|
-
return response.bars[symbol] || [];
|
|
3563
|
+
disconnectStockStream() {
|
|
3564
|
+
if (this.stockWs) {
|
|
3565
|
+
this.stockWs.close();
|
|
3566
|
+
}
|
|
3518
3567
|
}
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
* @returns A string summarizing the price data
|
|
3523
|
-
*/
|
|
3524
|
-
static analyzeBars(bars) {
|
|
3525
|
-
if (!bars || bars.length === 0) {
|
|
3526
|
-
return "No price data available";
|
|
3568
|
+
disconnectOptionStream() {
|
|
3569
|
+
if (this.optionWs) {
|
|
3570
|
+
this.optionWs.close();
|
|
3527
3571
|
}
|
|
3528
|
-
const firstBar = bars[0];
|
|
3529
|
-
const lastBar = bars[bars.length - 1];
|
|
3530
|
-
const priceChange = lastBar.c - firstBar.o;
|
|
3531
|
-
const percentChange = (priceChange / firstBar.o) * 100;
|
|
3532
|
-
const volumeChange = lastBar.v - firstBar.v;
|
|
3533
|
-
const percentVolumeChange = (volumeChange / firstBar.v) * 100;
|
|
3534
|
-
const high = Math.max(...bars.map((bar) => bar.h));
|
|
3535
|
-
const low = Math.min(...bars.map((bar) => bar.l));
|
|
3536
|
-
const totalVolume = bars.reduce((sum, bar) => sum + bar.v, 0);
|
|
3537
|
-
const avgVolume = totalVolume / bars.length;
|
|
3538
|
-
return (`Price: $${firstBar.o.toFixed(2)} -> $${lastBar.c.toFixed(2)} (${percentChange.toFixed(2)}%), ` +
|
|
3539
|
-
`Volume: ${firstBar.v.toLocaleString()} -> ${lastBar.v.toLocaleString()} (${percentVolumeChange.toFixed(2)}%), ` +
|
|
3540
|
-
`High: $${high.toFixed(2)}, Low: $${low.toFixed(2)}, ` +
|
|
3541
|
-
`Avg Volume: ${avgVolume.toLocaleString()}`);
|
|
3542
3572
|
}
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
* @see https://docs.alpaca.markets/reference/get-v2-assets-1
|
|
3548
|
-
*/
|
|
3549
|
-
async getAssets(params) {
|
|
3550
|
-
// Endpoint: GET /v2/assets
|
|
3551
|
-
return this.makeRequest("/assets", "GET", params, "api"); // use apiURL
|
|
3573
|
+
disconnectCryptoStream() {
|
|
3574
|
+
if (this.cryptoWs) {
|
|
3575
|
+
this.cryptoWs.close();
|
|
3576
|
+
}
|
|
3552
3577
|
}
|
|
3553
3578
|
/**
|
|
3554
|
-
*
|
|
3555
|
-
* @param
|
|
3556
|
-
* @returns
|
|
3557
|
-
* @see https://docs.alpaca.markets/reference/get-v2-assets-symbol_or_asset_id
|
|
3579
|
+
* Check if a specific stream is connected
|
|
3580
|
+
* @param streamType - The type of stream to check
|
|
3581
|
+
* @returns True if the stream is connected
|
|
3558
3582
|
*/
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3583
|
+
isStreamConnected(streamType) {
|
|
3584
|
+
if (streamType === "stock") {
|
|
3585
|
+
return (this.stockWs !== null && this.stockWs.readyState === WebSocket.OPEN);
|
|
3586
|
+
}
|
|
3587
|
+
else if (streamType === "option") {
|
|
3588
|
+
return (this.optionWs !== null && this.optionWs.readyState === WebSocket.OPEN);
|
|
3589
|
+
}
|
|
3590
|
+
else {
|
|
3591
|
+
return (this.cryptoWs !== null && this.cryptoWs.readyState === WebSocket.OPEN);
|
|
3592
|
+
}
|
|
3562
3593
|
}
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3594
|
+
subscribe(streamType, subscriptions) {
|
|
3595
|
+
let currentSubscriptions;
|
|
3596
|
+
if (streamType === "stock") {
|
|
3597
|
+
currentSubscriptions = this.stockSubscriptions;
|
|
3598
|
+
}
|
|
3599
|
+
else if (streamType === "option") {
|
|
3600
|
+
currentSubscriptions = this.optionSubscriptions;
|
|
3601
|
+
}
|
|
3602
|
+
else {
|
|
3603
|
+
currentSubscriptions = this.cryptoSubscriptions;
|
|
3604
|
+
}
|
|
3605
|
+
Object.entries(subscriptions).forEach(([key, value]) => {
|
|
3606
|
+
if (value) {
|
|
3607
|
+
currentSubscriptions[key] = [
|
|
3608
|
+
...new Set([...(currentSubscriptions[key] || []), ...value]),
|
|
3609
|
+
];
|
|
3610
|
+
}
|
|
3611
|
+
});
|
|
3612
|
+
this.sendSubscription(streamType);
|
|
3574
3613
|
}
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3614
|
+
unsubscribe(streamType, subscriptions) {
|
|
3615
|
+
let currentSubscriptions;
|
|
3616
|
+
if (streamType === "stock") {
|
|
3617
|
+
currentSubscriptions = this.stockSubscriptions;
|
|
3618
|
+
}
|
|
3619
|
+
else if (streamType === "option") {
|
|
3620
|
+
currentSubscriptions = this.optionSubscriptions;
|
|
3621
|
+
}
|
|
3622
|
+
else {
|
|
3623
|
+
currentSubscriptions = this.cryptoSubscriptions;
|
|
3624
|
+
}
|
|
3625
|
+
Object.entries(subscriptions).forEach(([key, value]) => {
|
|
3626
|
+
if (value) {
|
|
3627
|
+
currentSubscriptions[key] = (currentSubscriptions[key] || []).filter((s) => !value.includes(s));
|
|
3628
|
+
}
|
|
3629
|
+
});
|
|
3630
|
+
const unsubMessage = {
|
|
3631
|
+
action: "unsubscribe",
|
|
3632
|
+
...subscriptions,
|
|
3633
|
+
};
|
|
3634
|
+
let ws;
|
|
3635
|
+
if (streamType === "stock") {
|
|
3636
|
+
ws = this.stockWs;
|
|
3637
|
+
}
|
|
3638
|
+
else if (streamType === "option") {
|
|
3639
|
+
ws = this.optionWs;
|
|
3640
|
+
}
|
|
3641
|
+
else {
|
|
3642
|
+
ws = this.cryptoWs;
|
|
3643
|
+
}
|
|
3644
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
3645
|
+
ws.send(JSON.stringify(unsubMessage));
|
|
3646
|
+
}
|
|
3586
3647
|
}
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3648
|
+
async makeRequest(endpoint, method = "GET", params, baseUrlName = "data") {
|
|
3649
|
+
const baseUrl = baseUrlName === "data"
|
|
3650
|
+
? this.dataURL
|
|
3651
|
+
: baseUrlName === "api"
|
|
3652
|
+
? this.apiURL
|
|
3653
|
+
: this.v1beta1url;
|
|
3654
|
+
const url = new URL(`${baseUrl}${endpoint}`);
|
|
3655
|
+
try {
|
|
3656
|
+
if (params) {
|
|
3657
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
3658
|
+
if (Array.isArray(value)) {
|
|
3659
|
+
url.searchParams.append(key, value.join(","));
|
|
3660
|
+
}
|
|
3661
|
+
else if (value !== undefined && value !== null) {
|
|
3662
|
+
url.searchParams.append(key, value.toString());
|
|
3663
|
+
}
|
|
3664
|
+
});
|
|
3665
|
+
}
|
|
3666
|
+
// Gate all Alpaca market-data calls through the shared 1000/min token
|
|
3667
|
+
// bucket so concurrent callers (bar fetches, quotes, options, snapshots)
|
|
3668
|
+
// can't overrun Alpaca's server-side rate limit. Prior to this, parallel
|
|
3669
|
+
// historical-bar fan-out produced ~125 server-side 429s per minute.
|
|
3670
|
+
await rateLimiters.alpaca.acquire();
|
|
3671
|
+
// Retry ONLY transient connection faults, and only on GET (every
|
|
3672
|
+
// market-data read here is idempotent). A non-2xx response is a real
|
|
3673
|
+
// answer from Alpaca and is never retried — that path still throws on
|
|
3674
|
+
// the first attempt exactly as before.
|
|
3675
|
+
let response;
|
|
3676
|
+
let lastNetworkError;
|
|
3677
|
+
for (let attempt = 0; attempt < TRANSIENT_NETWORK_RETRY_ATTEMPTS; attempt += 1) {
|
|
3678
|
+
try {
|
|
3679
|
+
response = await fetch(url.toString(), {
|
|
3680
|
+
method,
|
|
3681
|
+
headers: this.headers,
|
|
3682
|
+
signal: createTimeoutSignal(DEFAULT_TIMEOUTS.ALPACA_API),
|
|
3683
|
+
});
|
|
3684
|
+
break;
|
|
3685
|
+
}
|
|
3686
|
+
catch (networkErr) {
|
|
3687
|
+
lastNetworkError = networkErr;
|
|
3688
|
+
const retryable = method === "GET" &&
|
|
3689
|
+
isTransientNetworkError(networkErr) &&
|
|
3690
|
+
attempt < TRANSIENT_NETWORK_RETRY_ATTEMPTS - 1;
|
|
3691
|
+
if (!retryable) {
|
|
3692
|
+
throw networkErr;
|
|
3693
|
+
}
|
|
3694
|
+
const delayMs = transientRetryDelayMs(attempt);
|
|
3695
|
+
log$l(`Transient network fault on ${endpoint} (attempt ${attempt + 1}/${TRANSIENT_NETWORK_RETRY_ATTEMPTS}); retrying in ${delayMs}ms`, { type: "warn" });
|
|
3696
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
3697
|
+
// Re-acquire the rate-limit token so a retry storm cannot overrun
|
|
3698
|
+
// Alpaca's server-side limit.
|
|
3699
|
+
await rateLimiters.alpaca.acquire();
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
if (!response) {
|
|
3703
|
+
throw lastNetworkError instanceof Error
|
|
3704
|
+
? lastNetworkError
|
|
3705
|
+
: new Error(`Market Data API request failed for ${endpoint}`);
|
|
3706
|
+
}
|
|
3707
|
+
if (!response.ok) {
|
|
3708
|
+
const errorText = await response.text();
|
|
3709
|
+
log$l(`Market Data API error (${response.status}): ${errorText}`, {
|
|
3710
|
+
type: "error",
|
|
3711
|
+
});
|
|
3712
|
+
throw new Error(`Market Data API error (${response.status}): ${errorText}`);
|
|
3713
|
+
}
|
|
3714
|
+
const data = await response.json();
|
|
3715
|
+
return data;
|
|
3716
|
+
}
|
|
3717
|
+
catch (err) {
|
|
3718
|
+
const error = err;
|
|
3719
|
+
log$l(`Error in makeRequest: ${error.message}. Endpoint: ${endpoint}. Url: ${url.toString()}`, { type: "error" });
|
|
3720
|
+
if (error instanceof TypeError) {
|
|
3721
|
+
log$l(`Network error details: ${error.stack}`, { type: "error" });
|
|
3722
|
+
}
|
|
3723
|
+
throw error;
|
|
3724
|
+
}
|
|
3598
3725
|
}
|
|
3599
3726
|
/**
|
|
3600
|
-
* Get historical OHLCV bars for
|
|
3727
|
+
* Get historical OHLCV bars for specified symbols, including pre-market and post-market data
|
|
3601
3728
|
* Automatically handles pagination to fetch all available data
|
|
3602
|
-
* @param params
|
|
3603
|
-
* @returns Historical
|
|
3604
|
-
|
|
3605
|
-
* @see https://docs.alpaca.markets/reference/optionbars
|
|
3729
|
+
* @param params Parameters for historical bars request
|
|
3730
|
+
* @returns Historical bars data with all pages combined
|
|
3606
3731
|
*/
|
|
3607
|
-
async
|
|
3732
|
+
async getHistoricalBars(params) {
|
|
3608
3733
|
const symbols = params.symbols;
|
|
3609
3734
|
const symbolsStr = symbols.join(",");
|
|
3610
3735
|
const allBars = {};
|
|
@@ -3612,26 +3737,33 @@ class AlpacaMarketDataAPI extends EventEmitter {
|
|
|
3612
3737
|
let hasMorePages = true;
|
|
3613
3738
|
let totalBarsCount = 0;
|
|
3614
3739
|
let pageCount = 0;
|
|
3740
|
+
let currency = "";
|
|
3615
3741
|
// Initialize bar arrays for each symbol
|
|
3616
3742
|
symbols.forEach((symbol) => {
|
|
3617
3743
|
allBars[symbol] = [];
|
|
3618
3744
|
});
|
|
3619
|
-
log$l(`Starting historical
|
|
3745
|
+
log$l(`Starting historical bars fetch for ${symbolsStr} (${params.timeframe}, ${params.start || "no start"} to ${params.end || "no end"})`, {
|
|
3620
3746
|
type: "info",
|
|
3621
3747
|
});
|
|
3622
3748
|
while (hasMorePages) {
|
|
3623
3749
|
pageCount++;
|
|
3624
3750
|
const requestParams = {
|
|
3625
3751
|
...params,
|
|
3752
|
+
adjustment: DEFAULT_ADJUSTMENT,
|
|
3753
|
+
feed: DEFAULT_FEED$1,
|
|
3626
3754
|
...(pageToken && { page_token: pageToken }),
|
|
3627
3755
|
};
|
|
3628
|
-
const response = await this.makeRequest("/
|
|
3756
|
+
const response = await this.makeRequest("/stocks/bars", "GET", requestParams);
|
|
3629
3757
|
if (!response.bars) {
|
|
3630
|
-
log$l(`No
|
|
3758
|
+
log$l(`No bars data found in response for ${symbolsStr}`, {
|
|
3631
3759
|
type: "warn",
|
|
3632
3760
|
});
|
|
3633
3761
|
break;
|
|
3634
3762
|
}
|
|
3763
|
+
// Track currency from first response
|
|
3764
|
+
if (!currency) {
|
|
3765
|
+
currency = response.currency;
|
|
3766
|
+
}
|
|
3635
3767
|
// Combine bars for each symbol
|
|
3636
3768
|
let pageBarsCount = 0;
|
|
3637
3769
|
let earliestTimestamp = null;
|
|
@@ -3659,12 +3791,12 @@ class AlpacaMarketDataAPI extends EventEmitter {
|
|
|
3659
3791
|
const dateRangeStr = earliestTimestamp && latestTimestamp
|
|
3660
3792
|
? `${earliestTimestamp.toLocaleDateString("en-US", { timeZone: "America/New_York" })} to ${latestTimestamp.toLocaleDateString("en-US", { timeZone: "America/New_York" })}`
|
|
3661
3793
|
: "unknown range";
|
|
3662
|
-
log$l(`Page ${pageCount}: Fetched ${pageBarsCount.toLocaleString()}
|
|
3794
|
+
log$l(`Page ${pageCount}: Fetched ${pageBarsCount.toLocaleString()} bars (total: ${totalBarsCount.toLocaleString()}) for ${symbolsStr}, date range: ${dateRangeStr}${hasMorePages ? ", more pages available" : ", complete"}`, {
|
|
3663
3795
|
type: "info",
|
|
3664
3796
|
});
|
|
3665
3797
|
// Prevent infinite loops
|
|
3666
3798
|
if (pageCount > 1000) {
|
|
3667
|
-
log$l(`Stopping
|
|
3799
|
+
log$l(`Stopping pagination after ${pageCount} pages to prevent infinite loop`, { type: "warn" });
|
|
3668
3800
|
break;
|
|
3669
3801
|
}
|
|
3670
3802
|
}
|
|
@@ -3672,711 +3804,636 @@ class AlpacaMarketDataAPI extends EventEmitter {
|
|
|
3672
3804
|
const symbolCounts = Object.entries(allBars)
|
|
3673
3805
|
.map(([symbol, bars]) => `${symbol}: ${bars.length}`)
|
|
3674
3806
|
.join(", ");
|
|
3675
|
-
log$l(`Historical
|
|
3807
|
+
log$l(`Historical bars fetch complete: ${totalBarsCount.toLocaleString()} total bars across ${pageCount} pages (${symbolCounts})`, {
|
|
3676
3808
|
type: "info",
|
|
3677
3809
|
});
|
|
3678
3810
|
return {
|
|
3679
3811
|
bars: allBars,
|
|
3680
|
-
next_page_token:
|
|
3812
|
+
next_page_token: null, // Always null since we fetch all pages
|
|
3813
|
+
currency: currency || DEFAULT_CURRENCY$1,
|
|
3681
3814
|
};
|
|
3682
3815
|
}
|
|
3683
3816
|
/**
|
|
3684
|
-
* Get
|
|
3685
|
-
*
|
|
3686
|
-
* @param
|
|
3687
|
-
* @returns
|
|
3817
|
+
* Get the most recent minute bar for requested symbols
|
|
3818
|
+
* @param symbols Array of stock symbols to query
|
|
3819
|
+
* @param currency Optional currency in ISO 4217 format
|
|
3820
|
+
* @returns Latest bar data for each symbol
|
|
3688
3821
|
|
|
3689
|
-
* @see https://docs.alpaca.markets/reference/optiontrades
|
|
3690
3822
|
*/
|
|
3691
|
-
async
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
let hasMorePages = true;
|
|
3697
|
-
let totalTradesCount = 0;
|
|
3698
|
-
let pageCount = 0;
|
|
3699
|
-
// Initialize trades arrays for each symbol
|
|
3700
|
-
symbols.forEach((symbol) => {
|
|
3701
|
-
allTrades[symbol] = [];
|
|
3823
|
+
async getLatestBars(symbols, currency) {
|
|
3824
|
+
return this.makeRequest("/stocks/bars/latest", "GET", {
|
|
3825
|
+
symbols,
|
|
3826
|
+
feed: DEFAULT_FEED$1,
|
|
3827
|
+
currency: currency || DEFAULT_CURRENCY$1,
|
|
3702
3828
|
});
|
|
3703
|
-
|
|
3704
|
-
|
|
3829
|
+
}
|
|
3830
|
+
/**
|
|
3831
|
+
* Get the last trade for a single symbol
|
|
3832
|
+
* @param symbol The stock symbol to query
|
|
3833
|
+
* @returns Last trade details including price, size, exchange, and conditions
|
|
3834
|
+
*/
|
|
3835
|
+
async getLastTrade(symbol) {
|
|
3836
|
+
return this.makeRequest(`/v1/last/stocks/${symbol}`, "GET");
|
|
3837
|
+
}
|
|
3838
|
+
/**
|
|
3839
|
+
* Get the most recent trades for requested symbols
|
|
3840
|
+
* @param symbols Array of stock symbols to query
|
|
3841
|
+
* @param feed Optional data source (sip/iex/delayed_sip)
|
|
3842
|
+
* @param currency Optional currency in ISO 4217 format
|
|
3843
|
+
* @returns Latest trade data for each symbol
|
|
3844
|
+
|
|
3845
|
+
*/
|
|
3846
|
+
async getLatestTrades(symbols, feed, currency) {
|
|
3847
|
+
return this.makeRequest("/stocks/trades/latest", "GET", {
|
|
3848
|
+
symbols,
|
|
3849
|
+
feed: feed || DEFAULT_FEED$1,
|
|
3850
|
+
currency: currency || DEFAULT_CURRENCY$1,
|
|
3705
3851
|
});
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
// Combine trades for each symbol
|
|
3720
|
-
let pageTradesCount = 0;
|
|
3721
|
-
let earliestTimestamp = null;
|
|
3722
|
-
let latestTimestamp = null;
|
|
3723
|
-
Object.entries(response.trades).forEach(([symbol, trades]) => {
|
|
3724
|
-
if (trades && trades.length > 0) {
|
|
3725
|
-
allTrades[symbol] = [...allTrades[symbol], ...trades];
|
|
3726
|
-
pageTradesCount += trades.length;
|
|
3727
|
-
// Track date range for this page
|
|
3728
|
-
trades.forEach((trade) => {
|
|
3729
|
-
const tradeDate = new Date(trade.t);
|
|
3730
|
-
if (!earliestTimestamp || tradeDate < earliestTimestamp) {
|
|
3731
|
-
earliestTimestamp = tradeDate;
|
|
3732
|
-
}
|
|
3733
|
-
if (!latestTimestamp || tradeDate > latestTimestamp) {
|
|
3734
|
-
latestTimestamp = tradeDate;
|
|
3735
|
-
}
|
|
3736
|
-
});
|
|
3737
|
-
}
|
|
3852
|
+
}
|
|
3853
|
+
/**
|
|
3854
|
+
* Get the most recent quotes for requested symbols
|
|
3855
|
+
* @param symbols Array of stock symbols to query
|
|
3856
|
+
* @param feed Optional data source (sip/iex/delayed_sip)
|
|
3857
|
+
* @param currency Optional currency in ISO 4217 format
|
|
3858
|
+
* @returns Latest quote data for each symbol
|
|
3859
|
+
*/
|
|
3860
|
+
async getLatestQuotes(symbols, feed, currency) {
|
|
3861
|
+
// Return empty response if symbols array is empty to avoid API error
|
|
3862
|
+
if (!symbols || symbols.length === 0) {
|
|
3863
|
+
log$l("No symbols provided to getLatestQuotes, returning empty response", {
|
|
3864
|
+
type: "warn",
|
|
3738
3865
|
});
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3866
|
+
return {
|
|
3867
|
+
quotes: {},
|
|
3868
|
+
currency: currency || DEFAULT_CURRENCY$1,
|
|
3869
|
+
};
|
|
3870
|
+
}
|
|
3871
|
+
return this.makeRequest("/stocks/quotes/latest", "GET", {
|
|
3872
|
+
symbols,
|
|
3873
|
+
feed: feed || DEFAULT_FEED$1,
|
|
3874
|
+
currency: currency || DEFAULT_CURRENCY$1,
|
|
3875
|
+
});
|
|
3876
|
+
}
|
|
3877
|
+
/**
|
|
3878
|
+
* Get the latest quote for a single symbol
|
|
3879
|
+
* @param symbol The stock symbol to query
|
|
3880
|
+
* @param feed Optional data source (sip/iex/delayed_sip)
|
|
3881
|
+
* @param currency Optional currency in ISO 4217 format
|
|
3882
|
+
* @returns Latest quote data with symbol and currency information
|
|
3883
|
+
*/
|
|
3884
|
+
async getLatestQuote(symbol, feed, currency) {
|
|
3885
|
+
return this.makeRequest(`/stocks/${symbol}/quotes/latest`, "GET", {
|
|
3886
|
+
feed: feed || DEFAULT_FEED$1,
|
|
3887
|
+
currency,
|
|
3888
|
+
});
|
|
3889
|
+
}
|
|
3890
|
+
/**
|
|
3891
|
+
* Get the previous day's closing price for a symbol
|
|
3892
|
+
* @param symbol The stock symbol to query
|
|
3893
|
+
* @param referenceDate Optional reference date to get the previous close for
|
|
3894
|
+
* @returns Previous day's closing price data
|
|
3895
|
+
*/
|
|
3896
|
+
async getPreviousClose(symbol, referenceDate) {
|
|
3897
|
+
const date = referenceDate || new Date();
|
|
3898
|
+
const prevMarketDate = getLastFullTradingDate(date);
|
|
3899
|
+
// Alpaca bars use inclusive-start, exclusive-end (t >= start AND t < end).
|
|
3900
|
+
// When start === end the range is empty and zero bars are returned.
|
|
3901
|
+
// Set end to the next calendar day to capture exactly one daily bar.
|
|
3902
|
+
const endDate = new Date(prevMarketDate.date);
|
|
3903
|
+
endDate.setDate(endDate.getDate() + 1);
|
|
3904
|
+
const response = await this.getHistoricalBars({
|
|
3905
|
+
symbols: [symbol],
|
|
3906
|
+
timeframe: "1Day",
|
|
3907
|
+
start: prevMarketDate.date.toISOString(),
|
|
3908
|
+
end: endDate.toISOString(),
|
|
3909
|
+
limit: 1,
|
|
3910
|
+
});
|
|
3911
|
+
if (!response.bars[symbol] || response.bars[symbol].length === 0) {
|
|
3912
|
+
log$l(`No previous close data available for ${symbol}`, {
|
|
3913
|
+
type: "error",
|
|
3914
|
+
symbol,
|
|
3748
3915
|
});
|
|
3749
|
-
|
|
3750
|
-
if (pageCount > 1000) {
|
|
3751
|
-
log$l(`Stopping options trades pagination after ${pageCount} pages to prevent infinite loop`, { type: "warn" });
|
|
3752
|
-
break;
|
|
3753
|
-
}
|
|
3916
|
+
return null;
|
|
3754
3917
|
}
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3918
|
+
return response.bars[symbol][0];
|
|
3919
|
+
}
|
|
3920
|
+
/**
|
|
3921
|
+
* Get hourly price data for a symbol
|
|
3922
|
+
* @param symbol The stock symbol to query
|
|
3923
|
+
* @param start Start time in milliseconds
|
|
3924
|
+
* @param end End time in milliseconds
|
|
3925
|
+
* @returns Array of hourly price bars
|
|
3926
|
+
*/
|
|
3927
|
+
async getHourlyPrices(symbol, start, end) {
|
|
3928
|
+
const response = await this.getHistoricalBars({
|
|
3929
|
+
symbols: [symbol],
|
|
3930
|
+
timeframe: "1Hour",
|
|
3931
|
+
start: new Date(start).toISOString(),
|
|
3932
|
+
end: new Date(end).toISOString(),
|
|
3933
|
+
limit: 96, // Last 96 hours (4 days)
|
|
3761
3934
|
});
|
|
3762
|
-
return
|
|
3763
|
-
trades: allTrades,
|
|
3764
|
-
next_page_token: undefined, // Always undefined since we fetch all pages
|
|
3765
|
-
};
|
|
3935
|
+
return response.bars[symbol] || [];
|
|
3766
3936
|
}
|
|
3767
3937
|
/**
|
|
3768
|
-
* Get
|
|
3769
|
-
*
|
|
3770
|
-
* @param
|
|
3771
|
-
* @
|
|
3772
|
-
|
|
3773
|
-
* @see https://docs.alpaca.markets/reference/optionsnapshots
|
|
3938
|
+
* Get half-hourly price data for a symbol
|
|
3939
|
+
* @param symbol The stock symbol to query
|
|
3940
|
+
* @param start Start time in milliseconds
|
|
3941
|
+
* @param end End time in milliseconds
|
|
3942
|
+
* @returns Array of half-hourly price bars
|
|
3774
3943
|
*/
|
|
3775
|
-
async
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3944
|
+
async getHalfHourlyPrices(symbol, start, end) {
|
|
3945
|
+
const response = await this.getHistoricalBars({
|
|
3946
|
+
symbols: [symbol],
|
|
3947
|
+
timeframe: "30Min",
|
|
3948
|
+
start: new Date(start).toISOString(),
|
|
3949
|
+
end: new Date(end).toISOString(),
|
|
3950
|
+
limit: 16 * 2 * 4, // last 4 days, 16 hours per day, 2 bars per hour
|
|
3951
|
+
});
|
|
3952
|
+
return response.bars[symbol] || [];
|
|
3779
3953
|
}
|
|
3780
3954
|
/**
|
|
3781
|
-
* Get
|
|
3782
|
-
*
|
|
3783
|
-
* @param
|
|
3784
|
-
* @
|
|
3785
|
-
|
|
3786
|
-
* @see https://docs.alpaca.markets/reference/optionmetaconditions
|
|
3955
|
+
* Get daily price data for a symbol
|
|
3956
|
+
* @param symbol The stock symbol to query
|
|
3957
|
+
* @param start Start time in milliseconds
|
|
3958
|
+
* @param end End time in milliseconds
|
|
3959
|
+
* @returns Array of daily price bars
|
|
3787
3960
|
*/
|
|
3788
|
-
async
|
|
3789
|
-
|
|
3961
|
+
async getDailyPrices(symbol, start, end) {
|
|
3962
|
+
const response = await this.getHistoricalBars({
|
|
3963
|
+
symbols: [symbol],
|
|
3964
|
+
timeframe: "1Day",
|
|
3965
|
+
start: new Date(start).toISOString(),
|
|
3966
|
+
end: new Date(end).toISOString(),
|
|
3967
|
+
limit: 100, // Last 100 days
|
|
3968
|
+
});
|
|
3969
|
+
return response.bars[symbol] || [];
|
|
3790
3970
|
}
|
|
3791
3971
|
/**
|
|
3792
|
-
* Get
|
|
3793
|
-
*
|
|
3794
|
-
* @
|
|
3795
|
-
|
|
3796
|
-
* @
|
|
3972
|
+
* Get intraday price data for a symbol
|
|
3973
|
+
* @param symbol The stock symbol to query
|
|
3974
|
+
* @param minutePeriod Minutes per bar (1, 5, 15, etc.)
|
|
3975
|
+
* @param start Start time in milliseconds
|
|
3976
|
+
* @param end End time in milliseconds
|
|
3977
|
+
* @returns Array of intraday price bars
|
|
3797
3978
|
*/
|
|
3798
|
-
async
|
|
3799
|
-
|
|
3979
|
+
async getIntradayPrices(symbol, minutePeriod, start, end) {
|
|
3980
|
+
const timeframe = `${minutePeriod}Min`;
|
|
3981
|
+
const response = await this.getHistoricalBars({
|
|
3982
|
+
symbols: [symbol],
|
|
3983
|
+
timeframe,
|
|
3984
|
+
start: new Date(start).toISOString(),
|
|
3985
|
+
end: new Date(end).toISOString(),
|
|
3986
|
+
});
|
|
3987
|
+
return response.bars[symbol] || [];
|
|
3800
3988
|
}
|
|
3801
3989
|
/**
|
|
3802
|
-
* Analyzes an array of
|
|
3803
|
-
* @param bars Array of
|
|
3804
|
-
* @returns A string summarizing the
|
|
3990
|
+
* Analyzes an array of price bars and returns a summary string
|
|
3991
|
+
* @param bars Array of price bars to analyze
|
|
3992
|
+
* @returns A string summarizing the price data
|
|
3805
3993
|
*/
|
|
3806
|
-
static
|
|
3994
|
+
static analyzeBars(bars) {
|
|
3807
3995
|
if (!bars || bars.length === 0) {
|
|
3808
|
-
return "No
|
|
3996
|
+
return "No price data available";
|
|
3809
3997
|
}
|
|
3810
3998
|
const firstBar = bars[0];
|
|
3811
3999
|
const lastBar = bars[bars.length - 1];
|
|
3812
4000
|
const priceChange = lastBar.c - firstBar.o;
|
|
3813
4001
|
const percentChange = (priceChange / firstBar.o) * 100;
|
|
3814
4002
|
const volumeChange = lastBar.v - firstBar.v;
|
|
3815
|
-
const percentVolumeChange =
|
|
4003
|
+
const percentVolumeChange = (volumeChange / firstBar.v) * 100;
|
|
3816
4004
|
const high = Math.max(...bars.map((bar) => bar.h));
|
|
3817
4005
|
const low = Math.min(...bars.map((bar) => bar.l));
|
|
3818
4006
|
const totalVolume = bars.reduce((sum, bar) => sum + bar.v, 0);
|
|
3819
4007
|
const avgVolume = totalVolume / bars.length;
|
|
3820
|
-
return (`
|
|
4008
|
+
return (`Price: $${firstBar.o.toFixed(2)} -> $${lastBar.c.toFixed(2)} (${percentChange.toFixed(2)}%), ` +
|
|
3821
4009
|
`Volume: ${firstBar.v.toLocaleString()} -> ${lastBar.v.toLocaleString()} (${percentVolumeChange.toFixed(2)}%), ` +
|
|
3822
4010
|
`High: $${high.toFixed(2)}, Low: $${low.toFixed(2)}, ` +
|
|
3823
4011
|
`Avg Volume: ${avgVolume.toLocaleString()}`);
|
|
3824
4012
|
}
|
|
3825
4013
|
/**
|
|
3826
|
-
*
|
|
3827
|
-
* @param
|
|
3828
|
-
* @returns
|
|
4014
|
+
* Get all assets available for trade and data consumption from Alpaca
|
|
4015
|
+
* @param params Optional query params: status (e.g. 'active'), asset_class (e.g. 'us_equity', 'crypto')
|
|
4016
|
+
* @returns Array of AlpacaAsset objects
|
|
4017
|
+
* @see https://docs.alpaca.markets/reference/get-v2-assets-1
|
|
3829
4018
|
*/
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
}
|
|
3834
|
-
const parts = [];
|
|
3835
|
-
if (greeks.delta !== undefined)
|
|
3836
|
-
parts.push(`Delta: ${greeks.delta.toFixed(4)}`);
|
|
3837
|
-
if (greeks.gamma !== undefined)
|
|
3838
|
-
parts.push(`Gamma: ${greeks.gamma.toFixed(4)}`);
|
|
3839
|
-
if (greeks.theta !== undefined)
|
|
3840
|
-
parts.push(`Theta: ${greeks.theta.toFixed(4)}`);
|
|
3841
|
-
if (greeks.vega !== undefined)
|
|
3842
|
-
parts.push(`Vega: ${greeks.vega.toFixed(4)}`);
|
|
3843
|
-
if (greeks.rho !== undefined)
|
|
3844
|
-
parts.push(`Rho: ${greeks.rho.toFixed(4)}`);
|
|
3845
|
-
return parts.length > 0 ? parts.join(", ") : "No greeks data available";
|
|
4019
|
+
async getAssets(params) {
|
|
4020
|
+
// Endpoint: GET /v2/assets
|
|
4021
|
+
return this.makeRequest("/assets", "GET", params, "api"); // use apiURL
|
|
3846
4022
|
}
|
|
3847
4023
|
/**
|
|
3848
|
-
*
|
|
3849
|
-
* @param
|
|
3850
|
-
* @
|
|
3851
|
-
* @
|
|
4024
|
+
* Get a single asset by symbol or asset_id
|
|
4025
|
+
* @param symbolOrAssetId Symbol or asset_id
|
|
4026
|
+
* @returns AlpacaAsset object
|
|
4027
|
+
* @see https://docs.alpaca.markets/reference/get-v2-assets-symbol_or_asset_id
|
|
3852
4028
|
*/
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
}
|
|
3857
|
-
const descriptions = conditionCodes
|
|
3858
|
-
.map((code) => conditionCodesMap[code] || `Unknown (${code})`)
|
|
3859
|
-
.filter((desc) => desc !== undefined);
|
|
3860
|
-
return descriptions.length > 0
|
|
3861
|
-
? descriptions.join(", ")
|
|
3862
|
-
: "No condition descriptions available";
|
|
4029
|
+
async getAsset(symbolOrAssetId) {
|
|
4030
|
+
// Endpoint: GET /v2/assets/{symbol_or_asset_id}
|
|
4031
|
+
return this.makeRequest(`/assets/${encodeURIComponent(symbolOrAssetId)}`, "GET", undefined, "api");
|
|
3863
4032
|
}
|
|
4033
|
+
// ===== OPTIONS MARKET DATA METHODS =====
|
|
3864
4034
|
/**
|
|
3865
|
-
*
|
|
3866
|
-
*
|
|
3867
|
-
* @param
|
|
3868
|
-
* @returns
|
|
4035
|
+
* Get options chain for an underlying symbol
|
|
4036
|
+
* Provides the latest trade, latest quote, and greeks for each contract symbol of the underlying symbol
|
|
4037
|
+
* @param params Options chain request parameters
|
|
4038
|
+
* @returns Options chain data with snapshots for each contract
|
|
4039
|
+
* @see https://docs.alpaca.markets/reference/optionchain
|
|
3869
4040
|
*/
|
|
3870
|
-
|
|
3871
|
-
|
|
4041
|
+
async getOptionsChain(params) {
|
|
4042
|
+
const { underlying_symbol, ...queryParams } = params;
|
|
4043
|
+
return this.makeRequest(`/options/snapshots/${encodeURIComponent(underlying_symbol)}`, "GET", queryParams, "v1beta1");
|
|
3872
4044
|
}
|
|
3873
4045
|
/**
|
|
3874
|
-
*
|
|
3875
|
-
* @param
|
|
3876
|
-
* @
|
|
3877
|
-
|
|
4046
|
+
* Get the most recent trades for requested option contract symbols
|
|
4047
|
+
* @param params Latest options trades request parameters
|
|
4048
|
+
* @returns Latest trade data for each option contract symbol
|
|
4049
|
+
|
|
4050
|
+
* @see https://docs.alpaca.markets/reference/optionlatesttrades
|
|
3878
4051
|
*/
|
|
3879
|
-
async
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
4052
|
+
async getLatestOptionsTrades(params) {
|
|
4053
|
+
// Remove limit and page_token as they're not supported by this endpoint
|
|
4054
|
+
const { limit: _limit, page_token: _page_token, ...requestParams } = params;
|
|
4055
|
+
return this.makeRequest("/options/trades/latest", "GET", requestParams, "v1beta1");
|
|
4056
|
+
}
|
|
4057
|
+
/**
|
|
4058
|
+
* Get the most recent quotes for requested option contract symbols
|
|
4059
|
+
* @param params Latest options quotes request parameters
|
|
4060
|
+
* @returns Latest quote data for each option contract symbol
|
|
4061
|
+
|
|
4062
|
+
* @see https://docs.alpaca.markets/reference/optionlatestquotes
|
|
4063
|
+
*/
|
|
4064
|
+
async getLatestOptionsQuotes(params) {
|
|
4065
|
+
// Remove limit and page_token as they're not supported by this endpoint
|
|
4066
|
+
const { limit: _limit, page_token: _page_token, ...requestParams } = params;
|
|
4067
|
+
return this.makeRequest("/options/quotes/latest", "GET", requestParams, "v1beta1");
|
|
4068
|
+
}
|
|
4069
|
+
/**
|
|
4070
|
+
* Get historical OHLCV bars for option contract symbols
|
|
4071
|
+
* Automatically handles pagination to fetch all available data
|
|
4072
|
+
* @param params Historical options bars request parameters
|
|
4073
|
+
* @returns Historical bar data for each option contract symbol with all pages combined
|
|
4074
|
+
|
|
4075
|
+
* @see https://docs.alpaca.markets/reference/optionbars
|
|
4076
|
+
*/
|
|
4077
|
+
async getHistoricalOptionsBars(params) {
|
|
4078
|
+
const symbols = params.symbols;
|
|
4079
|
+
const symbolsStr = symbols.join(",");
|
|
4080
|
+
const allBars = {};
|
|
3889
4081
|
let pageToken = null;
|
|
3890
4082
|
let hasMorePages = true;
|
|
3891
|
-
let
|
|
3892
|
-
|
|
3893
|
-
//
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
}
|
|
3900
|
-
while (hasMorePages) {
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
}),
|
|
3905
|
-
...(mergedParams.end && {
|
|
3906
|
-
end: new Date(mergedParams.end).toISOString(),
|
|
3907
|
-
}),
|
|
3908
|
-
...(symbol && { symbols: symbol }),
|
|
3909
|
-
...(mergedParams.limit && {
|
|
3910
|
-
limit: Math.min(50, maxLimit - fetchedCount).toString(),
|
|
3911
|
-
}),
|
|
3912
|
-
...(mergedParams.sort && { sort: mergedParams.sort }),
|
|
3913
|
-
...(mergedParams.include_content !== undefined
|
|
3914
|
-
? { include_content: mergedParams.include_content.toString() }
|
|
3915
|
-
: {}),
|
|
4083
|
+
let totalBarsCount = 0;
|
|
4084
|
+
let pageCount = 0;
|
|
4085
|
+
// Initialize bar arrays for each symbol
|
|
4086
|
+
symbols.forEach((symbol) => {
|
|
4087
|
+
allBars[symbol] = [];
|
|
4088
|
+
});
|
|
4089
|
+
log$l(`Starting historical options bars fetch for ${symbolsStr} (${params.timeframe}, ${params.start || "no start"} to ${params.end || "no end"})`, {
|
|
4090
|
+
type: "info",
|
|
4091
|
+
});
|
|
4092
|
+
while (hasMorePages) {
|
|
4093
|
+
pageCount++;
|
|
4094
|
+
const requestParams = {
|
|
4095
|
+
...params,
|
|
3916
4096
|
...(pageToken && { page_token: pageToken }),
|
|
3917
|
-
}
|
|
3918
|
-
const
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
method: "GET",
|
|
3922
|
-
headers: this.headers,
|
|
3923
|
-
});
|
|
3924
|
-
if (!response.ok) {
|
|
3925
|
-
const errorText = await response.text();
|
|
3926
|
-
log$l(`Alpaca news API error (${response.status}): ${errorText}`, {
|
|
3927
|
-
type: "error",
|
|
3928
|
-
symbol,
|
|
3929
|
-
});
|
|
3930
|
-
throw new Error(`Alpaca news API error (${response.status}): ${errorText}`);
|
|
3931
|
-
}
|
|
3932
|
-
const data = await response.json();
|
|
3933
|
-
if (!data.news || !Array.isArray(data.news)) {
|
|
3934
|
-
log$l(`No news data found in Alpaca response for ${symbol}`, {
|
|
4097
|
+
};
|
|
4098
|
+
const response = await this.makeRequest("/options/bars", "GET", requestParams, "v1beta1");
|
|
4099
|
+
if (!response.bars) {
|
|
4100
|
+
log$l(`No options bars data found in response for ${symbolsStr}`, {
|
|
3935
4101
|
type: "warn",
|
|
3936
|
-
symbol,
|
|
3937
4102
|
});
|
|
3938
4103
|
break;
|
|
3939
4104
|
}
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
newsArticles = newsArticles.slice(0, maxLimit);
|
|
3959
|
-
break;
|
|
3960
|
-
}
|
|
3961
|
-
}
|
|
3962
|
-
return newsArticles;
|
|
3963
|
-
}
|
|
3964
|
-
}
|
|
3965
|
-
// Export the singleton instance
|
|
3966
|
-
const marketDataAPI = AlpacaMarketDataAPI.getInstance();
|
|
3967
|
-
|
|
3968
|
-
const DEFAULT_RETRY_CONFIG = {
|
|
3969
|
-
maxRetries: 3,
|
|
3970
|
-
baseDelayMs: 1000,
|
|
3971
|
-
maxDelayMs: 30000,
|
|
3972
|
-
retryableStatusCodes: [429, 500, 502, 503, 504],
|
|
3973
|
-
retryOnNetworkError: true,
|
|
3974
|
-
};
|
|
3975
|
-
/**
|
|
3976
|
-
* Node.js / undici / system error codes that represent transient network
|
|
3977
|
-
* conditions. Present on `error.code` for net/http/dns/undici errors.
|
|
3978
|
-
*/
|
|
3979
|
-
const RETRYABLE_ERROR_CODES = new Set([
|
|
3980
|
-
"ETIMEDOUT",
|
|
3981
|
-
"ESOCKETTIMEDOUT",
|
|
3982
|
-
"ECONNRESET",
|
|
3983
|
-
"ECONNREFUSED",
|
|
3984
|
-
"EHOSTUNREACH",
|
|
3985
|
-
"ENETUNREACH",
|
|
3986
|
-
"EAI_AGAIN",
|
|
3987
|
-
"EPIPE",
|
|
3988
|
-
"ECONNABORTED",
|
|
3989
|
-
"ENOTFOUND",
|
|
3990
|
-
"UND_ERR_CONNECT_TIMEOUT",
|
|
3991
|
-
"UND_ERR_HEADERS_TIMEOUT",
|
|
3992
|
-
"UND_ERR_BODY_TIMEOUT",
|
|
3993
|
-
"UND_ERR_SOCKET",
|
|
3994
|
-
"UND_ERR_CLOSED",
|
|
3995
|
-
"UND_ERR_REQ_CONTENT_LENGTH_MISMATCH",
|
|
3996
|
-
]);
|
|
3997
|
-
/**
|
|
3998
|
-
* Error constructor names / `error.name` values that indicate transient
|
|
3999
|
-
* abort / timeout conditions.
|
|
4000
|
-
*/
|
|
4001
|
-
const RETRYABLE_ERROR_NAMES = new Set([
|
|
4002
|
-
"AbortError",
|
|
4003
|
-
"TimeoutError",
|
|
4004
|
-
"FetchError",
|
|
4005
|
-
"RequestTimeoutError",
|
|
4006
|
-
"ConnectTimeoutError",
|
|
4007
|
-
"HeadersTimeoutError",
|
|
4008
|
-
"BodyTimeoutError",
|
|
4009
|
-
]);
|
|
4010
|
-
/**
|
|
4011
|
-
* Message-pattern fallback for libraries that discard error codes/names but
|
|
4012
|
-
* preserve text (e.g., some Apollo/axios wrappers).
|
|
4013
|
-
*/
|
|
4014
|
-
const RETRYABLE_MESSAGE_PATTERNS = [
|
|
4015
|
-
/aborted/i,
|
|
4016
|
-
/timeout/i,
|
|
4017
|
-
/timed out/i,
|
|
4018
|
-
/network error/i,
|
|
4019
|
-
/socket hang up/i,
|
|
4020
|
-
/connection (reset|refused|closed)/i,
|
|
4021
|
-
/ECONNRESET/,
|
|
4022
|
-
/ETIMEDOUT/,
|
|
4023
|
-
/ECONNREFUSED/,
|
|
4024
|
-
/EAI_AGAIN/,
|
|
4025
|
-
/UND_ERR_/,
|
|
4026
|
-
];
|
|
4027
|
-
/**
|
|
4028
|
-
* Walks the `error.cause` chain (capped to avoid cycles) and tests whether
|
|
4029
|
-
* any link along the chain looks like a transient network error. Modern APIs
|
|
4030
|
-
* (undici, fetch, Apollo Client 3.8+) wrap the root network failure as a
|
|
4031
|
-
* `.cause`, so the surface `Error` may report a generic message while the
|
|
4032
|
-
* actionable signal lives one or more levels deeper.
|
|
4033
|
-
*
|
|
4034
|
-
* Exported for use by downstream consumers (engine services, per-call catch
|
|
4035
|
-
* blocks, application-level loggers) that need to demote recoverable
|
|
4036
|
-
* transient errors from ERROR to WARN. Aligns the whole stack on a single
|
|
4037
|
-
* canonical classifier so MassiveAPI, AlpacaAPI, and application-layer
|
|
4038
|
-
* retry handlers all treat the same network blips identically.
|
|
4039
|
-
*/
|
|
4040
|
-
function isTransientNetworkError(error) {
|
|
4041
|
-
const MAX_CAUSE_DEPTH = 6;
|
|
4042
|
-
let current = error;
|
|
4043
|
-
for (let depth = 0; depth < MAX_CAUSE_DEPTH && current; depth++) {
|
|
4044
|
-
if (current instanceof Error || typeof current === "object") {
|
|
4045
|
-
const err = current;
|
|
4046
|
-
if (typeof err.name === "string" && RETRYABLE_ERROR_NAMES.has(err.name)) {
|
|
4047
|
-
return true;
|
|
4048
|
-
}
|
|
4049
|
-
if (typeof err.code === "string" && RETRYABLE_ERROR_CODES.has(err.code)) {
|
|
4050
|
-
return true;
|
|
4051
|
-
}
|
|
4052
|
-
if (typeof err.message === "string") {
|
|
4053
|
-
for (const pattern of RETRYABLE_MESSAGE_PATTERNS) {
|
|
4054
|
-
if (pattern.test(err.message)) {
|
|
4055
|
-
return true;
|
|
4056
|
-
}
|
|
4105
|
+
// Combine bars for each symbol
|
|
4106
|
+
let pageBarsCount = 0;
|
|
4107
|
+
let earliestTimestamp = null;
|
|
4108
|
+
let latestTimestamp = null;
|
|
4109
|
+
Object.entries(response.bars).forEach(([symbol, bars]) => {
|
|
4110
|
+
if (bars && bars.length > 0) {
|
|
4111
|
+
allBars[symbol] = [...allBars[symbol], ...bars];
|
|
4112
|
+
pageBarsCount += bars.length;
|
|
4113
|
+
// Track date range for this page
|
|
4114
|
+
bars.forEach((bar) => {
|
|
4115
|
+
const barDate = new Date(bar.t);
|
|
4116
|
+
if (!earliestTimestamp || barDate < earliestTimestamp) {
|
|
4117
|
+
earliestTimestamp = barDate;
|
|
4118
|
+
}
|
|
4119
|
+
if (!latestTimestamp || barDate > latestTimestamp) {
|
|
4120
|
+
latestTimestamp = barDate;
|
|
4121
|
+
}
|
|
4122
|
+
});
|
|
4057
4123
|
}
|
|
4124
|
+
});
|
|
4125
|
+
totalBarsCount += pageBarsCount;
|
|
4126
|
+
pageToken = response.next_page_token || null;
|
|
4127
|
+
hasMorePages = !!pageToken;
|
|
4128
|
+
// Enhanced logging with date range and progress info
|
|
4129
|
+
const dateRangeStr = earliestTimestamp && latestTimestamp
|
|
4130
|
+
? `${earliestTimestamp.toLocaleDateString("en-US", { timeZone: "America/New_York" })} to ${latestTimestamp.toLocaleDateString("en-US", { timeZone: "America/New_York" })}`
|
|
4131
|
+
: "unknown range";
|
|
4132
|
+
log$l(`Page ${pageCount}: Fetched ${pageBarsCount.toLocaleString()} option bars (total: ${totalBarsCount.toLocaleString()}) for ${symbolsStr}, date range: ${dateRangeStr}${hasMorePages ? ", more pages available" : ", complete"}`, {
|
|
4133
|
+
type: "info",
|
|
4134
|
+
});
|
|
4135
|
+
// Prevent infinite loops
|
|
4136
|
+
if (pageCount > 1000) {
|
|
4137
|
+
log$l(`Stopping options bars pagination after ${pageCount} pages to prevent infinite loop`, { type: "warn" });
|
|
4138
|
+
break;
|
|
4058
4139
|
}
|
|
4059
|
-
current = err.cause;
|
|
4060
|
-
}
|
|
4061
|
-
else {
|
|
4062
|
-
break;
|
|
4063
4140
|
}
|
|
4141
|
+
// Final summary
|
|
4142
|
+
const symbolCounts = Object.entries(allBars)
|
|
4143
|
+
.map(([symbol, bars]) => `${symbol}: ${bars.length}`)
|
|
4144
|
+
.join(", ");
|
|
4145
|
+
log$l(`Historical options bars fetch complete: ${totalBarsCount.toLocaleString()} total bars across ${pageCount} pages (${symbolCounts})`, {
|
|
4146
|
+
type: "info",
|
|
4147
|
+
});
|
|
4148
|
+
return {
|
|
4149
|
+
bars: allBars,
|
|
4150
|
+
next_page_token: undefined, // Always undefined since we fetch all pages
|
|
4151
|
+
};
|
|
4064
4152
|
}
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
if (status === 401 || status === 403) {
|
|
4094
|
-
return {
|
|
4095
|
-
type: "AUTH_ERROR",
|
|
4096
|
-
reason: status === 401
|
|
4097
|
-
? "Authentication failed - invalid credentials"
|
|
4098
|
-
: "Access forbidden - insufficient permissions",
|
|
4099
|
-
status,
|
|
4100
|
-
isRetryable: false,
|
|
4101
|
-
};
|
|
4102
|
-
}
|
|
4103
|
-
// Server errors - check if in retryable list
|
|
4104
|
-
if (status >= 500 && status < 600) {
|
|
4105
|
-
return {
|
|
4106
|
-
type: "SERVER_ERROR",
|
|
4107
|
-
reason: `Server error (${status})`,
|
|
4108
|
-
status,
|
|
4109
|
-
isRetryable: config.retryableStatusCodes.includes(status),
|
|
4110
|
-
};
|
|
4111
|
-
}
|
|
4112
|
-
// Other client errors - never retry
|
|
4113
|
-
if (status >= 400 && status < 500) {
|
|
4114
|
-
return {
|
|
4115
|
-
type: "CLIENT_ERROR",
|
|
4116
|
-
reason: `Client error (${status})`,
|
|
4117
|
-
status,
|
|
4118
|
-
isRetryable: false,
|
|
4153
|
+
/**
|
|
4154
|
+
* Get historical trades for option contract symbols
|
|
4155
|
+
* Automatically handles pagination to fetch all available data
|
|
4156
|
+
* @param params Historical options trades request parameters
|
|
4157
|
+
* @returns Historical trade data for each option contract symbol with all pages combined
|
|
4158
|
+
|
|
4159
|
+
* @see https://docs.alpaca.markets/reference/optiontrades
|
|
4160
|
+
*/
|
|
4161
|
+
async getHistoricalOptionsTrades(params) {
|
|
4162
|
+
const symbols = params.symbols;
|
|
4163
|
+
const symbolsStr = symbols.join(",");
|
|
4164
|
+
const allTrades = {};
|
|
4165
|
+
let pageToken = null;
|
|
4166
|
+
let hasMorePages = true;
|
|
4167
|
+
let totalTradesCount = 0;
|
|
4168
|
+
let pageCount = 0;
|
|
4169
|
+
// Initialize trades arrays for each symbol
|
|
4170
|
+
symbols.forEach((symbol) => {
|
|
4171
|
+
allTrades[symbol] = [];
|
|
4172
|
+
});
|
|
4173
|
+
log$l(`Starting historical options trades fetch for ${symbolsStr} (${params.start || "no start"} to ${params.end || "no end"})`, {
|
|
4174
|
+
type: "info",
|
|
4175
|
+
});
|
|
4176
|
+
while (hasMorePages) {
|
|
4177
|
+
pageCount++;
|
|
4178
|
+
const requestParams = {
|
|
4179
|
+
...params,
|
|
4180
|
+
...(pageToken && { page_token: pageToken }),
|
|
4119
4181
|
};
|
|
4182
|
+
const response = await this.makeRequest("/options/trades", "GET", requestParams, "v1beta1");
|
|
4183
|
+
if (!response.trades) {
|
|
4184
|
+
log$l(`No options trades data found in response for ${symbolsStr}`, {
|
|
4185
|
+
type: "warn",
|
|
4186
|
+
});
|
|
4187
|
+
break;
|
|
4188
|
+
}
|
|
4189
|
+
// Combine trades for each symbol
|
|
4190
|
+
let pageTradesCount = 0;
|
|
4191
|
+
let earliestTimestamp = null;
|
|
4192
|
+
let latestTimestamp = null;
|
|
4193
|
+
Object.entries(response.trades).forEach(([symbol, trades]) => {
|
|
4194
|
+
if (trades && trades.length > 0) {
|
|
4195
|
+
allTrades[symbol] = [...allTrades[symbol], ...trades];
|
|
4196
|
+
pageTradesCount += trades.length;
|
|
4197
|
+
// Track date range for this page
|
|
4198
|
+
trades.forEach((trade) => {
|
|
4199
|
+
const tradeDate = new Date(trade.t);
|
|
4200
|
+
if (!earliestTimestamp || tradeDate < earliestTimestamp) {
|
|
4201
|
+
earliestTimestamp = tradeDate;
|
|
4202
|
+
}
|
|
4203
|
+
if (!latestTimestamp || tradeDate > latestTimestamp) {
|
|
4204
|
+
latestTimestamp = tradeDate;
|
|
4205
|
+
}
|
|
4206
|
+
});
|
|
4207
|
+
}
|
|
4208
|
+
});
|
|
4209
|
+
totalTradesCount += pageTradesCount;
|
|
4210
|
+
pageToken = response.next_page_token || null;
|
|
4211
|
+
hasMorePages = !!pageToken;
|
|
4212
|
+
// Enhanced logging with date range and progress info
|
|
4213
|
+
const dateRangeStr = earliestTimestamp && latestTimestamp
|
|
4214
|
+
? `${earliestTimestamp.toLocaleDateString("en-US", { timeZone: "America/New_York" })} to ${latestTimestamp.toLocaleDateString("en-US", { timeZone: "America/New_York" })}`
|
|
4215
|
+
: "unknown range";
|
|
4216
|
+
log$l(`Page ${pageCount}: Fetched ${pageTradesCount.toLocaleString()} option trades (total: ${totalTradesCount.toLocaleString()}) for ${symbolsStr}, date range: ${dateRangeStr}${hasMorePages ? ", more pages available" : ", complete"}`, {
|
|
4217
|
+
type: "info",
|
|
4218
|
+
});
|
|
4219
|
+
// Prevent infinite loops
|
|
4220
|
+
if (pageCount > 1000) {
|
|
4221
|
+
log$l(`Stopping options trades pagination after ${pageCount} pages to prevent infinite loop`, { type: "warn" });
|
|
4222
|
+
break;
|
|
4223
|
+
}
|
|
4120
4224
|
}
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4225
|
+
// Final summary
|
|
4226
|
+
const symbolCounts = Object.entries(allTrades)
|
|
4227
|
+
.map(([symbol, trades]) => `${symbol}: ${trades.length}`)
|
|
4228
|
+
.join(", ");
|
|
4229
|
+
log$l(`Historical options trades fetch complete: ${totalTradesCount.toLocaleString()} total trades across ${pageCount} pages (${symbolCounts})`, {
|
|
4230
|
+
type: "info",
|
|
4231
|
+
});
|
|
4124
4232
|
return {
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
status: null,
|
|
4128
|
-
isRetryable: config.retryOnNetworkError,
|
|
4233
|
+
trades: allTrades,
|
|
4234
|
+
next_page_token: undefined, // Always undefined since we fetch all pages
|
|
4129
4235
|
};
|
|
4130
4236
|
}
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4237
|
+
/**
|
|
4238
|
+
* Get snapshots for option contract symbols
|
|
4239
|
+
* Provides latest trade, latest quote, and greeks for each contract symbol
|
|
4240
|
+
* @param params Options snapshots request parameters
|
|
4241
|
+
* @returns Snapshot data for each option contract symbol
|
|
4242
|
+
|
|
4243
|
+
* @see https://docs.alpaca.markets/reference/optionsnapshots
|
|
4244
|
+
*/
|
|
4245
|
+
async getOptionsSnapshot(params) {
|
|
4246
|
+
// Remove limit and page_token as they may not be supported by this endpoint
|
|
4247
|
+
const { limit: _limit, page_token: _page_token, ...requestParams } = params;
|
|
4248
|
+
return this.makeRequest("/options/snapshots", "GET", requestParams, "v1beta1");
|
|
4143
4249
|
}
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
retryAfter,
|
|
4155
|
-
isRetryable: true,
|
|
4156
|
-
};
|
|
4157
|
-
}
|
|
4158
|
-
if (error.message.includes("401") ||
|
|
4159
|
-
error.message.includes("403") ||
|
|
4160
|
-
error.message.includes("AUTH_ERROR")) {
|
|
4161
|
-
const status = error.message.includes("401") ? 401 : 403;
|
|
4162
|
-
return {
|
|
4163
|
-
type: "AUTH_ERROR",
|
|
4164
|
-
reason: `Authentication error (${status})`,
|
|
4165
|
-
status,
|
|
4166
|
-
isRetryable: false,
|
|
4167
|
-
};
|
|
4168
|
-
}
|
|
4169
|
-
if (error.message.includes("SERVER_ERROR") ||
|
|
4170
|
-
error.message.match(/50[0-9]/)) {
|
|
4171
|
-
const statusMatch = error.message.match(/50[0-9]/);
|
|
4172
|
-
const status = statusMatch ? parseInt(statusMatch[0], 10) : 500;
|
|
4173
|
-
return {
|
|
4174
|
-
type: "SERVER_ERROR",
|
|
4175
|
-
reason: `Server error (${status})`,
|
|
4176
|
-
status,
|
|
4177
|
-
isRetryable: config.retryableStatusCodes.includes(status),
|
|
4178
|
-
};
|
|
4179
|
-
}
|
|
4180
|
-
if (error.message.includes("network") ||
|
|
4181
|
-
error.message.includes("NETWORK_ERROR")) {
|
|
4182
|
-
return {
|
|
4183
|
-
type: "NETWORK_ERROR",
|
|
4184
|
-
reason: error.message,
|
|
4185
|
-
status: null,
|
|
4186
|
-
isRetryable: config.retryOnNetworkError,
|
|
4187
|
-
};
|
|
4188
|
-
}
|
|
4250
|
+
/**
|
|
4251
|
+
* Get condition codes for options trades or quotes
|
|
4252
|
+
* Returns the mapping between condition codes and their descriptions
|
|
4253
|
+
* @param tickType The type of tick data ('trade' or 'quote')
|
|
4254
|
+
* @returns Mapping of condition codes to descriptions
|
|
4255
|
+
|
|
4256
|
+
* @see https://docs.alpaca.markets/reference/optionmetaconditions
|
|
4257
|
+
*/
|
|
4258
|
+
async getOptionsConditionCodes(tickType) {
|
|
4259
|
+
return this.makeRequest(`/options/meta/conditions/${tickType}`, "GET", undefined, "v1beta1");
|
|
4189
4260
|
}
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
* async () => fetch('https://api.example.com/data'),
|
|
4233
|
-
* {},
|
|
4234
|
-
* 'ExampleAPI.fetchData'
|
|
4235
|
-
* );
|
|
4236
|
-
*
|
|
4237
|
-
* // Custom configuration for rate-limited API
|
|
4238
|
-
* const result = await withRetry(
|
|
4239
|
-
* async () => alphaVantageAPI.getQuote(symbol),
|
|
4240
|
-
* {
|
|
4241
|
-
* maxRetries: 5,
|
|
4242
|
-
* baseDelayMs: 5000,
|
|
4243
|
-
* maxDelayMs: 60000,
|
|
4244
|
-
* onRetry: (attempt, error) => {
|
|
4245
|
-
* getLogger().info(`Retry ${attempt} after error:`, error);
|
|
4246
|
-
* }
|
|
4247
|
-
* },
|
|
4248
|
-
* 'AlphaVantage.getQuote'
|
|
4249
|
-
* );
|
|
4250
|
-
* ```
|
|
4251
|
-
*/
|
|
4252
|
-
async function withRetry(fn, config = {}, label = "unknown") {
|
|
4253
|
-
const fullConfig = { ...DEFAULT_RETRY_CONFIG, ...config };
|
|
4254
|
-
let lastError;
|
|
4255
|
-
for (let attempt = 1; attempt <= fullConfig.maxRetries; attempt++) {
|
|
4256
|
-
try {
|
|
4257
|
-
const result = await fn();
|
|
4258
|
-
// If we succeeded after retries, log it
|
|
4259
|
-
if (attempt > 1) {
|
|
4260
|
-
getLogger().info(`[${label}] Succeeded on attempt ${attempt}/${fullConfig.maxRetries}`);
|
|
4261
|
-
}
|
|
4262
|
-
return result;
|
|
4261
|
+
/**
|
|
4262
|
+
* Get exchange codes for options
|
|
4263
|
+
* Returns the mapping between option exchange codes and exchange names
|
|
4264
|
+
* @returns Mapping of exchange codes to exchange names
|
|
4265
|
+
|
|
4266
|
+
* @see https://docs.alpaca.markets/reference/optionmetaexchanges
|
|
4267
|
+
*/
|
|
4268
|
+
async getOptionsExchangeCodes() {
|
|
4269
|
+
return this.makeRequest("/options/meta/exchanges", "GET", undefined, "v1beta1");
|
|
4270
|
+
}
|
|
4271
|
+
/**
|
|
4272
|
+
* Analyzes an array of option bars and returns a summary string
|
|
4273
|
+
* @param bars Array of option bars to analyze
|
|
4274
|
+
* @returns A string summarizing the option price data
|
|
4275
|
+
*/
|
|
4276
|
+
static analyzeOptionBars(bars) {
|
|
4277
|
+
if (!bars || bars.length === 0) {
|
|
4278
|
+
return "No option price data available";
|
|
4279
|
+
}
|
|
4280
|
+
const firstBar = bars[0];
|
|
4281
|
+
const lastBar = bars[bars.length - 1];
|
|
4282
|
+
const priceChange = lastBar.c - firstBar.o;
|
|
4283
|
+
const percentChange = (priceChange / firstBar.o) * 100;
|
|
4284
|
+
const volumeChange = lastBar.v - firstBar.v;
|
|
4285
|
+
const percentVolumeChange = firstBar.v > 0 ? (volumeChange / firstBar.v) * 100 : 0;
|
|
4286
|
+
const high = Math.max(...bars.map((bar) => bar.h));
|
|
4287
|
+
const low = Math.min(...bars.map((bar) => bar.l));
|
|
4288
|
+
const totalVolume = bars.reduce((sum, bar) => sum + bar.v, 0);
|
|
4289
|
+
const avgVolume = totalVolume / bars.length;
|
|
4290
|
+
return (`Option Price: $${firstBar.o.toFixed(2)} -> $${lastBar.c.toFixed(2)} (${percentChange.toFixed(2)}%), ` +
|
|
4291
|
+
`Volume: ${firstBar.v.toLocaleString()} -> ${lastBar.v.toLocaleString()} (${percentVolumeChange.toFixed(2)}%), ` +
|
|
4292
|
+
`High: $${high.toFixed(2)}, Low: $${low.toFixed(2)}, ` +
|
|
4293
|
+
`Avg Volume: ${avgVolume.toLocaleString()}`);
|
|
4294
|
+
}
|
|
4295
|
+
/**
|
|
4296
|
+
* Formats option greeks for display
|
|
4297
|
+
* @param greeks Option greeks object
|
|
4298
|
+
* @returns Formatted string with greek values
|
|
4299
|
+
*/
|
|
4300
|
+
static formatOptionGreeks(greeks) {
|
|
4301
|
+
if (!greeks) {
|
|
4302
|
+
return "No greeks data available";
|
|
4263
4303
|
}
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
+
const parts = [];
|
|
4305
|
+
if (greeks.delta !== undefined)
|
|
4306
|
+
parts.push(`Delta: ${greeks.delta.toFixed(4)}`);
|
|
4307
|
+
if (greeks.gamma !== undefined)
|
|
4308
|
+
parts.push(`Gamma: ${greeks.gamma.toFixed(4)}`);
|
|
4309
|
+
if (greeks.theta !== undefined)
|
|
4310
|
+
parts.push(`Theta: ${greeks.theta.toFixed(4)}`);
|
|
4311
|
+
if (greeks.vega !== undefined)
|
|
4312
|
+
parts.push(`Vega: ${greeks.vega.toFixed(4)}`);
|
|
4313
|
+
if (greeks.rho !== undefined)
|
|
4314
|
+
parts.push(`Rho: ${greeks.rho.toFixed(4)}`);
|
|
4315
|
+
return parts.length > 0 ? parts.join(", ") : "No greeks data available";
|
|
4316
|
+
}
|
|
4317
|
+
/**
|
|
4318
|
+
* Interprets condition codes using the provided condition codes mapping
|
|
4319
|
+
* @param conditionCodes Array of condition codes from trade or quote
|
|
4320
|
+
* @param conditionCodesMap Mapping of condition codes to descriptions
|
|
4321
|
+
* @returns Formatted string with condition descriptions
|
|
4322
|
+
*/
|
|
4323
|
+
static interpretConditionCodes(conditionCodes, conditionCodesMap) {
|
|
4324
|
+
if (!conditionCodes || conditionCodes.length === 0) {
|
|
4325
|
+
return "No conditions";
|
|
4326
|
+
}
|
|
4327
|
+
const descriptions = conditionCodes
|
|
4328
|
+
.map((code) => conditionCodesMap[code] || `Unknown (${code})`)
|
|
4329
|
+
.filter((desc) => desc !== undefined);
|
|
4330
|
+
return descriptions.length > 0
|
|
4331
|
+
? descriptions.join(", ")
|
|
4332
|
+
: "No condition descriptions available";
|
|
4333
|
+
}
|
|
4334
|
+
/**
|
|
4335
|
+
* Gets the exchange name from exchange code using the provided exchange codes mapping
|
|
4336
|
+
* @param exchangeCode Exchange code from trade or quote
|
|
4337
|
+
* @param exchangeCodesMap Mapping of exchange codes to names
|
|
4338
|
+
* @returns Exchange name or formatted unknown exchange
|
|
4339
|
+
*/
|
|
4340
|
+
static getExchangeName(exchangeCode, exchangeCodesMap) {
|
|
4341
|
+
return (exchangeCodesMap[exchangeCode] || `Unknown Exchange (${exchangeCode})`);
|
|
4342
|
+
}
|
|
4343
|
+
/**
|
|
4344
|
+
* Fetches news articles from Alpaca API for a symbol, paginating through all results.
|
|
4345
|
+
* @param symbol The symbol to fetch news for (e.g., 'AAPL')
|
|
4346
|
+
* @param params Optional parameters: start, end, limit, sort, include_content
|
|
4347
|
+
* @returns Array of SimpleNews articles
|
|
4348
|
+
*/
|
|
4349
|
+
async fetchNews(symbol, params) {
|
|
4350
|
+
const defaultParams = {
|
|
4351
|
+
start: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
|
4352
|
+
end: new Date(),
|
|
4353
|
+
limit: 10,
|
|
4354
|
+
sort: "desc",
|
|
4355
|
+
include_content: true,
|
|
4356
|
+
};
|
|
4357
|
+
const mergedParams = { ...defaultParams, ...params };
|
|
4358
|
+
let newsArticles = [];
|
|
4359
|
+
let pageToken = null;
|
|
4360
|
+
let hasMorePages = true;
|
|
4361
|
+
let fetchedCount = 0;
|
|
4362
|
+
const maxLimit = mergedParams.limit;
|
|
4363
|
+
// Utility to clean content
|
|
4364
|
+
function cleanContent(content) {
|
|
4365
|
+
if (!content)
|
|
4366
|
+
return undefined;
|
|
4367
|
+
// Remove excessive whitespace, newlines, and trim
|
|
4368
|
+
return content.replace(/\s+/g, " ").trim();
|
|
4369
|
+
}
|
|
4370
|
+
while (hasMorePages) {
|
|
4371
|
+
const queryParams = new URLSearchParams({
|
|
4372
|
+
...(mergedParams.start && {
|
|
4373
|
+
start: new Date(mergedParams.start).toISOString(),
|
|
4374
|
+
}),
|
|
4375
|
+
...(mergedParams.end && {
|
|
4376
|
+
end: new Date(mergedParams.end).toISOString(),
|
|
4377
|
+
}),
|
|
4378
|
+
...(symbol && { symbols: symbol }),
|
|
4379
|
+
...(mergedParams.limit && {
|
|
4380
|
+
limit: Math.min(50, maxLimit - fetchedCount).toString(),
|
|
4381
|
+
}),
|
|
4382
|
+
...(mergedParams.sort && { sort: mergedParams.sort }),
|
|
4383
|
+
...(mergedParams.include_content !== undefined
|
|
4384
|
+
? { include_content: mergedParams.include_content.toString() }
|
|
4385
|
+
: {}),
|
|
4386
|
+
...(pageToken && { page_token: pageToken }),
|
|
4387
|
+
});
|
|
4388
|
+
const url = `${this.v1beta1url}/news?${queryParams}`;
|
|
4389
|
+
log$l(`Fetching news from: ${url}`, { type: "debug", symbol });
|
|
4390
|
+
const response = await fetch(url, {
|
|
4391
|
+
method: "GET",
|
|
4392
|
+
headers: this.headers,
|
|
4393
|
+
});
|
|
4394
|
+
if (!response.ok) {
|
|
4395
|
+
const errorText = await response.text();
|
|
4396
|
+
log$l(`Alpaca news API error (${response.status}): ${errorText}`, {
|
|
4397
|
+
type: "error",
|
|
4398
|
+
symbol,
|
|
4304
4399
|
});
|
|
4305
|
-
throw error;
|
|
4306
|
-
}
|
|
4307
|
-
// Calculate delay for next retry
|
|
4308
|
-
let delayMs;
|
|
4309
|
-
if (errorDetails.type === "RATE_LIMIT" && errorDetails.retryAfter) {
|
|
4310
|
-
// Use Retry-After header if available
|
|
4311
|
-
delayMs = errorDetails.retryAfter;
|
|
4312
|
-
}
|
|
4313
|
-
else if (errorDetails.type === "RATE_LIMIT") {
|
|
4314
|
-
// For rate limits without Retry-After, use a longer minimum delay
|
|
4315
|
-
delayMs = Math.max(calculateBackoff(attempt, fullConfig.baseDelayMs, fullConfig.maxDelayMs), 5000);
|
|
4400
|
+
throw new Error(`Alpaca news API error (${response.status}): ${errorText}`);
|
|
4316
4401
|
}
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4402
|
+
const data = await response.json();
|
|
4403
|
+
if (!data.news || !Array.isArray(data.news)) {
|
|
4404
|
+
log$l(`No news data found in Alpaca response for ${symbol}`, {
|
|
4405
|
+
type: "warn",
|
|
4406
|
+
symbol,
|
|
4407
|
+
});
|
|
4408
|
+
break;
|
|
4320
4409
|
}
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4410
|
+
const transformedNews = data.news.map((article) => ({
|
|
4411
|
+
symbols: article.symbols,
|
|
4412
|
+
title: article.headline,
|
|
4413
|
+
summary: cleanContent(article.summary) ?? "",
|
|
4414
|
+
content: article.content ? cleanContent(article.content) : undefined,
|
|
4415
|
+
url: article.url,
|
|
4416
|
+
source: article.source,
|
|
4417
|
+
author: article.author,
|
|
4418
|
+
date: article.updated_at || article.created_at,
|
|
4419
|
+
updatedDate: article.updated_at || article.created_at,
|
|
4420
|
+
sentiment: 0,
|
|
4421
|
+
}));
|
|
4422
|
+
newsArticles = newsArticles.concat(transformedNews);
|
|
4423
|
+
fetchedCount = newsArticles.length;
|
|
4424
|
+
pageToken = data.next_page_token || null;
|
|
4425
|
+
hasMorePages = !!pageToken && (!maxLimit || fetchedCount < maxLimit);
|
|
4426
|
+
log$l(`Fetched ${transformedNews.length} news articles (total: ${fetchedCount}) for ${symbol}. More pages: ${hasMorePages}`, { type: "debug", symbol });
|
|
4427
|
+
if (maxLimit && fetchedCount >= maxLimit) {
|
|
4428
|
+
newsArticles = newsArticles.slice(0, maxLimit);
|
|
4429
|
+
break;
|
|
4333
4430
|
}
|
|
4334
|
-
// Wait before retrying
|
|
4335
|
-
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
4336
4431
|
}
|
|
4432
|
+
return newsArticles;
|
|
4337
4433
|
}
|
|
4338
|
-
// This should never be reached due to the throw in the last attempt,
|
|
4339
|
-
// but TypeScript needs this to satisfy the return type
|
|
4340
|
-
throw lastError;
|
|
4341
4434
|
}
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
* These configurations are tuned based on each API's rate limits and characteristics.
|
|
4345
|
-
*/
|
|
4346
|
-
const API_RETRY_CONFIGS = {
|
|
4347
|
-
/** Massive.com API - 5 requests/second rate limit */
|
|
4348
|
-
MASSIVE: {
|
|
4349
|
-
maxRetries: 3,
|
|
4350
|
-
baseDelayMs: 1000,
|
|
4351
|
-
maxDelayMs: 30000,
|
|
4352
|
-
retryableStatusCodes: [429, 500, 502, 503, 504],
|
|
4353
|
-
retryOnNetworkError: true,
|
|
4354
|
-
},
|
|
4355
|
-
/** Alpha Vantage API - 5 requests/minute rate limit (more strict) */
|
|
4356
|
-
ALPHA_VANTAGE: {
|
|
4357
|
-
maxRetries: 5,
|
|
4358
|
-
baseDelayMs: 5000,
|
|
4359
|
-
maxDelayMs: 60000,
|
|
4360
|
-
retryableStatusCodes: [429, 500, 502, 503, 504],
|
|
4361
|
-
retryOnNetworkError: true,
|
|
4362
|
-
},
|
|
4363
|
-
/** Alpaca API - generally reliable, shorter retry window */
|
|
4364
|
-
ALPACA: {
|
|
4365
|
-
maxRetries: 3,
|
|
4366
|
-
baseDelayMs: 1000,
|
|
4367
|
-
maxDelayMs: 30000,
|
|
4368
|
-
retryableStatusCodes: [429, 500, 502, 503, 504],
|
|
4369
|
-
retryOnNetworkError: true,
|
|
4370
|
-
},
|
|
4371
|
-
/** Generic crypto API configuration */
|
|
4372
|
-
CRYPTO: {
|
|
4373
|
-
maxRetries: 3,
|
|
4374
|
-
baseDelayMs: 1000,
|
|
4375
|
-
maxDelayMs: 30000,
|
|
4376
|
-
retryableStatusCodes: [429, 500, 502, 503, 504],
|
|
4377
|
-
retryOnNetworkError: true,
|
|
4378
|
-
},
|
|
4379
|
-
};
|
|
4435
|
+
// Export the singleton instance
|
|
4436
|
+
const marketDataAPI = AlpacaMarketDataAPI.getInstance();
|
|
4380
4437
|
|
|
4381
4438
|
const limitPriceSlippagePercent100 = 0.1; // 0.1%
|
|
4382
4439
|
/**
|