@adaptic/utils 0.0.964 → 0.0.966
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 +5106 -5090
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +5106 -5090
- package/dist/index.mjs.map +1 -1
- package/dist/test.js +383 -1
- package/dist/test.js.map +1 -1
- package/dist/types/alpaca-market-data-api.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/test.js
CHANGED
|
@@ -640,6 +640,67 @@ class DisplayManager {
|
|
|
640
640
|
}
|
|
641
641
|
}
|
|
642
642
|
|
|
643
|
+
/**
|
|
644
|
+
* Configurable logger interface compatible with Pino and other logging libraries.
|
|
645
|
+
* Provides structured logging with context support.
|
|
646
|
+
*/
|
|
647
|
+
/**
|
|
648
|
+
* Normalizes context to a format suitable for logging.
|
|
649
|
+
* Handles various types including primitives, objects, and errors.
|
|
650
|
+
*/
|
|
651
|
+
function normalizeContext(context) {
|
|
652
|
+
if (context === undefined || context === null) {
|
|
653
|
+
return undefined;
|
|
654
|
+
}
|
|
655
|
+
if (typeof context === "object" && context !== null) {
|
|
656
|
+
// Handle Error objects
|
|
657
|
+
if (context instanceof Error) {
|
|
658
|
+
return {
|
|
659
|
+
error: {
|
|
660
|
+
message: context.message,
|
|
661
|
+
name: context.name,
|
|
662
|
+
stack: context.stack,
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
// Already an object, return as-is
|
|
667
|
+
return context;
|
|
668
|
+
}
|
|
669
|
+
// Primitive types - wrap in object
|
|
670
|
+
return { value: context };
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Default logger implementation that uses console for backward compatibility.
|
|
674
|
+
* Formats messages in a simple readable format.
|
|
675
|
+
*/
|
|
676
|
+
const defaultLogger = {
|
|
677
|
+
error: (msg, ctx) => console.error(msg, normalizeContext(ctx) || ""),
|
|
678
|
+
warn: (msg, ctx) => console.warn(msg, normalizeContext(ctx) || ""),
|
|
679
|
+
info: (msg, ctx) => console.info(msg, normalizeContext(ctx) || ""),
|
|
680
|
+
debug: (msg, ctx) => console.debug(msg, normalizeContext(ctx) || ""),
|
|
681
|
+
};
|
|
682
|
+
let currentLogger = defaultLogger;
|
|
683
|
+
/**
|
|
684
|
+
* Gets the current logger instance.
|
|
685
|
+
* Use this to log messages throughout the application.
|
|
686
|
+
*
|
|
687
|
+
* @returns The current logger instance
|
|
688
|
+
*
|
|
689
|
+
* @example
|
|
690
|
+
* ```typescript
|
|
691
|
+
* import { getLogger } from '@adaptic/utils';
|
|
692
|
+
*
|
|
693
|
+
* const logger = getLogger();
|
|
694
|
+
* logger.error('Operation failed', { userId: 123, operation: 'createOrder' });
|
|
695
|
+
* logger.warn('Rate limit approaching', { remaining: 10 });
|
|
696
|
+
* logger.info('Order created', { orderId: 'abc123', symbol: 'AAPL' });
|
|
697
|
+
* logger.debug('Cache hit', { key: 'user:123' });
|
|
698
|
+
* ```
|
|
699
|
+
*/
|
|
700
|
+
function getLogger() {
|
|
701
|
+
return currentLogger;
|
|
702
|
+
}
|
|
703
|
+
|
|
643
704
|
/**
|
|
644
705
|
* Logs a message.
|
|
645
706
|
*
|
|
@@ -1482,12 +1543,328 @@ function createTimeoutSignal(ms) {
|
|
|
1482
1543
|
return AbortSignal.timeout(ms);
|
|
1483
1544
|
}
|
|
1484
1545
|
|
|
1546
|
+
/**
|
|
1547
|
+
* Structured error type hierarchy for all API integrations
|
|
1548
|
+
*
|
|
1549
|
+
* This module provides a comprehensive error handling system for external API integrations,
|
|
1550
|
+
* including Alpaca, Massive, and AlphaVantage services.
|
|
1551
|
+
*/
|
|
1552
|
+
/**
|
|
1553
|
+
* Base error class for all @adaptic/utils errors
|
|
1554
|
+
* Extends Error with additional context about service, error code, and retry capability
|
|
1555
|
+
*/
|
|
1556
|
+
class AdapticUtilsError extends Error {
|
|
1557
|
+
code;
|
|
1558
|
+
service;
|
|
1559
|
+
isRetryable;
|
|
1560
|
+
cause;
|
|
1561
|
+
name;
|
|
1562
|
+
constructor(message, code, service, isRetryable = false, cause) {
|
|
1563
|
+
super(message);
|
|
1564
|
+
this.code = code;
|
|
1565
|
+
this.service = service;
|
|
1566
|
+
this.isRetryable = isRetryable;
|
|
1567
|
+
this.cause = cause;
|
|
1568
|
+
this.name = this.constructor.name;
|
|
1569
|
+
// Maintains proper stack trace for where error was thrown (only available on V8)
|
|
1570
|
+
if (Error.captureStackTrace) {
|
|
1571
|
+
Error.captureStackTrace(this, this.constructor);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Rate limit errors (429)
|
|
1577
|
+
* Used when API rate limits are exceeded
|
|
1578
|
+
* Always retryable, often with retry-after header information
|
|
1579
|
+
*/
|
|
1580
|
+
class RateLimitError extends AdapticUtilsError {
|
|
1581
|
+
service;
|
|
1582
|
+
retryAfterMs;
|
|
1583
|
+
constructor(message, service, retryAfterMs, cause) {
|
|
1584
|
+
super(message, "RATE_LIMIT", service, true, // Rate limit errors are always retryable
|
|
1585
|
+
cause);
|
|
1586
|
+
this.service = service;
|
|
1587
|
+
this.retryAfterMs = retryAfterMs;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* Token bucket rate limiter for external API integrations
|
|
1593
|
+
*
|
|
1594
|
+
* Implements client-side rate limiting to prevent exceeding API quotas
|
|
1595
|
+
* and ensure fair usage of external services like Alpaca, Massive, and AlphaVantage.
|
|
1596
|
+
*
|
|
1597
|
+
* @example
|
|
1598
|
+
* ```typescript
|
|
1599
|
+
* import { rateLimiters } from '@adaptic/utils';
|
|
1600
|
+
*
|
|
1601
|
+
* // Before making an API call
|
|
1602
|
+
* await rateLimiters.alpaca.acquire();
|
|
1603
|
+
* const result = await makeAlpacaApiCall();
|
|
1604
|
+
* ```
|
|
1605
|
+
*/
|
|
1606
|
+
/**
|
|
1607
|
+
* Token bucket rate limiter implementation
|
|
1608
|
+
*
|
|
1609
|
+
* Uses the token bucket algorithm to control the rate of API requests.
|
|
1610
|
+
* Tokens are consumed on each request and refilled at a constant rate.
|
|
1611
|
+
* Requests that exceed the available tokens are queued and processed
|
|
1612
|
+
* when tokens become available.
|
|
1613
|
+
*/
|
|
1614
|
+
class TokenBucketRateLimiter {
|
|
1615
|
+
config;
|
|
1616
|
+
tokens;
|
|
1617
|
+
lastRefill;
|
|
1618
|
+
queue = [];
|
|
1619
|
+
timeoutMs;
|
|
1620
|
+
processingQueue = false;
|
|
1621
|
+
/**
|
|
1622
|
+
* Creates a new rate limiter instance
|
|
1623
|
+
*
|
|
1624
|
+
* @param config - Rate limiter configuration
|
|
1625
|
+
*
|
|
1626
|
+
* @example
|
|
1627
|
+
* ```typescript
|
|
1628
|
+
* // Alpaca: 200 requests per minute
|
|
1629
|
+
* const alpacaLimiter = new TokenBucketRateLimiter({
|
|
1630
|
+
* maxTokens: 200,
|
|
1631
|
+
* refillRate: 200 / 60, // ~3.33 per second
|
|
1632
|
+
* label: 'alpaca',
|
|
1633
|
+
* timeoutMs: 60000
|
|
1634
|
+
* });
|
|
1635
|
+
* ```
|
|
1636
|
+
*/
|
|
1637
|
+
constructor(config) {
|
|
1638
|
+
this.config = config;
|
|
1639
|
+
this.tokens = config.maxTokens;
|
|
1640
|
+
this.lastRefill = Date.now();
|
|
1641
|
+
this.timeoutMs = config.timeoutMs ?? 60000; // Default 60 second timeout
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Acquires a token for making an API request
|
|
1645
|
+
*
|
|
1646
|
+
* If a token is available, it is consumed immediately.
|
|
1647
|
+
* If no tokens are available, the request is queued and will resolve
|
|
1648
|
+
* when a token becomes available or reject if it times out.
|
|
1649
|
+
*
|
|
1650
|
+
* @throws {RateLimitError} If the request times out waiting for a token
|
|
1651
|
+
*
|
|
1652
|
+
* @example
|
|
1653
|
+
* ```typescript
|
|
1654
|
+
* try {
|
|
1655
|
+
* await limiter.acquire();
|
|
1656
|
+
* // Make API call
|
|
1657
|
+
* } catch (error) {
|
|
1658
|
+
* if (error instanceof RateLimitError) {
|
|
1659
|
+
* // Handle rate limit timeout
|
|
1660
|
+
* }
|
|
1661
|
+
* }
|
|
1662
|
+
* ```
|
|
1663
|
+
*/
|
|
1664
|
+
async acquire() {
|
|
1665
|
+
const logger = getLogger();
|
|
1666
|
+
this.refill();
|
|
1667
|
+
if (this.tokens > 0) {
|
|
1668
|
+
this.tokens--;
|
|
1669
|
+
logger.debug(`Rate limit token acquired for ${this.config.label}`, {
|
|
1670
|
+
remainingTokens: this.tokens,
|
|
1671
|
+
queueLength: this.queue.length,
|
|
1672
|
+
});
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
// No tokens available, queue the request
|
|
1676
|
+
logger.debug(`Rate limit reached for ${this.config.label}, queuing request`, {
|
|
1677
|
+
queueLength: this.queue.length,
|
|
1678
|
+
});
|
|
1679
|
+
return new Promise((resolve, reject) => {
|
|
1680
|
+
const timeoutHandle = setTimeout(() => {
|
|
1681
|
+
// Remove from queue on timeout
|
|
1682
|
+
const index = this.queue.findIndex((req) => req.timeoutHandle === timeoutHandle);
|
|
1683
|
+
if (index !== -1) {
|
|
1684
|
+
this.queue.splice(index, 1);
|
|
1685
|
+
}
|
|
1686
|
+
const error = new RateLimitError(`Rate limit timeout for ${this.config.label} after ${this.timeoutMs}ms`, this.config.label, undefined);
|
|
1687
|
+
logger.warn(`Rate limit timeout for ${this.config.label}`, {
|
|
1688
|
+
queueLength: this.queue.length,
|
|
1689
|
+
timeoutMs: this.timeoutMs,
|
|
1690
|
+
});
|
|
1691
|
+
reject(error);
|
|
1692
|
+
}, this.timeoutMs);
|
|
1693
|
+
this.queue.push({ resolve, reject, timeoutHandle });
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Refills tokens based on elapsed time and processes queued requests
|
|
1698
|
+
*
|
|
1699
|
+
* Tokens are refilled at the configured rate up to the maximum capacity.
|
|
1700
|
+
* If tokens are available after refilling, queued requests are processed.
|
|
1701
|
+
*/
|
|
1702
|
+
refill() {
|
|
1703
|
+
const now = Date.now();
|
|
1704
|
+
const elapsed = (now - this.lastRefill) / 1000; // Convert to seconds
|
|
1705
|
+
const tokensToAdd = elapsed * this.config.refillRate;
|
|
1706
|
+
this.tokens = Math.min(this.config.maxTokens, this.tokens + tokensToAdd);
|
|
1707
|
+
this.lastRefill = now;
|
|
1708
|
+
// Process queued requests if we have tokens
|
|
1709
|
+
this.processQueue();
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* Processes queued requests when tokens are available
|
|
1713
|
+
*
|
|
1714
|
+
* Prevents concurrent queue processing to ensure FIFO order.
|
|
1715
|
+
*/
|
|
1716
|
+
processQueue() {
|
|
1717
|
+
// Prevent concurrent queue processing
|
|
1718
|
+
if (this.processingQueue) {
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
this.processingQueue = true;
|
|
1722
|
+
const logger = getLogger();
|
|
1723
|
+
try {
|
|
1724
|
+
while (this.queue.length > 0 && this.tokens > 0) {
|
|
1725
|
+
this.tokens--;
|
|
1726
|
+
const next = this.queue.shift();
|
|
1727
|
+
if (next) {
|
|
1728
|
+
clearTimeout(next.timeoutHandle);
|
|
1729
|
+
next.resolve();
|
|
1730
|
+
logger.debug(`Processed queued request for ${this.config.label}`, {
|
|
1731
|
+
remainingTokens: this.tokens,
|
|
1732
|
+
remainingQueue: this.queue.length,
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
finally {
|
|
1738
|
+
this.processingQueue = false;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Gets the current number of available tokens
|
|
1743
|
+
*
|
|
1744
|
+
* @returns Number of tokens currently available
|
|
1745
|
+
*/
|
|
1746
|
+
getAvailableTokens() {
|
|
1747
|
+
this.refill();
|
|
1748
|
+
return Math.floor(this.tokens);
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Gets the current queue length
|
|
1752
|
+
*
|
|
1753
|
+
* @returns Number of requests waiting for tokens
|
|
1754
|
+
*/
|
|
1755
|
+
getQueueLength() {
|
|
1756
|
+
return this.queue.length;
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* Clears all queued requests and resets the token bucket
|
|
1760
|
+
*
|
|
1761
|
+
* All queued requests will be rejected with a RateLimitError.
|
|
1762
|
+
* Useful for cleanup or when changing rate limit configurations.
|
|
1763
|
+
*/
|
|
1764
|
+
reset() {
|
|
1765
|
+
const logger = getLogger();
|
|
1766
|
+
// Reject all queued requests
|
|
1767
|
+
for (const request of this.queue) {
|
|
1768
|
+
clearTimeout(request.timeoutHandle);
|
|
1769
|
+
request.reject(new RateLimitError(`Rate limiter reset for ${this.config.label}`, this.config.label, undefined));
|
|
1770
|
+
}
|
|
1771
|
+
this.queue = [];
|
|
1772
|
+
this.tokens = this.config.maxTokens;
|
|
1773
|
+
this.lastRefill = Date.now();
|
|
1774
|
+
this.processingQueue = false;
|
|
1775
|
+
logger.info(`Rate limiter reset for ${this.config.label}`, {
|
|
1776
|
+
maxTokens: this.config.maxTokens,
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* Pre-configured rate limiters for common external APIs
|
|
1782
|
+
*
|
|
1783
|
+
* These limiters are configured based on the documented rate limits
|
|
1784
|
+
* for each service. Adjust the configurations if you have different
|
|
1785
|
+
* tier access or if limits change.
|
|
1786
|
+
*
|
|
1787
|
+
* @example
|
|
1788
|
+
* ```typescript
|
|
1789
|
+
* import { rateLimiters } from '@adaptic/utils';
|
|
1790
|
+
*
|
|
1791
|
+
* // Use before making API calls
|
|
1792
|
+
* await rateLimiters.alpaca.acquire();
|
|
1793
|
+
* await rateLimiters.massive.acquire();
|
|
1794
|
+
* await rateLimiters.alphaVantage.acquire();
|
|
1795
|
+
* ```
|
|
1796
|
+
*/
|
|
1797
|
+
const rateLimiters = {
|
|
1798
|
+
/**
|
|
1799
|
+
* Alpaca API rate limiter
|
|
1800
|
+
*
|
|
1801
|
+
* Configured for 1000 requests per minute (paid tier).
|
|
1802
|
+
* The token bucket allows burst up to maxTokens, then refills at the steady rate.
|
|
1803
|
+
* See: https://alpaca.markets/docs/api-references/trading-api/#rate-limit
|
|
1804
|
+
*/
|
|
1805
|
+
alpaca: new TokenBucketRateLimiter({
|
|
1806
|
+
maxTokens: 1000,
|
|
1807
|
+
refillRate: 1000 / 60, // 1000 requests per 60 seconds (~16.67/sec)
|
|
1808
|
+
label: "alpaca",
|
|
1809
|
+
timeoutMs: 60000,
|
|
1810
|
+
}),
|
|
1811
|
+
/**
|
|
1812
|
+
* Massive.com API rate limiter
|
|
1813
|
+
*
|
|
1814
|
+
* Configured generously for paid unlimited tier. The bucket exists only as a
|
|
1815
|
+
* safety net against runaway loops — the 1000 token burst and 500/sec refill
|
|
1816
|
+
* should never be hit under normal operation. If the paid plan truly has no
|
|
1817
|
+
* hard limit, this just prevents accidental self-DDoS.
|
|
1818
|
+
*/
|
|
1819
|
+
massive: new TokenBucketRateLimiter({
|
|
1820
|
+
maxTokens: 1000,
|
|
1821
|
+
refillRate: 500, // 500 tokens/sec refill — effectively unlimited for paid tier
|
|
1822
|
+
label: "massive",
|
|
1823
|
+
timeoutMs: 30000,
|
|
1824
|
+
}),
|
|
1825
|
+
/**
|
|
1826
|
+
* AlphaVantage API rate limiter
|
|
1827
|
+
*
|
|
1828
|
+
* Configured for 5 requests per minute (free tier).
|
|
1829
|
+
* For premium tier (75/min), create a custom limiter:
|
|
1830
|
+
*
|
|
1831
|
+
* @example
|
|
1832
|
+
* ```typescript
|
|
1833
|
+
* const premiumAV = new TokenBucketRateLimiter({
|
|
1834
|
+
* maxTokens: 75,
|
|
1835
|
+
* refillRate: 75 / 60,
|
|
1836
|
+
* label: 'alphaVantage-premium',
|
|
1837
|
+
* timeoutMs: 60000,
|
|
1838
|
+
* });
|
|
1839
|
+
* ```
|
|
1840
|
+
*
|
|
1841
|
+
* See: https://www.alphavantage.co/premium/
|
|
1842
|
+
*/
|
|
1843
|
+
alphaVantage: new TokenBucketRateLimiter({
|
|
1844
|
+
maxTokens: 5,
|
|
1845
|
+
refillRate: 5 / 60, // 5 requests per 60 seconds (~0.083/sec)
|
|
1846
|
+
label: "alphaVantage",
|
|
1847
|
+
timeoutMs: 60000,
|
|
1848
|
+
}),
|
|
1849
|
+
};
|
|
1850
|
+
|
|
1485
1851
|
const log$1 = (message, options = { type: "info" }) => {
|
|
1486
1852
|
log$2(message, { ...options, source: "AlpacaMarketDataAPI" });
|
|
1487
1853
|
};
|
|
1488
1854
|
// Default settings for market data API
|
|
1489
1855
|
const DEFAULT_ADJUSTMENT = "all";
|
|
1490
|
-
|
|
1856
|
+
// Data feed tier. SIP is full US-market-consolidated feed (requires a paid
|
|
1857
|
+
// Alpaca market-data subscription). IEX is the free tier — single-exchange,
|
|
1858
|
+
// delayed. Engine deploys running on IEX-only accounts hit chronic HTTP 403
|
|
1859
|
+
// ("subscription does not permit querying recent SIP data") at ~100/min when
|
|
1860
|
+
// this defaulted to "sip", which saturated Railway's per-replica log ingest
|
|
1861
|
+
// and hid steady-state signal. The ALPACA_MARKET_DATA_FEED env var overrides
|
|
1862
|
+
// the default. Fall back to "iex" so the free tier is safe by default; LIVE
|
|
1863
|
+
// deployments with SIP entitlements set ALPACA_MARKET_DATA_FEED=sip in their
|
|
1864
|
+
// environment to restore full data access. Per-call overrides (the optional
|
|
1865
|
+
// `feed` argument on getOptions*/getLatestBars/etc.) still win.
|
|
1866
|
+
const DEFAULT_FEED = (process.env.ALPACA_MARKET_DATA_FEED ||
|
|
1867
|
+
"iex");
|
|
1491
1868
|
const DEFAULT_CURRENCY = "USD";
|
|
1492
1869
|
/**
|
|
1493
1870
|
* Singleton class for interacting with Alpaca Market Data API
|
|
@@ -1877,6 +2254,11 @@ class AlpacaMarketDataAPI extends EventEmitter {
|
|
|
1877
2254
|
}
|
|
1878
2255
|
});
|
|
1879
2256
|
}
|
|
2257
|
+
// Gate all Alpaca market-data calls through the shared 1000/min token
|
|
2258
|
+
// bucket so concurrent callers (bar fetches, quotes, options, snapshots)
|
|
2259
|
+
// can't overrun Alpaca's server-side rate limit. Prior to this, parallel
|
|
2260
|
+
// historical-bar fan-out produced ~125 server-side 429s per minute.
|
|
2261
|
+
await rateLimiters.alpaca.acquire();
|
|
1880
2262
|
const response = await fetch(url.toString(), {
|
|
1881
2263
|
method,
|
|
1882
2264
|
headers: this.headers,
|