@adaptic/utils 0.0.1004 → 0.0.1006
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1992 -1935
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +1992 -1935
- package/dist/index.mjs.map +1 -1
- package/dist/test.js +155 -5
- package/dist/test.js.map +1 -1
- package/dist/types/alpaca-market-data-api.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.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
|
-
*
|
|
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
|
-
*
|
|
2188
|
-
*
|
|
2210
|
+
* Error constructor names / `error.name` values that indicate transient
|
|
2211
|
+
* abort / timeout conditions.
|
|
2189
2212
|
*/
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
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
|
-
*
|
|
2211
|
-
*
|
|
2223
|
+
* Message-pattern fallback for libraries that discard error codes/names but
|
|
2224
|
+
* preserve text (e.g., some Apollo/axios wrappers).
|
|
2212
2225
|
*/
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
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
|
-
*
|
|
2224
|
-
*
|
|
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
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
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
|
-
*
|
|
2237
|
-
*
|
|
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
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
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
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
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
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
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
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
}
|
|
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
|
-
*
|
|
2382
|
-
*
|
|
2383
|
-
*
|
|
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
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
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
|
-
*
|
|
2427
|
+
* Wraps an async function with retry logic and exponential backoff.
|
|
2396
2428
|
*
|
|
2397
|
-
*
|
|
2398
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* //
|
|
2405
|
-
* await
|
|
2406
|
-
*
|
|
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
|
-
|
|
2410
|
-
const
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
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
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
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
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
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
|
-
|
|
2515
|
-
}
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
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
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
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
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
const
|
|
2569
|
-
|
|
2570
|
-
this.
|
|
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
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
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
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
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
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
this.
|
|
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
|
-
*
|
|
2655
|
-
*
|
|
2656
|
-
*
|
|
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
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
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
|
-
*
|
|
2743
|
-
*
|
|
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
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
}
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
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
|
-
*
|
|
2795
|
-
*
|
|
2796
|
-
*
|
|
2797
|
-
*
|
|
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
|
-
|
|
2856
|
+
wakeTimer = null;
|
|
2802
2857
|
/**
|
|
2803
|
-
*
|
|
2804
|
-
*
|
|
2805
|
-
*
|
|
2806
|
-
*
|
|
2807
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
2812
|
-
*
|
|
2813
|
-
*
|
|
2814
|
-
*
|
|
2815
|
-
*
|
|
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
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
this.
|
|
2822
|
-
this.
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
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
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
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
|
-
|
|
2846
|
-
|
|
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
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
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
|
-
|
|
2885
|
-
|
|
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
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
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
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
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
|
-
|
|
2909
|
-
this.
|
|
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.
|
|
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
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
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
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
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
|
-
|
|
3060
|
-
|
|
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
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
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 (
|
|
3091
|
-
//
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
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
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
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
|
-
|
|
3112
|
-
if (
|
|
3113
|
-
|
|
3277
|
+
getMode() {
|
|
3278
|
+
if (this.stockStreamUrl.includes("sandbox")) {
|
|
3279
|
+
return "sandbox";
|
|
3114
3280
|
}
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
if (!this.optionWs) {
|
|
3118
|
-
this.connect("option");
|
|
3281
|
+
else if (this.stockStreamUrl.includes("test")) {
|
|
3282
|
+
return "test";
|
|
3119
3283
|
}
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
if (!this.cryptoWs) {
|
|
3123
|
-
this.connect("crypto");
|
|
3284
|
+
else {
|
|
3285
|
+
return "production";
|
|
3124
3286
|
}
|
|
3125
3287
|
}
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
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
|
-
|
|
3132
|
-
if (
|
|
3133
|
-
|
|
3314
|
+
static getInstance() {
|
|
3315
|
+
if (!AlpacaMarketDataAPI.instance) {
|
|
3316
|
+
AlpacaMarketDataAPI.instance = new AlpacaMarketDataAPI();
|
|
3134
3317
|
}
|
|
3318
|
+
return AlpacaMarketDataAPI.instance;
|
|
3135
3319
|
}
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
this.cryptoWs.close();
|
|
3139
|
-
}
|
|
3320
|
+
on(event, listener) {
|
|
3321
|
+
return super.on(event, listener);
|
|
3140
3322
|
}
|
|
3141
|
-
|
|
3142
|
-
|
|
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
|
-
|
|
3158
|
-
let
|
|
3326
|
+
connect(streamType) {
|
|
3327
|
+
let url;
|
|
3159
3328
|
if (streamType === "stock") {
|
|
3160
|
-
|
|
3329
|
+
url = this.stockStreamUrl;
|
|
3161
3330
|
}
|
|
3162
3331
|
else if (streamType === "option") {
|
|
3163
|
-
|
|
3332
|
+
url = this.optionStreamUrl;
|
|
3164
3333
|
}
|
|
3165
3334
|
else {
|
|
3166
|
-
|
|
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
|
-
|
|
3186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3345
|
+
this.stockWs = ws;
|
|
3200
3346
|
}
|
|
3201
3347
|
else if (streamType === "option") {
|
|
3202
|
-
|
|
3348
|
+
this.optionWs = ws;
|
|
3203
3349
|
}
|
|
3204
3350
|
else {
|
|
3205
|
-
|
|
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
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
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
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
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
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3368
|
+
catch (e) {
|
|
3369
|
+
log$l(`${streamType} stream received invalid JSON: ${rawData.substring(0, 200)}`, { type: "error" });
|
|
3370
|
+
return;
|
|
3298
3371
|
}
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
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
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
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
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
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
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
const
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
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 (
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
}
|
|
3448
|
-
|
|
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
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
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
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
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
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
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
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
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
|
-
|
|
3523
|
-
|
|
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
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
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
|
-
*
|
|
3557
|
-
* @param
|
|
3558
|
-
* @returns
|
|
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
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
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
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
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
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
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
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
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
|
|
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
|
|
3605
|
-
* @returns Historical
|
|
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
|
|
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
|
|
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("/
|
|
3758
|
+
const response = await this.makeRequest("/stocks/bars", "GET", requestParams);
|
|
3631
3759
|
if (!response.bars) {
|
|
3632
|
-
log$l(`No
|
|
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()}
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
3687
|
-
*
|
|
3688
|
-
* @param
|
|
3689
|
-
* @returns
|
|
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
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
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
|
-
|
|
3706
|
-
|
|
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
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
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
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
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
|
|
3771
|
-
*
|
|
3772
|
-
* @param
|
|
3773
|
-
* @
|
|
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
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
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
|
|
3784
|
-
*
|
|
3785
|
-
* @param
|
|
3786
|
-
* @
|
|
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
|
|
3791
|
-
|
|
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
|
|
3795
|
-
*
|
|
3796
|
-
* @
|
|
3797
|
-
|
|
3798
|
-
* @
|
|
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
|
|
3801
|
-
|
|
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
|
|
3805
|
-
* @param bars Array of
|
|
3806
|
-
* @returns A string summarizing the
|
|
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
|
|
3996
|
+
static analyzeBars(bars) {
|
|
3809
3997
|
if (!bars || bars.length === 0) {
|
|
3810
|
-
return "No
|
|
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 =
|
|
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 (`
|
|
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
|
-
*
|
|
3829
|
-
* @param
|
|
3830
|
-
* @returns
|
|
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
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
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
|
-
*
|
|
3851
|
-
* @param
|
|
3852
|
-
* @
|
|
3853
|
-
* @
|
|
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
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
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
|
-
*
|
|
3868
|
-
*
|
|
3869
|
-
* @param
|
|
3870
|
-
* @returns
|
|
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
|
-
|
|
3873
|
-
|
|
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
|
-
*
|
|
3877
|
-
* @param
|
|
3878
|
-
* @
|
|
3879
|
-
|
|
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
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
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
|
|
3894
|
-
|
|
3895
|
-
//
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
}
|
|
3902
|
-
while (hasMorePages) {
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
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
|
|
3921
|
-
|
|
3922
|
-
|
|
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
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
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
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
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
|
-
|
|
4125
|
-
|
|
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
|
-
|
|
4128
|
-
|
|
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
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
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
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
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
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
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
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
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
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
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
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
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
|
-
|
|
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
|
/**
|