@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.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
- * Structured error type hierarchy for all API integrations
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
- * Base error class for all @adaptic/utils errors
2186
- * Extends Error with additional context about service, error code, and retry capability
2208
+ * Error constructor names / `error.name` values that indicate transient
2209
+ * abort / timeout conditions.
2187
2210
  */
2188
- class AdapticUtilsError extends Error {
2189
- code;
2190
- service;
2191
- isRetryable;
2192
- cause;
2193
- name;
2194
- constructor(message, code, service, isRetryable = false, cause) {
2195
- super(message);
2196
- this.code = code;
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
- * Alpaca API specific errors
2209
- * Handles all errors from Alpaca trading and market data APIs
2221
+ * Message-pattern fallback for libraries that discard error codes/names but
2222
+ * preserve text (e.g., some Apollo/axios wrappers).
2210
2223
  */
2211
- class AlpacaApiError extends AdapticUtilsError {
2212
- statusCode;
2213
- constructor(message, code, statusCode, cause) {
2214
- // Rate limit (429) and server errors (5xx) are retryable
2215
- const isRetryable = statusCode === 429 || (statusCode !== undefined && statusCode >= 500);
2216
- super(message, code, "alpaca", isRetryable, cause);
2217
- this.statusCode = statusCode;
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
- * Massive.com API specific errors
2222
- * Handles all errors from Massive market data API
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
- class MassiveApiError extends AdapticUtilsError {
2225
- statusCode;
2226
- constructor(message, code, statusCode, cause) {
2227
- // Rate limit (429) and server errors (5xx) are retryable
2228
- const isRetryable = statusCode === 429 || (statusCode !== undefined && statusCode >= 500);
2229
- super(message, code, "massive", isRetryable, cause);
2230
- this.statusCode = statusCode;
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
- * AlphaVantage API specific errors
2235
- * Handles all errors from AlphaVantage financial data API
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
- class AlphaVantageError extends AdapticUtilsError {
2238
- statusCode;
2239
- constructor(message, code, statusCode, cause) {
2240
- // Rate limit (429) and server errors (5xx) are retryable
2241
- const isRetryable = statusCode === 429 || (statusCode !== undefined && statusCode >= 500);
2242
- super(message, code, "alphavantage", isRetryable, cause);
2243
- this.statusCode = statusCode;
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
- * Network timeout errors
2248
- * Used when API requests exceed configured timeout limits
2249
- * Always retryable as timeouts are often transient
2250
- */
2251
- class TimeoutError extends AdapticUtilsError {
2252
- service;
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
- * Input validation errors
2263
- * Used when function inputs fail validation checks
2264
- * Never retryable as the inputs need to be corrected
2265
- */
2266
- class ValidationError extends AdapticUtilsError {
2267
- service;
2268
- invalidField;
2269
- constructor(message, service, invalidField, cause) {
2270
- super(message, "VALIDATION_ERROR", service, false, // Validation errors are never retryable
2271
- cause);
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
- * Authentication and authorization errors
2278
- * Used when API credentials are invalid, expired, or lack permissions
2279
- * Never retryable as credentials need to be updated
2280
- */
2281
- class AuthenticationError extends AdapticUtilsError {
2282
- service;
2283
- statusCode;
2284
- constructor(message, service, statusCode, cause) {
2285
- super(message, "AUTH_ERROR", service, false, // Auth errors are never retryable
2286
- cause);
2287
- this.service = service;
2288
- this.statusCode = statusCode;
2289
- }
2290
- }
2291
- /**
2292
- * HTTP client errors (4xx)
2293
- * Used for client-side errors that are not authentication or validation related
2294
- * Generally not retryable unless specific status codes indicate otherwise
2295
- */
2296
- class HttpClientError extends AdapticUtilsError {
2297
- service;
2298
- statusCode;
2299
- constructor(message, service, statusCode, cause) {
2300
- super(message, "CLIENT_ERROR", service, false, // Client errors are generally not retryable
2301
- cause);
2302
- this.service = service;
2303
- this.statusCode = statusCode;
2304
- }
2305
- }
2306
- /**
2307
- * HTTP server errors (5xx)
2308
- * Used for server-side errors from external APIs
2309
- * Always retryable as server issues are often transient
2310
- */
2311
- class HttpServerError extends AdapticUtilsError {
2312
- service;
2313
- statusCode;
2314
- constructor(message, service, statusCode, cause) {
2315
- super(message, "SERVER_ERROR", service, true, // Server errors are always retryable
2316
- cause);
2317
- this.service = service;
2318
- this.statusCode = statusCode;
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
- * Data parsing and format errors
2380
- * Used when API responses cannot be parsed or are in unexpected format
2381
- * Not retryable as the data format issue needs investigation
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
- class DataFormatError extends AdapticUtilsError {
2384
- service;
2385
- constructor(message, service, cause) {
2386
- super(message, "DATA_FORMAT_ERROR", service, false, // Data format errors are not retryable
2387
- cause);
2388
- this.service = service;
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
- * Token bucket rate limiter for external API integrations
2425
+ * Wraps an async function with retry logic and exponential backoff.
2394
2426
  *
2395
- * Implements client-side rate limiting to prevent exceeding API quotas
2396
- * and ensure fair usage of external services like Alpaca, Massive, and AlphaVantage.
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
- * import { rateLimiters } from '@adaptic/utils';
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
- * // Before making an API call
2403
- * await rateLimiters.alpaca.acquire();
2404
- * const result = await makeAlpacaApiCall();
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
- /** Number of milliseconds in one second, used for token-refill timing math. */
2408
- const MS_PER_SECOND$1 = 1000;
2409
- /**
2410
- * Minimum delay (ms) for a scheduled queue wake-up. Guards against a `0`/`NaN`
2411
- * delay when the token deficit rounds down, ensuring the timer always makes
2412
- * forward progress rather than busy-looping on the event loop.
2413
- */
2414
- const MIN_WAKE_DELAY_MS = 1;
2415
- /**
2416
- * Number of whole tokens required to release a single queued request. The token
2417
- * bucket consumes exactly one token per admitted request.
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
- // No tokens available, queue the request
2497
- logger.debug(`Rate limit reached for ${this.config.label}, queuing request`, {
2498
- queueLength: this.queue.length,
2499
- });
2500
- return new Promise((resolve, reject) => {
2501
- const timeoutHandle = setTimeout(() => {
2502
- // Remove from queue on timeout
2503
- const index = this.queue.findIndex((req) => req.timeoutHandle === timeoutHandle);
2504
- if (index !== -1) {
2505
- this.queue.splice(index, 1);
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
- const error = new RateLimitError(`Rate limit timeout for ${this.config.label} after ${this.timeoutMs}ms`, this.config.label, undefined);
2508
- logger.warn(`Rate limit timeout for ${this.config.label}`, {
2509
- queueLength: this.queue.length,
2510
- timeoutMs: this.timeoutMs,
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
- reject(error);
2513
- }, this.timeoutMs);
2514
- this.queue.push({ resolve, reject, timeoutHandle });
2515
- // Ensure the queue is actively drained even if no further acquire() calls
2516
- // arrive: schedule a wake-up to refill tokens and release this request.
2517
- this.scheduleQueueWake();
2518
- });
2519
- }
2520
- /**
2521
- * Schedules a single wake-up timer that refills tokens and drains the queue.
2522
- *
2523
- * The delay is the time required to accrue the tokens still needed to release
2524
- * the next queued request at the configured refill rate. Only one timer is
2525
- * ever outstanding (guarded by {@link wakeTimer}); the timer is `unref`'d so
2526
- * it never keeps the Node.js process alive on its own. When it fires it
2527
- * refills, drains what it can, and re-arms itself if work remains.
2528
- */
2529
- scheduleQueueWake() {
2530
- // A wake-up is already pending, or there is nothing to wake for.
2531
- if (this.wakeTimer !== null || this.queue.length === 0) {
2532
- return;
2533
- }
2534
- const tokensNeeded = Math.max(0, TOKENS_PER_REQUEST - this.tokens);
2535
- const deficitMs = Math.max(MIN_WAKE_DELAY_MS, Math.ceil((tokensNeeded / this.config.refillRate) * MS_PER_SECOND$1));
2536
- const timer = setTimeout(() => {
2537
- this.wakeTimer = null;
2538
- // refill() drains the queue via processQueue(); if requests remain
2539
- // afterwards, processQueue() re-arms the wake-up.
2540
- this.refill();
2541
- }, deficitMs);
2542
- // Do not let a pending rate-limiter wake-up keep the process alive.
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
- * Clears any pending wake-up timer.
2550
- */
2551
- clearWakeTimer() {
2552
- if (this.wakeTimer !== null) {
2553
- clearTimeout(this.wakeTimer);
2554
- this.wakeTimer = null;
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
- * Refills tokens based on elapsed time and processes queued requests
2559
- *
2560
- * Tokens are refilled at the configured rate up to the maximum capacity.
2561
- * If tokens are available after refilling, queued requests are processed.
2562
- */
2563
- refill() {
2564
- const now = Date.now();
2565
- const elapsed = (now - this.lastRefill) / 1000; // Convert to seconds
2566
- const tokensToAdd = elapsed * this.config.refillRate;
2567
- this.tokens = Math.min(this.config.maxTokens, this.tokens + tokensToAdd);
2568
- this.lastRefill = now;
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
- * Gets the current number of available tokens
2613
- *
2614
- * @returns Number of tokens currently available
2615
- */
2616
- getAvailableTokens() {
2617
- this.refill();
2618
- return Math.floor(this.tokens);
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
- * Gets the current queue length
2622
- *
2623
- * @returns Number of requests waiting for tokens
2624
- */
2625
- getQueueLength() {
2626
- return this.queue.length;
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
- * Clears all queued requests and resets the token bucket
2630
- *
2631
- * All queued requests will be rejected with a RateLimitError.
2632
- * Useful for cleanup or when changing rate limit configurations.
2633
- */
2634
- reset() {
2635
- const logger = getLogger();
2636
- // Reject all queued requests
2637
- for (const request of this.queue) {
2638
- clearTimeout(request.timeoutHandle);
2639
- request.reject(new RateLimitError(`Rate limiter reset for ${this.config.label}`, this.config.label, undefined));
2640
- }
2641
- this.clearWakeTimer();
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
- * Pre-configured rate limiters for common external APIs
2653
- *
2654
- * These limiters are configured based on the documented rate limits
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
- const rateLimiters = {
2669
- /**
2670
- * Alpaca API rate limiter
2671
- *
2672
- * Configured for 1000 requests per minute (paid tier).
2673
- * The token bucket allows burst up to maxTokens, then refills at the steady rate.
2674
- * See: https://alpaca.markets/docs/api-references/trading-api/#rate-limit
2675
- */
2676
- alpaca: new TokenBucketRateLimiter({
2677
- maxTokens: 1000,
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
- * Singleton class for interacting with Alpaca Market Data API
2741
- * Provides methods for fetching historical bars, latest bars, last trades, latest trades, latest quotes, and latest quote for a single symbol
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 AlpacaMarketDataAPI extends EventEmitter {
2744
- static instance;
2745
- headers;
2746
- dataURL;
2747
- apiURL;
2748
- v1beta1url;
2749
- /** Whether API credentials are valid and available. False during build time when env vars are missing. */
2750
- credentialsValid = false;
2751
- stockStreamUrl = getStockStreamUrl("PRODUCTION"); // production values
2752
- optionStreamUrl = getOptionsStreamUrl("PRODUCTION"); // production values
2753
- cryptoStreamUrl = getCryptoStreamUrl("PRODUCTION"); // production values
2754
- stockWs = null;
2755
- optionWs = null;
2756
- cryptoWs = null;
2757
- stockSubscriptions = {
2758
- trades: [],
2759
- quotes: [],
2760
- bars: [],
2761
- };
2762
- optionSubscriptions = {
2763
- trades: [],
2764
- quotes: [],
2765
- bars: [],
2766
- };
2767
- cryptoSubscriptions = {
2768
- trades: [],
2769
- quotes: [],
2770
- bars: [],
2771
- };
2772
- reconnectAttempts = {};
2773
- reconnectTimers = {};
2774
- /**
2775
- * Wall-clock timestamp of the most recent Alpaca app-level error code
2776
- * 406 ("connection limit exceeded") received on each stream. Used by
2777
- * {@link scheduleReconnect} to apply a long backoff with jitter rather
2778
- * than the normal sub-second exponential ramp — without this, hitting
2779
- * Alpaca's account-wide concurrent-connection cap (typical for blue/green
2780
- * deploy rollovers where the old pod's WS slots haven't been released
2781
- * yet) produced a 10-attempt retry storm that compounded the slot
2782
- * pressure and consumed the per-account connection quota across the
2783
- * organisation.
2784
- *
2785
- * Cleared once the long-backoff retry is scheduled so that subsequent
2786
- * normal failures fall back to the standard sub-second exponential.
2787
- *
2788
- * @see CONNECTION_LIMIT_BACKOFF_MS / CONNECTION_LIMIT_BACKOFF_JITTER_MS
2789
- */
2790
- lastConnectionLimitAt = {};
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
- * Five-minute base backoff after Alpaca's app-level 406. Long enough
2793
- * for Alpaca's server-side cleanup to release stale slots in typical
2794
- * rollover scenarios; short enough that an operator doesn't need to
2795
- * intervene. Mirrors the equivalent MassiveClient
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
- CONNECTION_LIMIT_BACKOFF_MS = 5 * 60_000;
2854
+ wakeTimer = null;
2800
2855
  /**
2801
- * ±30 s of uniform jitter on the connection-limit backoff. Prevents
2802
- * a thundering-herd retry when all three streams (stock / option /
2803
- * crypto) hit 406 simultaneously during a deploy rollover — without
2804
- * jitter they'd all retry at the same wall-clock instant and could
2805
- * re-trip the account cap together.
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
- CONNECTION_LIMIT_BACKOFF_JITTER_MS = 30_000;
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
- * Recency window within which a 406 is considered "still applicable"
2810
- * to a subsequent reconnect-schedule call. The 406 message handler
2811
- * stamps {@link lastConnectionLimitAt} and the `close` handler fires
2812
- * shortly afterwards (sub-second typically) — the window is wide
2813
- * enough to absorb scheduling delays without false-positives.
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
- CONNECTION_LIMIT_RECENCY_MS = 30_000;
2816
- setMode(mode = "production") {
2817
- if (mode === "sandbox") {
2818
- // sandbox mode
2819
- this.stockStreamUrl = WEBSOCKET_STREAMS.STOCKS.PRODUCTION; // sandbox uses production for stocks
2820
- this.optionStreamUrl = getOptionsStreamUrl("SANDBOX");
2821
- this.cryptoStreamUrl = getCryptoStreamUrl("SANDBOX");
2822
- }
2823
- else if (mode === "test") {
2824
- // test mode, can only use ticker FAKEPACA
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
- getMode() {
2837
- if (this.stockStreamUrl.includes("sandbox")) {
2838
- return "sandbox";
2839
- }
2840
- else if (this.stockStreamUrl.includes("test")) {
2841
- return "test";
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
- else {
2844
- return "production";
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
- constructor() {
2848
- super();
2849
- // Validate credentials from environment variables before initializing
2850
- // Use throwOnMissing: false to allow initialization during build time
2851
- // when env vars are not available. Features will be unavailable until
2852
- // credentials are provided at runtime.
2853
- const apiKey = process.env.ALPACA_API_KEY || "";
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
- emit(event, ...args) {
2883
- return super.emit(event, ...args);
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
- connect(streamType) {
2886
- let url;
2887
- if (streamType === "stock") {
2888
- url = this.stockStreamUrl;
2889
- }
2890
- else if (streamType === "option") {
2891
- url = this.optionStreamUrl;
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
- const ws = new WebSocket(url);
2903
- if (streamType === "stock") {
2904
- this.stockWs = ws;
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
- else if (streamType === "option") {
2907
- this.optionWs = ws;
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.cryptoWs = ws;
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
- scheduleReconnect(streamType) {
2992
- // 406-recovery fast path. When the most recent close was preceded
2993
- // by an Alpaca app-level 406 ("connection limit exceeded"), the
2994
- // standard sub-second exponential ramp is exactly wrong — it
2995
- // hammers the rate-limit endpoint and prolongs the slot pressure.
2996
- // Use a 5-minute jittered backoff instead and reset the normal
2997
- // attempt counter so we don't fall off the end of maxAttempts
2998
- // and permanently give up on a transient rollover blip.
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
- sendSubscription(streamType) {
3043
- let ws;
3044
- let subscriptions;
3045
- if (streamType === "stock") {
3046
- ws = this.stockWs;
3047
- subscriptions = this.stockSubscriptions;
3048
- }
3049
- else if (streamType === "option") {
3050
- ws = this.optionWs;
3051
- subscriptions = this.optionSubscriptions;
3052
- }
3053
- else {
3054
- ws = this.cryptoWs;
3055
- subscriptions = this.cryptoSubscriptions;
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
- 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})`, {
3058
- type: "debug",
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
- if (ws && ws.readyState === WebSocket.OPEN) {
3061
- const subMessagePayload = {};
3062
- if (subscriptions.trades.length > 0) {
3063
- subMessagePayload.trades = subscriptions.trades;
3064
- }
3065
- if (subscriptions.quotes.length > 0) {
3066
- subMessagePayload.quotes = subscriptions.quotes;
3067
- }
3068
- if (subscriptions.bars.length > 0) {
3069
- subMessagePayload.bars = subscriptions.bars;
3070
- }
3071
- if (Object.keys(subMessagePayload).length > 0) {
3072
- const subMessage = {
3073
- action: "subscribe",
3074
- ...subMessagePayload,
3075
- };
3076
- const messageJson = JSON.stringify(subMessage);
3077
- log$l(`Sending ${streamType} subscription: ${messageJson}`, {
3078
- type: "info",
3079
- });
3080
- ws.send(messageJson);
3081
- }
3082
- else {
3083
- log$l(`No ${streamType} subscriptions to send (all arrays empty)`, {
3084
- type: "debug",
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 (ws && ws.readyState === WebSocket.CONNECTING) {
3089
- // WebSocket is still establishing. Subscriptions are already persisted
3090
- // in `subscriptions` and will be dispatched automatically by the
3091
- // auth-success handler once the stream authenticates. This is the
3092
- // expected transient state during startup / reconnect — not a failure.
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
- const stateLabel = ws === null
3100
- ? "null"
3101
- : ws.readyState === WebSocket.CLOSING
3102
- ? "CLOSING"
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
- connectStockStream() {
3110
- if (!this.stockWs) {
3111
- this.connect("stock");
3275
+ getMode() {
3276
+ if (this.stockStreamUrl.includes("sandbox")) {
3277
+ return "sandbox";
3112
3278
  }
3113
- }
3114
- connectOptionStream() {
3115
- if (!this.optionWs) {
3116
- this.connect("option");
3279
+ else if (this.stockStreamUrl.includes("test")) {
3280
+ return "test";
3117
3281
  }
3118
- }
3119
- connectCryptoStream() {
3120
- if (!this.cryptoWs) {
3121
- this.connect("crypto");
3282
+ else {
3283
+ return "production";
3122
3284
  }
3123
3285
  }
3124
- disconnectStockStream() {
3125
- if (this.stockWs) {
3126
- this.stockWs.close();
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
- disconnectOptionStream() {
3130
- if (this.optionWs) {
3131
- this.optionWs.close();
3312
+ static getInstance() {
3313
+ if (!AlpacaMarketDataAPI.instance) {
3314
+ AlpacaMarketDataAPI.instance = new AlpacaMarketDataAPI();
3132
3315
  }
3316
+ return AlpacaMarketDataAPI.instance;
3133
3317
  }
3134
- disconnectCryptoStream() {
3135
- if (this.cryptoWs) {
3136
- this.cryptoWs.close();
3137
- }
3318
+ on(event, listener) {
3319
+ return super.on(event, listener);
3138
3320
  }
3139
- /**
3140
- * Check if a specific stream is connected
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
- subscribe(streamType, subscriptions) {
3156
- let currentSubscriptions;
3324
+ connect(streamType) {
3325
+ let url;
3157
3326
  if (streamType === "stock") {
3158
- currentSubscriptions = this.stockSubscriptions;
3327
+ url = this.stockStreamUrl;
3159
3328
  }
3160
3329
  else if (streamType === "option") {
3161
- currentSubscriptions = this.optionSubscriptions;
3330
+ url = this.optionStreamUrl;
3162
3331
  }
3163
3332
  else {
3164
- currentSubscriptions = this.cryptoSubscriptions;
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
- else {
3184
- currentSubscriptions = this.cryptoSubscriptions;
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
- Object.entries(subscriptions).forEach(([key, value]) => {
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
- ws = this.stockWs;
3343
+ this.stockWs = ws;
3198
3344
  }
3199
3345
  else if (streamType === "option") {
3200
- ws = this.optionWs;
3346
+ this.optionWs = ws;
3201
3347
  }
3202
3348
  else {
3203
- ws = this.cryptoWs;
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
- * Get historical OHLCV bars for specified symbols, including pre-market and post-market data
3258
- * Automatically handles pagination to fetch all available data
3259
- * @param params Parameters for historical bars request
3260
- * @returns Historical bars data with all pages combined
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
- const response = await this.makeRequest("/stocks/bars", "GET", requestParams);
3287
- if (!response.bars) {
3288
- log$l(`No bars data found in response for ${symbolsStr}`, {
3289
- type: "warn",
3290
- });
3291
- break;
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
- // Track currency from first response
3294
- if (!currency) {
3295
- currency = response.currency;
3366
+ catch (e) {
3367
+ log$l(`${streamType} stream received invalid JSON: ${rawData.substring(0, 200)}`, { type: "error" });
3368
+ return;
3296
3369
  }
3297
- // Combine bars for each symbol
3298
- let pageBarsCount = 0;
3299
- let earliestTimestamp = null;
3300
- let latestTimestamp = null;
3301
- Object.entries(response.bars).forEach(([symbol, bars]) => {
3302
- if (bars && bars.length > 0) {
3303
- allBars[symbol] = [...allBars[symbol], ...bars];
3304
- pageBarsCount += bars.length;
3305
- // Track date range for this page
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
- totalBarsCount += pageBarsCount;
3318
- pageToken = response.next_page_token || null;
3319
- hasMorePages = !!pageToken;
3320
- // Enhanced logging with date range and progress info
3321
- const dateRangeStr = earliestTimestamp && latestTimestamp
3322
- ? `${earliestTimestamp.toLocaleDateString("en-US", { timeZone: "America/New_York" })} to ${latestTimestamp.toLocaleDateString("en-US", { timeZone: "America/New_York" })}`
3323
- : "unknown range";
3324
- log$l(`Page ${pageCount}: Fetched ${pageBarsCount.toLocaleString()} bars (total: ${totalBarsCount.toLocaleString()}) for ${symbolsStr}, date range: ${dateRangeStr}${hasMorePages ? ", more pages available" : ", complete"}`, {
3325
- type: "info",
3326
- });
3327
- // Prevent infinite loops
3328
- if (pageCount > 1000) {
3329
- log$l(`Stopping pagination after ${pageCount} pages to prevent infinite loop`, { type: "warn" });
3330
- break;
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
- return {
3397
- quotes: {},
3398
- currency: currency || DEFAULT_CURRENCY$1,
3399
- };
3400
- }
3401
- return this.makeRequest("/stocks/quotes/latest", "GET", {
3402
- symbols,
3403
- feed: feed || DEFAULT_FEED$1,
3404
- currency: currency || DEFAULT_CURRENCY$1,
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
- * Get the previous day's closing price for a symbol
3422
- * @param symbol The stock symbol to query
3423
- * @param referenceDate Optional reference date to get the previous close for
3424
- * @returns Previous day's closing price data
3425
- */
3426
- async getPreviousClose(symbol, referenceDate) {
3427
- const date = referenceDate || new Date();
3428
- const prevMarketDate = getLastFullTradingDate(date);
3429
- // Alpaca bars use inclusive-start, exclusive-end (t >= start AND t < end).
3430
- // When start === end the range is empty and zero bars are returned.
3431
- // Set end to the next calendar day to capture exactly one daily bar.
3432
- const endDate = new Date(prevMarketDate.date);
3433
- endDate.setDate(endDate.getDate() + 1);
3434
- const response = await this.getHistoricalBars({
3435
- symbols: [symbol],
3436
- timeframe: "1Day",
3437
- start: prevMarketDate.date.toISOString(),
3438
- end: endDate.toISOString(),
3439
- limit: 1,
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 (!response.bars[symbol] || response.bars[symbol].length === 0) {
3442
- log$l(`No previous close data available for ${symbol}`, {
3443
- type: "error",
3444
- symbol,
3445
- });
3446
- return null;
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
- * Get hourly price data for a symbol
3452
- * @param symbol The stock symbol to query
3453
- * @param start Start time in milliseconds
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
- * Get half-hourly price data for a symbol
3469
- * @param symbol The stock symbol to query
3470
- * @param start Start time in milliseconds
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
- * Get daily price data for a symbol
3486
- * @param symbol The stock symbol to query
3487
- * @param start Start time in milliseconds
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
- * Get intraday price data for a symbol
3503
- * @param symbol The stock symbol to query
3504
- * @param minutePeriod Minutes per bar (1, 5, 15, etc.)
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
- * Analyzes an array of price bars and returns a summary string
3521
- * @param bars Array of price bars to analyze
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
- * Get all assets available for trade and data consumption from Alpaca
3545
- * @param params Optional query params: status (e.g. 'active'), asset_class (e.g. 'us_equity', 'crypto')
3546
- * @returns Array of AlpacaAsset objects
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
- * Get a single asset by symbol or asset_id
3555
- * @param symbolOrAssetId Symbol or asset_id
3556
- * @returns AlpacaAsset object
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
- async getAsset(symbolOrAssetId) {
3560
- // Endpoint: GET /v2/assets/{symbol_or_asset_id}
3561
- return this.makeRequest(`/assets/${encodeURIComponent(symbolOrAssetId)}`, "GET", undefined, "api");
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
- // ===== OPTIONS MARKET DATA METHODS =====
3564
- /**
3565
- * Get options chain for an underlying symbol
3566
- * Provides the latest trade, latest quote, and greeks for each contract symbol of the underlying symbol
3567
- * @param params Options chain request parameters
3568
- * @returns Options chain data with snapshots for each contract
3569
- * @see https://docs.alpaca.markets/reference/optionchain
3570
- */
3571
- async getOptionsChain(params) {
3572
- const { underlying_symbol, ...queryParams } = params;
3573
- return this.makeRequest(`/options/snapshots/${encodeURIComponent(underlying_symbol)}`, "GET", queryParams, "v1beta1");
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
- * Get the most recent trades for requested option contract symbols
3577
- * @param params Latest options trades request parameters
3578
- * @returns Latest trade data for each option contract symbol
3579
-
3580
- * @see https://docs.alpaca.markets/reference/optionlatesttrades
3581
- */
3582
- async getLatestOptionsTrades(params) {
3583
- // Remove limit and page_token as they're not supported by this endpoint
3584
- const { limit: _limit, page_token: _page_token, ...requestParams } = params;
3585
- return this.makeRequest("/options/trades/latest", "GET", requestParams, "v1beta1");
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
- * Get the most recent quotes for requested option contract symbols
3589
- * @param params Latest options quotes request parameters
3590
- * @returns Latest quote data for each option contract symbol
3591
-
3592
- * @see https://docs.alpaca.markets/reference/optionlatestquotes
3593
- */
3594
- async getLatestOptionsQuotes(params) {
3595
- // Remove limit and page_token as they're not supported by this endpoint
3596
- const { limit: _limit, page_token: _page_token, ...requestParams } = params;
3597
- return this.makeRequest("/options/quotes/latest", "GET", requestParams, "v1beta1");
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 option contract symbols
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 Historical options bars request parameters
3603
- * @returns Historical bar data for each option contract symbol with all pages combined
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 getHistoricalOptionsBars(params) {
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 options bars fetch for ${symbolsStr} (${params.timeframe}, ${params.start || "no start"} to ${params.end || "no end"})`, {
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("/options/bars", "GET", requestParams, "v1beta1");
3756
+ const response = await this.makeRequest("/stocks/bars", "GET", requestParams);
3629
3757
  if (!response.bars) {
3630
- log$l(`No options bars data found in response for ${symbolsStr}`, {
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()} option bars (total: ${totalBarsCount.toLocaleString()}) for ${symbolsStr}, date range: ${dateRangeStr}${hasMorePages ? ", more pages available" : ", complete"}`, {
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 options bars pagination after ${pageCount} pages to prevent infinite loop`, { type: "warn" });
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 options bars fetch complete: ${totalBarsCount.toLocaleString()} total bars across ${pageCount} pages (${symbolCounts})`, {
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: undefined, // Always undefined since we fetch all pages
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 historical trades for option contract symbols
3685
- * Automatically handles pagination to fetch all available data
3686
- * @param params Historical options trades request parameters
3687
- * @returns Historical trade data for each option contract symbol with all pages combined
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 getHistoricalOptionsTrades(params) {
3692
- const symbols = params.symbols;
3693
- const symbolsStr = symbols.join(",");
3694
- const allTrades = {};
3695
- let pageToken = null;
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
- log$l(`Starting historical options trades fetch for ${symbolsStr} (${params.start || "no start"} to ${params.end || "no end"})`, {
3704
- type: "info",
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
- while (hasMorePages) {
3707
- pageCount++;
3708
- const requestParams = {
3709
- ...params,
3710
- ...(pageToken && { page_token: pageToken }),
3711
- };
3712
- const response = await this.makeRequest("/options/trades", "GET", requestParams, "v1beta1");
3713
- if (!response.trades) {
3714
- log$l(`No options trades data found in response for ${symbolsStr}`, {
3715
- type: "warn",
3716
- });
3717
- break;
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
- totalTradesCount += pageTradesCount;
3740
- pageToken = response.next_page_token || null;
3741
- hasMorePages = !!pageToken;
3742
- // Enhanced logging with date range and progress info
3743
- const dateRangeStr = earliestTimestamp && latestTimestamp
3744
- ? `${earliestTimestamp.toLocaleDateString("en-US", { timeZone: "America/New_York" })} to ${latestTimestamp.toLocaleDateString("en-US", { timeZone: "America/New_York" })}`
3745
- : "unknown range";
3746
- log$l(`Page ${pageCount}: Fetched ${pageTradesCount.toLocaleString()} option trades (total: ${totalTradesCount.toLocaleString()}) for ${symbolsStr}, date range: ${dateRangeStr}${hasMorePages ? ", more pages available" : ", complete"}`, {
3747
- type: "info",
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
- // Prevent infinite loops
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
- // Final summary
3756
- const symbolCounts = Object.entries(allTrades)
3757
- .map(([symbol, trades]) => `${symbol}: ${trades.length}`)
3758
- .join(", ");
3759
- log$l(`Historical options trades fetch complete: ${totalTradesCount.toLocaleString()} total trades across ${pageCount} pages (${symbolCounts})`, {
3760
- type: "info",
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 snapshots for option contract symbols
3769
- * Provides latest trade, latest quote, and greeks for each contract symbol
3770
- * @param params Options snapshots request parameters
3771
- * @returns Snapshot data for each option contract symbol
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 getOptionsSnapshot(params) {
3776
- // Remove limit and page_token as they may not be supported by this endpoint
3777
- const { limit: _limit, page_token: _page_token, ...requestParams } = params;
3778
- return this.makeRequest("/options/snapshots", "GET", requestParams, "v1beta1");
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 condition codes for options trades or quotes
3782
- * Returns the mapping between condition codes and their descriptions
3783
- * @param tickType The type of tick data ('trade' or 'quote')
3784
- * @returns Mapping of condition codes to descriptions
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 getOptionsConditionCodes(tickType) {
3789
- return this.makeRequest(`/options/meta/conditions/${tickType}`, "GET", undefined, "v1beta1");
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 exchange codes for options
3793
- * Returns the mapping between option exchange codes and exchange names
3794
- * @returns Mapping of exchange codes to exchange names
3795
-
3796
- * @see https://docs.alpaca.markets/reference/optionmetaexchanges
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 getOptionsExchangeCodes() {
3799
- return this.makeRequest("/options/meta/exchanges", "GET", undefined, "v1beta1");
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 option bars and returns a summary string
3803
- * @param bars Array of option bars to analyze
3804
- * @returns A string summarizing the option price data
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 analyzeOptionBars(bars) {
3994
+ static analyzeBars(bars) {
3807
3995
  if (!bars || bars.length === 0) {
3808
- return "No option price data available";
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 = firstBar.v > 0 ? (volumeChange / firstBar.v) * 100 : 0;
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 (`Option Price: $${firstBar.o.toFixed(2)} -> $${lastBar.c.toFixed(2)} (${percentChange.toFixed(2)}%), ` +
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
- * Formats option greeks for display
3827
- * @param greeks Option greeks object
3828
- * @returns Formatted string with greek values
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
- static formatOptionGreeks(greeks) {
3831
- if (!greeks) {
3832
- return "No greeks data available";
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
- * Interprets condition codes using the provided condition codes mapping
3849
- * @param conditionCodes Array of condition codes from trade or quote
3850
- * @param conditionCodesMap Mapping of condition codes to descriptions
3851
- * @returns Formatted string with condition descriptions
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
- static interpretConditionCodes(conditionCodes, conditionCodesMap) {
3854
- if (!conditionCodes || conditionCodes.length === 0) {
3855
- return "No conditions";
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
- * Gets the exchange name from exchange code using the provided exchange codes mapping
3866
- * @param exchangeCode Exchange code from trade or quote
3867
- * @param exchangeCodesMap Mapping of exchange codes to names
3868
- * @returns Exchange name or formatted unknown exchange
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
- static getExchangeName(exchangeCode, exchangeCodesMap) {
3871
- return (exchangeCodesMap[exchangeCode] || `Unknown Exchange (${exchangeCode})`);
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
- * Fetches news articles from Alpaca API for a symbol, paginating through all results.
3875
- * @param symbol The symbol to fetch news for (e.g., 'AAPL')
3876
- * @param params Optional parameters: start, end, limit, sort, include_content
3877
- * @returns Array of SimpleNews articles
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 fetchNews(symbol, params) {
3880
- const defaultParams = {
3881
- start: new Date(Date.now() - 24 * 60 * 60 * 1000),
3882
- end: new Date(),
3883
- limit: 10,
3884
- sort: "desc",
3885
- include_content: true,
3886
- };
3887
- const mergedParams = { ...defaultParams, ...params };
3888
- let newsArticles = [];
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 fetchedCount = 0;
3892
- const maxLimit = mergedParams.limit;
3893
- // Utility to clean content
3894
- function cleanContent(content) {
3895
- if (!content)
3896
- return undefined;
3897
- // Remove excessive whitespace, newlines, and trim
3898
- return content.replace(/\s+/g, " ").trim();
3899
- }
3900
- while (hasMorePages) {
3901
- const queryParams = new URLSearchParams({
3902
- ...(mergedParams.start && {
3903
- start: new Date(mergedParams.start).toISOString(),
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 url = `${this.v1beta1url}/news?${queryParams}`;
3919
- log$l(`Fetching news from: ${url}`, { type: "debug", symbol });
3920
- const response = await fetch(url, {
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
- const transformedNews = data.news.map((article) => ({
3941
- symbols: article.symbols,
3942
- title: article.headline,
3943
- summary: cleanContent(article.summary) ?? "",
3944
- content: article.content ? cleanContent(article.content) : undefined,
3945
- url: article.url,
3946
- source: article.source,
3947
- author: article.author,
3948
- date: article.updated_at || article.created_at,
3949
- updatedDate: article.updated_at || article.created_at,
3950
- sentiment: 0,
3951
- }));
3952
- newsArticles = newsArticles.concat(transformedNews);
3953
- fetchedCount = newsArticles.length;
3954
- pageToken = data.next_page_token || null;
3955
- hasMorePages = !!pageToken && (!maxLimit || fetchedCount < maxLimit);
3956
- log$l(`Fetched ${transformedNews.length} news articles (total: ${fetchedCount}) for ${symbol}. More pages: ${hasMorePages}`, { type: "debug", symbol });
3957
- if (maxLimit && fetchedCount >= maxLimit) {
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
- return false;
4066
- }
4067
- /**
4068
- * Analyzes an error and determines if it's retryable.
4069
- * @param error - The error to analyze
4070
- * @param response - Optional Response object for HTTP errors
4071
- * @param config - Retry configuration
4072
- * @returns Structured error details
4073
- */
4074
- function analyzeError(error, response, config) {
4075
- // Handle Response objects with error status codes
4076
- if (response && !response.ok) {
4077
- const status = response.status;
4078
- // Rate limit errors - always retryable
4079
- if (status === 429) {
4080
- const retryAfterHeader = response.headers.get("Retry-After");
4081
- const retryAfter = retryAfterHeader
4082
- ? parseInt(retryAfterHeader, 10) * 1000
4083
- : undefined;
4084
- return {
4085
- type: "RATE_LIMIT",
4086
- reason: "Rate limit exceeded",
4087
- status,
4088
- retryAfter,
4089
- isRetryable: true,
4090
- };
4091
- }
4092
- // Authentication errors - never retry
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
- // Handle network errors (TypeError from fetch API)
4123
- if (error instanceof TypeError && error.message.includes("fetch")) {
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
- type: "NETWORK_ERROR",
4126
- reason: "Network connectivity issue",
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
- // Handle transient network conditions: AbortError, TimeoutError,
4132
- // Node/undici error codes (ETIMEDOUT, ECONNRESET, UND_ERR_*), and
4133
- // wrapped failures exposed via error.cause. This catches the broad class
4134
- // of infrastructure flakes that the TypeError-only check above misses.
4135
- if (isTransientNetworkError(error)) {
4136
- const reason = error instanceof Error ? error.message : "Transient network error";
4137
- return {
4138
- type: "NETWORK_ERROR",
4139
- reason,
4140
- status: null,
4141
- isRetryable: config.retryOnNetworkError,
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
- // Handle error objects with messages
4145
- if (error instanceof Error) {
4146
- // Parse error messages that might contain status information
4147
- if (error.message.includes("429") || error.message.includes("RATE_LIMIT")) {
4148
- const match = error.message.match(/RATE_LIMIT: 429:(\d+)/);
4149
- const retryAfter = match ? parseInt(match[1], 10) : undefined;
4150
- return {
4151
- type: "RATE_LIMIT",
4152
- reason: "Rate limit exceeded",
4153
- status: 429,
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
- // Unknown error - not retryable by default for safety
4191
- return {
4192
- type: "UNKNOWN",
4193
- reason: error instanceof Error ? error.message : String(error),
4194
- status: null,
4195
- isRetryable: false,
4196
- };
4197
- }
4198
- /**
4199
- * Calculates the delay before the next retry attempt using exponential backoff with jitter.
4200
- * @param attempt - Current attempt number (1-indexed)
4201
- * @param baseDelay - Base delay in milliseconds
4202
- * @param maxDelay - Maximum delay in milliseconds
4203
- * @returns Delay in milliseconds
4204
- */
4205
- function calculateBackoff(attempt, baseDelay, maxDelay) {
4206
- // Exponential backoff: baseDelay * 2^(attempt-1)
4207
- const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
4208
- // Cap at maxDelay
4209
- const cappedDelay = Math.min(exponentialDelay, maxDelay);
4210
- // Add jitter (random value between 0% and 25% of the delay)
4211
- const jitter = Math.random() * cappedDelay * 0.25;
4212
- return Math.floor(cappedDelay + jitter);
4213
- }
4214
- /**
4215
- * Wraps an async function with retry logic and exponential backoff.
4216
- *
4217
- * This utility handles transient errors in external API calls by automatically retrying
4218
- * failed requests with intelligent backoff strategies. It respects rate limit headers,
4219
- * fails fast on non-retryable errors, and provides detailed logging.
4220
- *
4221
- * @template T - The return type of the wrapped function
4222
- * @param fn - The async function to wrap with retry logic
4223
- * @param config - Retry configuration (merged with defaults)
4224
- * @param label - A descriptive label for logging (e.g., 'Massive.fetchTickerInfo')
4225
- * @returns A promise that resolves to the function's return value
4226
- * @throws The last error encountered if all retries are exhausted
4227
- *
4228
- * @example
4229
- * ```typescript
4230
- * // Basic usage with defaults
4231
- * const data = await withRetry(
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
- catch (error) {
4265
- lastError = error;
4266
- // If this is the last attempt, throw the error.
4267
- // Transient network classes (undici/fetch timeouts, ECONNRESET,
4268
- // AbortError, etc.) are self-healing at the upstream retry layer —
4269
- // the caller re-invokes on the next refresh/poll tick. Logging them
4270
- // at ERROR produces alert noise that does not represent actionable
4271
- // failures. Demote the transient class to WARN with a recovery hint;
4272
- // reserve ERROR for non-transient final failures (auth, schema,
4273
- // contract violations, unknown classes).
4274
- if (attempt === fullConfig.maxRetries) {
4275
- const isTransient = isTransientNetworkError(error);
4276
- const logMeta = {
4277
- error: error instanceof Error ? error.message : String(error),
4278
- attempts: fullConfig.maxRetries,
4279
- timestamp: new Date().toISOString(),
4280
- ...(isTransient
4281
- ? {
4282
- transient: true,
4283
- recoveryHint: "Upstream caller should retry on next cycle",
4284
- }
4285
- : {}),
4286
- };
4287
- if (isTransient) {
4288
- getLogger().warn(`[${label}] Failed after ${fullConfig.maxRetries} attempts (transient)`, logMeta);
4289
- }
4290
- else {
4291
- getLogger().error(`[${label}] Failed after ${fullConfig.maxRetries} attempts`, logMeta);
4292
- }
4293
- throw error;
4294
- }
4295
- // Analyze the error to determine if we should retry
4296
- const response = error instanceof Response ? error : null;
4297
- const errorDetails = analyzeError(error, response, fullConfig);
4298
- // If error is not retryable, fail immediately
4299
- if (!errorDetails.isRetryable) {
4300
- getLogger().error(`[${label}] Non-retryable error (${errorDetails.type})`, {
4301
- reason: errorDetails.reason,
4302
- status: errorDetails.status,
4303
- timestamp: new Date().toISOString(),
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
- else {
4318
- // Standard exponential backoff with jitter
4319
- delayMs = calculateBackoff(attempt, fullConfig.baseDelayMs, fullConfig.maxDelayMs);
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
- // Log the retry attempt
4322
- getLogger().warn(`[${label}] Attempt ${attempt}/${fullConfig.maxRetries} failed: ${errorDetails.reason}. Retrying in ${delayMs}ms...`, {
4323
- attemptNumber: attempt,
4324
- totalRetries: fullConfig.maxRetries,
4325
- errorType: errorDetails.type,
4326
- httpStatus: errorDetails.status,
4327
- retryDelay: delayMs,
4328
- timestamp: new Date().toISOString(),
4329
- });
4330
- // Call the optional retry callback
4331
- if (fullConfig.onRetry) {
4332
- fullConfig.onRetry(attempt, error);
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
- * API-specific retry configurations for different external services.
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
  /**