@adaptic/utils 0.0.1005 → 0.0.1007

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