@adaptic/utils 0.0.1002 → 0.0.1004

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -5,6 +5,7 @@ var dateFns = require('date-fns');
5
5
  var dateFnsTz = require('date-fns-tz');
6
6
  var require$$0$1 = require('events');
7
7
  var WebSocket = require('ws');
8
+ var node_crypto = require('node:crypto');
8
9
  var ms = require('ms');
9
10
  var require$$0$2 = require('fs');
10
11
  var require$$1 = require('path');
@@ -2405,6 +2406,19 @@ class DataFormatError extends AdapticUtilsError {
2405
2406
  * const result = await makeAlpacaApiCall();
2406
2407
  * ```
2407
2408
  */
2409
+ /** Number of milliseconds in one second, used for token-refill timing math. */
2410
+ const MS_PER_SECOND$1 = 1000;
2411
+ /**
2412
+ * Minimum delay (ms) for a scheduled queue wake-up. Guards against a `0`/`NaN`
2413
+ * delay when the token deficit rounds down, ensuring the timer always makes
2414
+ * forward progress rather than busy-looping on the event loop.
2415
+ */
2416
+ const MIN_WAKE_DELAY_MS = 1;
2417
+ /**
2418
+ * Number of whole tokens required to release a single queued request. The token
2419
+ * bucket consumes exactly one token per admitted request.
2420
+ */
2421
+ const TOKENS_PER_REQUEST = 1;
2408
2422
  /**
2409
2423
  * Token bucket rate limiter implementation
2410
2424
  *
@@ -2420,6 +2434,13 @@ class TokenBucketRateLimiter {
2420
2434
  queue = [];
2421
2435
  timeoutMs;
2422
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;
2423
2444
  /**
2424
2445
  * Creates a new rate limiter instance
2425
2446
  *
@@ -2493,8 +2514,48 @@ class TokenBucketRateLimiter {
2493
2514
  reject(error);
2494
2515
  }, this.timeoutMs);
2495
2516
  this.queue.push({ resolve, reject, timeoutHandle });
2517
+ // Ensure the queue is actively drained even if no further acquire() calls
2518
+ // arrive: schedule a wake-up to refill tokens and release this request.
2519
+ this.scheduleQueueWake();
2496
2520
  });
2497
2521
  }
2522
+ /**
2523
+ * Schedules a single wake-up timer that refills tokens and drains the queue.
2524
+ *
2525
+ * The delay is the time required to accrue the tokens still needed to release
2526
+ * the next queued request at the configured refill rate. Only one timer is
2527
+ * ever outstanding (guarded by {@link wakeTimer}); the timer is `unref`'d so
2528
+ * it never keeps the Node.js process alive on its own. When it fires it
2529
+ * refills, drains what it can, and re-arms itself if work remains.
2530
+ */
2531
+ scheduleQueueWake() {
2532
+ // A wake-up is already pending, or there is nothing to wake for.
2533
+ if (this.wakeTimer !== null || this.queue.length === 0) {
2534
+ return;
2535
+ }
2536
+ const tokensNeeded = Math.max(0, TOKENS_PER_REQUEST - this.tokens);
2537
+ const deficitMs = Math.max(MIN_WAKE_DELAY_MS, Math.ceil((tokensNeeded / this.config.refillRate) * MS_PER_SECOND$1));
2538
+ const timer = setTimeout(() => {
2539
+ this.wakeTimer = null;
2540
+ // refill() drains the queue via processQueue(); if requests remain
2541
+ // afterwards, processQueue() re-arms the wake-up.
2542
+ this.refill();
2543
+ }, deficitMs);
2544
+ // Do not let a pending rate-limiter wake-up keep the process alive.
2545
+ if (typeof timer.unref === "function") {
2546
+ timer.unref();
2547
+ }
2548
+ this.wakeTimer = timer;
2549
+ }
2550
+ /**
2551
+ * Clears any pending wake-up timer.
2552
+ */
2553
+ clearWakeTimer() {
2554
+ if (this.wakeTimer !== null) {
2555
+ clearTimeout(this.wakeTimer);
2556
+ this.wakeTimer = null;
2557
+ }
2558
+ }
2498
2559
  /**
2499
2560
  * Refills tokens based on elapsed time and processes queued requests
2500
2561
  *
@@ -2539,6 +2600,15 @@ class TokenBucketRateLimiter {
2539
2600
  finally {
2540
2601
  this.processingQueue = false;
2541
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
+ }
2542
2612
  }
2543
2613
  /**
2544
2614
  * Gets the current number of available tokens
@@ -2570,6 +2640,7 @@ class TokenBucketRateLimiter {
2570
2640
  clearTimeout(request.timeoutHandle);
2571
2641
  request.reject(new RateLimitError(`Rate limiter reset for ${this.config.label}`, this.config.label, undefined));
2572
2642
  }
2643
+ this.clearWakeTimer();
2573
2644
  this.queue = [];
2574
2645
  this.tokens = this.config.maxTokens;
2575
2646
  this.lastRefill = Date.now();
@@ -4319,6 +4390,46 @@ const limitPriceSlippagePercent100 = 0.1; // 0.1%
4319
4390
  const ORDER_PAGE_LIMIT = 500;
4320
4391
  /** Delay between order pagination pages to stay clear of rate limits. */
4321
4392
  const ORDER_PAGINATION_DELAY_MS = 300;
4393
+ /**
4394
+ * HTTP status at or above which a Multi-Status (207) sub-result is a failure.
4395
+ * Alpaca's bulk `DELETE /orders` and `DELETE /positions` endpoints return a 207
4396
+ * envelope whose top-level status is 2xx even when individual orders/positions
4397
+ * failed to cancel or close; each element carries its own per-item HTTP status.
4398
+ * Treating >= 300 as a failure lets the engine failsafe see partial failures
4399
+ * instead of recording a false success.
4400
+ */
4401
+ const HTTP_STATUS_MIN_ERROR = 300;
4402
+ /**
4403
+ * Prefix applied to engine-derived `client_order_id` idempotency keys so they
4404
+ * are visibly attributable in Alpaca's dashboard and can never collide with a
4405
+ * caller-supplied identifier.
4406
+ */
4407
+ const CLIENT_ORDER_ID_PREFIX = "adaptic-";
4408
+ /**
4409
+ * Number of leading hex characters of the SHA-256 digest retained in a derived
4410
+ * `client_order_id`. 32 hex chars = 128 bits of entropy (collision-negligible),
4411
+ * and keeps the full id (prefix + digest = 40 chars) within Alpaca's identifier
4412
+ * length limit.
4413
+ */
4414
+ const CLIENT_ORDER_ID_HASH_LENGTH = 32;
4415
+ /**
4416
+ * Idempotency window (ms) used when deriving a default `client_order_id`.
4417
+ *
4418
+ * A client request timeout followed by an automatic retry re-submits the SAME
4419
+ * logical order. Deriving the id from the order's semantic parameters plus the
4420
+ * current time-window bucket makes Alpaca reject the retried duplicate
4421
+ * (`client_order_id must be unique`) instead of double-filling. The window is
4422
+ * deliberately much larger than the 30s Alpaca request timeout so a full
4423
+ * timeout+retry sequence lands in the same bucket, while a genuinely new but
4424
+ * otherwise-identical order placed in a later window still receives a distinct
4425
+ * id.
4426
+ *
4427
+ * This derived default is a best-effort safety net; the guaranteed-idempotent
4428
+ * path is for the caller to pass an explicit `clientOrderId` tied to the
4429
+ * originating signal/decision id (which also permits legitimately-repeated
4430
+ * identical orders inside a single window).
4431
+ */
4432
+ const CLIENT_ORDER_ID_WINDOW_MS = 300_000;
4322
4433
  /**
4323
4434
  Websocket example
4324
4435
  const alpacaAPI = createAlpacaTradingAPI(credentials); // type AlpacaCredentials
@@ -4399,6 +4510,114 @@ class AlpacaTradingAPI {
4399
4510
  ? Math.round(price * 100) / 100
4400
4511
  : Math.round(price * 10000) / 10000;
4401
4512
  };
4513
+ /**
4514
+ * Derive a deterministic `client_order_id` from an order's semantic
4515
+ * parameters so that a client-timeout-triggered retry re-submits the SAME id
4516
+ * and Alpaca rejects the duplicate broker-side instead of double-filling.
4517
+ *
4518
+ * The id is stable for identical parameters within a single
4519
+ * {@link CLIENT_ORDER_ID_WINDOW_MS} bucket and scoped per account. Callers
4520
+ * that must place genuinely distinct yet otherwise-identical orders should
4521
+ * pass an explicit `clientOrderId` rather than relying on this default.
4522
+ *
4523
+ * @param parts - Ordered, stringifiable components uniquely describing the
4524
+ * order (e.g. order kind, symbol, side, quantity, price, intent).
4525
+ * @returns An Alpaca-safe `client_order_id` (prefix + truncated SHA-256 hex).
4526
+ */
4527
+ deriveClientOrderId(parts) {
4528
+ const windowBucket = Math.floor(Date.now() / CLIENT_ORDER_ID_WINDOW_MS);
4529
+ const material = [
4530
+ this.credentials.accountName,
4531
+ windowBucket,
4532
+ ...parts.map((part) => (part === undefined ? "" : String(part))),
4533
+ ].join("|");
4534
+ const digest = node_crypto.createHash("sha256")
4535
+ .update(material)
4536
+ .digest("hex")
4537
+ .slice(0, CLIENT_ORDER_ID_HASH_LENGTH);
4538
+ return `${CLIENT_ORDER_ID_PREFIX}${digest}`;
4539
+ }
4540
+ /**
4541
+ * Collect the human-readable failure entries from a bulk Multi-Status (207)
4542
+ * response body (`DELETE /orders`, `DELETE /positions`). Each element carries
4543
+ * its own per-item HTTP status; any element with status >=
4544
+ * {@link HTTP_STATUS_MIN_ERROR} is a failure the caller must be able to see.
4545
+ *
4546
+ * @param entries - Parsed 207 response array.
4547
+ * @returns One `"<identifier>:<status>"` string per failed entry.
4548
+ */
4549
+ collectMultiStatusFailures(entries) {
4550
+ return entries
4551
+ .filter((entry) => typeof entry.status === "number" &&
4552
+ entry.status >= HTTP_STATUS_MIN_ERROR)
4553
+ .map((entry) => `${entry.symbol ?? entry.id ?? "unknown"}:${entry.status}`);
4554
+ }
4555
+ /**
4556
+ * Flatten a single position with a marketable limit order, deriving the
4557
+ * closing side, intent, and slippage-adjusted limit price from the latest
4558
+ * quote. Throws when no usable quote/price is available for the symbol so the
4559
+ * caller can record a per-position failure via {@link Promise.allSettled}
4560
+ * without aborting the flatten of the remaining positions.
4561
+ *
4562
+ * @param position - The position to close.
4563
+ * @param quotesResponse - Latest quotes keyed by symbol.
4564
+ * @param extendedHours - Whether the closing order is an extended-hours order.
4565
+ */
4566
+ async closePositionWithLimitOrder(position, quotesResponse, extendedHours) {
4567
+ const quote = quotesResponse.quotes[position.symbol];
4568
+ if (!quote) {
4569
+ throw new Error(`No quote available for ${position.symbol}`);
4570
+ }
4571
+ const qty = Math.abs(parseFloat(position.qty));
4572
+ const side = position.side === "long" ? "sell" : "buy";
4573
+ const positionIntent = side === "sell" ? "sell_to_close" : "buy_to_close";
4574
+ // Use bid for sells, ask for buys.
4575
+ const currentPrice = side === "sell" ? quote.bp : quote.ap;
4576
+ if (!currentPrice) {
4577
+ throw new Error(`No valid price available for ${position.symbol}`);
4578
+ }
4579
+ const limitSlippagePercent1 = limitPriceSlippagePercent100 / 100;
4580
+ const limitPrice = side === "sell"
4581
+ ? this.roundPriceForAlpaca(currentPrice * (1 - limitSlippagePercent1)) // Sell slightly lower
4582
+ : this.roundPriceForAlpaca(currentPrice * (1 + limitSlippagePercent1)); // Buy slightly higher
4583
+ this.log(`Creating ${extendedHours ? "extended hours " : ""}limit order to close ${position.symbol} position: ${side} ${qty} shares at $${limitPrice.toFixed(2)}`, {
4584
+ symbol: position.symbol,
4585
+ });
4586
+ await this.createLimitOrder(position.symbol, qty, side, limitPrice, positionIntent, extendedHours);
4587
+ }
4588
+ /**
4589
+ * Flatten every supplied position independently and surface an aggregate
4590
+ * failure if any could not be closed. Positions are attempted concurrently
4591
+ * with {@link Promise.allSettled} so a data gap or broker rejection on one
4592
+ * symbol never silently prevents the others from being flattened.
4593
+ *
4594
+ * @param positions - Positions to flatten.
4595
+ * @param extendedHours - Whether the closing orders are extended-hours orders.
4596
+ * @throws Error listing every symbol that failed to flatten.
4597
+ */
4598
+ async flattenPositionsWithLimitOrders(positions, extendedHours) {
4599
+ const symbols = positions.map((position) => position.symbol);
4600
+ const quotesResponse = await marketDataAPI.getLatestQuotes(symbols);
4601
+ const results = await Promise.allSettled(positions.map((position) => this.closePositionWithLimitOrder(position, quotesResponse, extendedHours)));
4602
+ const failures = [];
4603
+ results.forEach((result, index) => {
4604
+ if (result.status === "rejected") {
4605
+ const symbol = positions[index]?.symbol ?? "unknown";
4606
+ const reason = result.reason instanceof Error
4607
+ ? result.reason.message
4608
+ : String(result.reason);
4609
+ failures.push(`${symbol}: ${reason}`);
4610
+ this.log(`Failed to close position ${symbol}: ${reason}`, {
4611
+ symbol,
4612
+ type: "error",
4613
+ });
4614
+ }
4615
+ });
4616
+ if (failures.length > 0) {
4617
+ throw new Error(`Failed to close ${failures.length} of ${positions.length} positions: ${failures.join("; ")}`);
4618
+ }
4619
+ this.log(`All positions closed: ${symbols.join(", ")}`);
4620
+ }
4402
4621
  handleAuthMessage(data) {
4403
4622
  if (data.status === "authorized") {
4404
4623
  this.authenticated = true;
@@ -4770,21 +4989,31 @@ class AlpacaTradingAPI {
4770
4989
  * @param position_intent (string) - the position intent of the order
4771
4990
  * @returns The created AlpacaOrder with order ID and details
4772
4991
  */
4773
- async createTrailingStop(symbol, qty, side, trailPercent100, position_intent) {
4992
+ async createTrailingStop(symbol, qty, side, trailPercent100, position_intent, clientOrderId) {
4774
4993
  this.log(`Creating trailing stop ${side.toUpperCase()} ${qty} shares for ${symbol} with trail percent ${trailPercent100}%`, {
4775
4994
  symbol,
4776
4995
  });
4996
+ const body = {
4997
+ symbol,
4998
+ qty: Math.abs(qty).toString(),
4999
+ side,
5000
+ position_intent,
5001
+ order_class: "simple",
5002
+ type: "trailing_stop",
5003
+ trail_percent: trailPercent100.toString(), // Already in decimal form (e.g., 4 for 4%)
5004
+ time_in_force: "gtc",
5005
+ client_order_id: clientOrderId ??
5006
+ this.deriveClientOrderId([
5007
+ "trailing_stop",
5008
+ symbol,
5009
+ side,
5010
+ position_intent,
5011
+ Math.abs(qty),
5012
+ trailPercent100,
5013
+ ]),
5014
+ };
4777
5015
  try {
4778
- const order = await this.makeRequest(`/orders`, "POST", {
4779
- symbol,
4780
- qty: Math.abs(qty),
4781
- side,
4782
- position_intent,
4783
- order_class: "simple",
4784
- type: "trailing_stop",
4785
- trail_percent: trailPercent100, // Already in decimal form (e.g., 4 for 4%)
4786
- time_in_force: "gtc",
4787
- });
5016
+ const order = await this.makeRequest(`/orders`, "POST", body);
4788
5017
  this.log(`Trailing stop order created for ${symbol}: orderId=${order.id}, trailPercent=${trailPercent100}%`, { symbol });
4789
5018
  return order;
4790
5019
  }
@@ -4816,9 +5045,15 @@ class AlpacaTradingAPI {
4816
5045
  time_in_force: "day",
4817
5046
  order_class: "simple",
4818
5047
  };
4819
- if (client_order_id !== undefined) {
4820
- body.client_order_id = client_order_id;
4821
- }
5048
+ body.client_order_id =
5049
+ client_order_id ??
5050
+ this.deriveClientOrderId([
5051
+ "market",
5052
+ symbol,
5053
+ side,
5054
+ position_intent,
5055
+ Math.abs(qty),
5056
+ ]);
4822
5057
  try {
4823
5058
  return await this.makeRequest("/orders", "POST", body);
4824
5059
  }
@@ -4919,16 +5154,31 @@ class AlpacaTradingAPI {
4919
5154
  }
4920
5155
  }
4921
5156
  /**
4922
- * Cancel all open orders
5157
+ * Cancel all open orders.
5158
+ *
5159
+ * Alpaca's bulk cancel returns a 207 Multi-Status body whose top-level status
5160
+ * is 2xx even when individual orders failed to cancel; this method inspects
5161
+ * the per-order statuses and throws if any order could not be canceled, so a
5162
+ * caller acting as a live-stop failsafe cannot record success while orders
5163
+ * remain live. Transport/HTTP errors propagate unchanged (matching the
5164
+ * throw-on-failure contract of {@link cancelOrder}).
5165
+ *
5166
+ * @throws Error if the bulk cancel request fails or any individual order
5167
+ * could not be canceled.
4923
5168
  */
4924
5169
  async cancelAllOrders() {
4925
5170
  this.log(`Canceling all open orders`);
4926
- try {
4927
- await this.makeRequest("/orders", "DELETE");
5171
+ const results = await this.makeRequest("/orders", "DELETE");
5172
+ if (!Array.isArray(results)) {
5173
+ return;
4928
5174
  }
4929
- catch (error) {
4930
- this.log(`Error canceling all orders: ${error}`, { type: "error" });
5175
+ const failures = this.collectMultiStatusFailures(results);
5176
+ if (failures.length > 0) {
5177
+ const detail = failures.join(", ");
5178
+ this.log(`Error canceling all orders: ${failures.length}/${results.length} orders failed to cancel (${detail})`, { type: "error" });
5179
+ throw new Error(`Failed to cancel ${failures.length} of ${results.length} orders: ${detail}`);
4931
5180
  }
5181
+ this.log(`Successfully canceled ${results.length} open orders`);
4932
5182
  }
4933
5183
  /**
4934
5184
  * Cancel a specific order by its ID
@@ -4979,9 +5229,17 @@ class AlpacaTradingAPI {
4979
5229
  order_class: "simple",
4980
5230
  extended_hours,
4981
5231
  };
4982
- if (client_order_id !== undefined) {
4983
- body.client_order_id = client_order_id;
4984
- }
5232
+ body.client_order_id =
5233
+ client_order_id ??
5234
+ this.deriveClientOrderId([
5235
+ "limit",
5236
+ symbol,
5237
+ side,
5238
+ position_intent,
5239
+ Math.abs(qty),
5240
+ this.roundPriceForAlpaca(limitPrice),
5241
+ extended_hours,
5242
+ ]);
4985
5243
  try {
4986
5244
  return await this.makeRequest("/orders", "POST", body);
4987
5245
  }
@@ -5009,55 +5267,21 @@ class AlpacaTradingAPI {
5009
5267
  return;
5010
5268
  }
5011
5269
  this.log(`Found ${positions.length} positions to close`);
5012
- // Get latest quotes for all positions
5013
- const symbols = positions.map((position) => position.symbol);
5014
- const quotesResponse = await marketDataAPI.getLatestQuotes(symbols);
5015
- const lengthOfQuotes = Object.keys(quotesResponse.quotes).length;
5016
- if (lengthOfQuotes === 0) {
5017
- this.log("No quotes available for positions, received 0 quotes", {
5018
- type: "error",
5019
- });
5020
- return;
5021
- }
5022
- if (lengthOfQuotes !== positions.length) {
5023
- this.log(`Received ${lengthOfQuotes} quotes for ${positions.length} positions, expected ${positions.length} quotes`, { type: "warn" });
5024
- return;
5025
- }
5026
- // Create limit orders to close each position
5027
- for (const position of positions) {
5028
- const quote = quotesResponse.quotes[position.symbol];
5029
- if (!quote) {
5030
- this.log(`No quote available for ${position.symbol}, skipping limit order`, {
5031
- symbol: position.symbol,
5032
- type: "warn",
5033
- });
5034
- continue;
5035
- }
5036
- const qty = Math.abs(parseFloat(position.qty));
5037
- const side = position.side === "long" ? "sell" : "buy";
5038
- const positionIntent = side === "sell" ? "sell_to_close" : "buy_to_close";
5039
- // Get the current price from the quote
5040
- const currentPrice = side === "sell" ? quote.bp : quote.ap; // Use bid for sells, ask for buys
5041
- if (!currentPrice) {
5042
- this.log(`No valid price available for ${position.symbol}, skipping limit order`, {
5043
- symbol: position.symbol,
5044
- type: "warn",
5045
- });
5046
- continue;
5047
- }
5048
- // Apply slippage from config
5049
- const limitSlippagePercent1 = limitPriceSlippagePercent100 / 100;
5050
- const limitPrice = side === "sell"
5051
- ? this.roundPriceForAlpaca(currentPrice * (1 - limitSlippagePercent1)) // Sell slightly lower
5052
- : this.roundPriceForAlpaca(currentPrice * (1 + limitSlippagePercent1)); // Buy slightly higher
5053
- this.log(`Creating limit order to close ${position.symbol} position: ${side} ${qty} shares at $${limitPrice.toFixed(2)}`, {
5054
- symbol: position.symbol,
5055
- });
5056
- await this.createLimitOrder(position.symbol, qty, side, limitPrice, positionIntent);
5057
- }
5270
+ // Flatten each position independently. A missing quote or broker
5271
+ // rejection on one symbol must never abort the flatten of the others; any
5272
+ // per-position failure is surfaced as an aggregate error.
5273
+ await this.flattenPositionsWithLimitOrders(positions, false);
5058
5274
  }
5059
5275
  else {
5060
- await this.makeRequest("/positions", "DELETE", undefined, options.cancel_orders ? "?cancel_orders=true" : "");
5276
+ const results = await this.makeRequest("/positions", "DELETE", undefined, options.cancel_orders ? "?cancel_orders=true" : "");
5277
+ if (Array.isArray(results)) {
5278
+ const failures = this.collectMultiStatusFailures(results);
5279
+ if (failures.length > 0) {
5280
+ const detail = failures.join(", ");
5281
+ this.log(`Error closing all positions: ${failures.length}/${results.length} positions failed to close (${detail})`, { type: "error" });
5282
+ throw new Error(`Failed to close ${failures.length} of ${results.length} positions: ${detail}`);
5283
+ }
5284
+ }
5061
5285
  }
5062
5286
  }
5063
5287
  /**
@@ -5074,44 +5298,22 @@ class AlpacaTradingAPI {
5074
5298
  this.log("No positions to close");
5075
5299
  return;
5076
5300
  }
5077
- await this.cancelAllOrders();
5078
- this.log(`Cancelled all open orders`);
5079
- // Get latest quotes for all positions
5080
- const symbols = positions.map((position) => position.symbol);
5081
- const quotesResponse = await marketDataAPI.getLatestQuotes(symbols);
5082
- // Create limit orders to close each position
5083
- for (const position of positions) {
5084
- const quote = quotesResponse.quotes[position.symbol];
5085
- if (!quote) {
5086
- this.log(`No quote available for ${position.symbol}, skipping limit order`, {
5087
- symbol: position.symbol,
5088
- type: "warn",
5089
- });
5090
- continue;
5091
- }
5092
- const qty = Math.abs(parseFloat(position.qty));
5093
- const side = position.side === "long" ? "sell" : "buy";
5094
- const positionIntent = side === "sell" ? "sell_to_close" : "buy_to_close";
5095
- // Get the current price from the quote
5096
- const currentPrice = side === "sell" ? quote.bp : quote.ap; // Use bid for sells, ask for buys
5097
- if (!currentPrice) {
5098
- this.log(`No valid price available for ${position.symbol}, skipping limit order`, {
5099
- symbol: position.symbol,
5100
- type: "warn",
5101
- });
5102
- continue;
5103
- }
5104
- // Apply slippage from config
5105
- const limitSlippagePercent1 = limitPriceSlippagePercent100 / 100;
5106
- const limitPrice = side === "sell"
5107
- ? this.roundPriceForAlpaca(currentPrice * (1 - limitSlippagePercent1)) // Sell slightly lower
5108
- : this.roundPriceForAlpaca(currentPrice * (1 + limitSlippagePercent1)); // Buy slightly higher
5109
- this.log(`Creating extended hours limit order to close ${position.symbol} position: ${side} ${qty} shares at $${limitPrice.toFixed(2)}`, {
5110
- symbol: position.symbol,
5111
- });
5112
- await this.createLimitOrder(position.symbol, qty, side, limitPrice, positionIntent, true);
5301
+ // Cancelling stale open orders is secondary to the primary failsafe goal of
5302
+ // flattening positions. A cancel failure is logged but must not abort the
5303
+ // flatten, otherwise a single un-cancelable order would leave every position
5304
+ // open. The flatten step below surfaces its own aggregate failure.
5305
+ try {
5306
+ await this.cancelAllOrders();
5307
+ this.log(`Cancelled all open orders`);
5113
5308
  }
5114
- this.log(`All positions closed: ${positions.map((p) => p.symbol).join(", ")}`);
5309
+ catch (error) {
5310
+ this.log(`Proceeding to flatten despite cancelAllOrders failure: ${error instanceof Error ? error.message : String(error)}`, { type: "error" });
5311
+ }
5312
+ // Flatten each position independently with extended-hours limit orders. A
5313
+ // missing quote or broker rejection on one symbol must never silently leave
5314
+ // the remaining positions open; per-position failures are surfaced as an
5315
+ // aggregate error.
5316
+ await this.flattenPositionsWithLimitOrders(positions, true);
5115
5317
  }
5116
5318
  onTradeUpdate(callback) {
5117
5319
  this.tradeUpdateCallback = callback;
@@ -5190,9 +5392,12 @@ class AlpacaTradingAPI {
5190
5392
  * @param position_intent Position intent (buy_to_open, buy_to_close, sell_to_open, sell_to_close)
5191
5393
  * @param type Order type (market or limit)
5192
5394
  * @param limitPrice Limit price (required for limit orders)
5395
+ * @param clientOrderId Optional idempotency key; a deterministic one is
5396
+ * derived from the order parameters when omitted so a client-timeout retry
5397
+ * is de-duplicated broker-side.
5193
5398
  * @returns The created order
5194
5399
  */
5195
- async createOptionOrder(symbol, qty, side, position_intent, type, limitPrice) {
5400
+ async createOptionOrder(symbol, qty, side, position_intent, type, limitPrice, clientOrderId) {
5196
5401
  if (!Number.isInteger(qty) || qty <= 0) {
5197
5402
  this.log("Quantity must be a positive whole number for option orders", {
5198
5403
  type: "error",
@@ -5217,6 +5422,19 @@ class AlpacaTradingAPI {
5217
5422
  if (type === "limit" && limitPrice !== undefined) {
5218
5423
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
5219
5424
  }
5425
+ orderData.client_order_id =
5426
+ clientOrderId ??
5427
+ this.deriveClientOrderId([
5428
+ "option",
5429
+ type,
5430
+ symbol,
5431
+ side,
5432
+ position_intent,
5433
+ qty,
5434
+ type === "limit" && limitPrice !== undefined
5435
+ ? this.roundPriceForAlpaca(limitPrice)
5436
+ : undefined,
5437
+ ]);
5220
5438
  return this.makeRequest("/orders", "POST", orderData);
5221
5439
  }
5222
5440
  /**
@@ -5225,9 +5443,12 @@ class AlpacaTradingAPI {
5225
5443
  * @param qty Quantity of the multi-leg order (must be a whole number)
5226
5444
  * @param type Order type (market or limit)
5227
5445
  * @param limitPrice Limit price (required for limit orders)
5446
+ * @param clientOrderId Optional idempotency key; a deterministic one is
5447
+ * derived from the legs and order parameters when omitted so a
5448
+ * client-timeout retry is de-duplicated broker-side.
5228
5449
  * @returns The created multi-leg order
5229
5450
  */
5230
- async createMultiLegOptionOrder(legs, qty, type, limitPrice) {
5451
+ async createMultiLegOptionOrder(legs, qty, type, limitPrice, clientOrderId) {
5231
5452
  if (!Number.isInteger(qty) || qty <= 0) {
5232
5453
  this.log("Quantity must be a positive whole number for option orders", {
5233
5454
  type: "error",
@@ -5253,6 +5474,17 @@ class AlpacaTradingAPI {
5253
5474
  if (type === "limit" && limitPrice !== undefined) {
5254
5475
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
5255
5476
  }
5477
+ orderData.client_order_id =
5478
+ clientOrderId ??
5479
+ this.deriveClientOrderId([
5480
+ "mleg",
5481
+ type,
5482
+ qty,
5483
+ type === "limit" && limitPrice !== undefined
5484
+ ? this.roundPriceForAlpaca(limitPrice)
5485
+ : undefined,
5486
+ ...legs.map((leg) => `${leg.symbol}:${leg.side}:${leg.ratio_qty}:${leg.position_intent}`),
5487
+ ]);
5256
5488
  return this.makeRequest("/orders", "POST", orderData);
5257
5489
  }
5258
5490
  /**
@@ -5730,9 +5962,26 @@ class AlpacaTradingAPI {
5730
5962
  extended_hours: extendedHours,
5731
5963
  position_intent: side === "buy" ? "buy_to_open" : "sell_to_open",
5732
5964
  };
5733
- if (clientOrderId) {
5734
- orderData.client_order_id = clientOrderId;
5735
- }
5965
+ orderData.client_order_id =
5966
+ clientOrderId ??
5967
+ this.deriveClientOrderId([
5968
+ "equities",
5969
+ orderClass,
5970
+ type,
5971
+ symbol,
5972
+ side,
5973
+ Math.abs(qty),
5974
+ type === "limit" && limitPrice !== undefined
5975
+ ? this.roundPriceForAlpaca(limitPrice)
5976
+ : undefined,
5977
+ extendedHours,
5978
+ useStopLoss && calculatedStopPrice !== undefined
5979
+ ? this.roundPriceForAlpaca(calculatedStopPrice)
5980
+ : undefined,
5981
+ useTakeProfit && calculatedTakeProfitPrice !== undefined
5982
+ ? this.roundPriceForAlpaca(calculatedTakeProfitPrice)
5983
+ : undefined,
5984
+ ]);
5736
5985
  // Add limit price for limit orders
5737
5986
  if (type === "limit" && limitPrice !== undefined) {
5738
5987
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
@@ -8408,6 +8657,13 @@ var atrNs = /*#__PURE__*/Object.freeze({
8408
8657
  });
8409
8658
 
8410
8659
  const ALPACA_API_BASE = MARKET_DATA_API.CRYPTO;
8660
+ /**
8661
+ * Hard upper bound on the number of paginated news pages fetched in a single
8662
+ * {@link fetchNews} call. Acts as a runaway-loop backstop that is independent of
8663
+ * the caller-supplied `limit`, mirroring the max-page guard the equities
8664
+ * paginator already enforces.
8665
+ */
8666
+ const MAX_NEWS_PAGES = 100;
8411
8667
  /**
8412
8668
  * Fetches cryptocurrency bars for the specified parameters.
8413
8669
  * This function retrieves historical price data for multiple cryptocurrencies.
@@ -8507,17 +8763,25 @@ async function fetchNews(params, auth) {
8507
8763
  include_content: includeContent.toString(),
8508
8764
  limit: limit.toString(),
8509
8765
  });
8510
- const url = `${ALPACA_API_BASE}/news?${queryParams}`;
8511
- logIfDebug(`Fetching news from: ${url}`);
8512
- let newsArticles = [];
8766
+ const authHeaders = {
8767
+ "APCA-API-KEY-ID": auth.APIKey,
8768
+ "APCA-API-SECRET-KEY": auth.APISecret,
8769
+ };
8770
+ const newsArticles = [];
8513
8771
  let pageToken = null;
8514
- let hasMorePages = true;
8515
- while (hasMorePages) {
8772
+ let pageCount = 0;
8773
+ while (pageCount < MAX_NEWS_PAGES) {
8774
+ // Rebuild the request URL on every iteration so the pagination cursor is
8775
+ // actually applied. Using `set` (not `append`) overwrites the previous
8776
+ // cursor instead of accumulating stale `page_token` values across pages.
8516
8777
  if (pageToken) {
8517
- queryParams.append("page_token", pageToken);
8778
+ queryParams.set("page_token", pageToken);
8518
8779
  }
8780
+ const url = `${ALPACA_API_BASE}/news?${queryParams.toString()}`;
8781
+ logIfDebug(`Fetching news from: ${url}`);
8519
8782
  await withRetry(async () => {
8520
8783
  const response = await fetch(url, {
8784
+ headers: authHeaders,
8521
8785
  signal: createTimeoutSignal(DEFAULT_TIMEOUTS.ALPACA_API),
8522
8786
  });
8523
8787
  if (!response.ok) {
@@ -8525,7 +8789,7 @@ async function fetchNews(params, auth) {
8525
8789
  throw new Error(`Alpaca API error (${response.status}): ${errorText}`);
8526
8790
  }
8527
8791
  const data = await response.json();
8528
- newsArticles = newsArticles.concat(data.news.map((article) => ({
8792
+ const pageArticles = (data.news ?? []).map((article) => ({
8529
8793
  id: article.id,
8530
8794
  author: article.author,
8531
8795
  content: article.content,
@@ -8537,11 +8801,17 @@ async function fetchNews(params, auth) {
8537
8801
  url: article.url,
8538
8802
  symbols: article.symbols,
8539
8803
  images: article.images,
8540
- })));
8804
+ }));
8805
+ newsArticles.push(...pageArticles);
8541
8806
  pageToken = data.next_page_token ?? null;
8542
- hasMorePages = !!pageToken;
8543
- logIfDebug(`Received ${data.news.length} news articles. More pages: ${hasMorePages}`);
8807
+ logIfDebug(`Received ${pageArticles.length} news articles. Next page token: ${pageToken ? "present" : "none"}`);
8544
8808
  }, API_RETRY_CONFIGS.CRYPTO, `Crypto.fetchNews(${symbol})`);
8809
+ pageCount++;
8810
+ // Terminate once the API reports no further pages or once we have
8811
+ // accumulated at least the requested number of articles.
8812
+ if (!pageToken || newsArticles.length >= limit) {
8813
+ break;
8814
+ }
8545
8815
  }
8546
8816
  // If sort is "asc" and limit is 10, return only the 10 most recent articles
8547
8817
  if (sort === "asc" && limit === 10) {
@@ -9758,33 +10028,138 @@ const formatIndicesBarData = (data) => {
9758
10028
  };
9759
10029
 
9760
10030
  // price-utils.ts
10031
+ // ---------------------------------------------------------------------------
10032
+ // Transaction-cost (fee) model
10033
+ //
10034
+ // Alpaca's REST order object does not expose the realized per-order fee, so the
10035
+ // transaction cost is reconstructed from the published fee schedules, branching
10036
+ // on the order's ACTUAL asset class (never a hardcoded STOCK). Every rate is a
10037
+ // named constant sourced from Alpaca / SEC / FINRA public schedules (2024-2025)
10038
+ // so it can be audited and updated in one place.
10039
+ // ---------------------------------------------------------------------------
10040
+ /** Basis points in one whole unit (1 = 10,000 bps). */
10041
+ const BPS_PER_UNIT = 10_000;
10042
+ /** Shares represented by one US listed option contract. */
10043
+ const OPTIONS_CONTRACT_MULTIPLIER = 100;
10044
+ /**
10045
+ * SEC Section 31 fee, charged on the principal of SELL orders for equities and
10046
+ * options. FY2024+ rate: USD 8.00 per USD 1,000,000 of principal.
10047
+ */
10048
+ const SEC_SECTION31_FEE_PER_USD = 8.0 / 1_000_000;
10049
+ /** FINRA Trading Activity Fee (TAF) for equity sells: USD per share sold. */
10050
+ const FINRA_TAF_EQUITY_PER_SHARE = 0.000166;
10051
+ /** FINRA TAF for option sells: USD per contract sold. */
10052
+ const FINRA_TAF_OPTIONS_PER_CONTRACT = 0.00279;
10053
+ /** FINRA TAF is capped per trade regardless of size. */
10054
+ const FINRA_TAF_MAX_PER_TRADE = 8.3;
10055
+ /** OCC clearing fee per option contract, capped per trade. */
10056
+ const OCC_CLEARING_FEE_PER_CONTRACT = 0.02;
10057
+ const OCC_CLEARING_FEE_MAX_PER_TRADE = 55.0;
10058
+ /**
10059
+ * Options Regulatory Fee (ORF) pass-through, charged on both sides, USD per
10060
+ * contract. Published, exchange-set pass-through rate.
10061
+ */
10062
+ const OPTIONS_REGULATORY_FEE_PER_CONTRACT = 0.02685;
10063
+ /**
10064
+ * Alpaca crypto TAKER fee schedule as `[minTrailing30dVolumeUsd, takerBps]`,
10065
+ * ordered ascending by volume threshold. Market orders are takers; absent a
10066
+ * known trailing-30-day volume we conservatively select the tier-1 (highest)
10067
+ * taker rate. Source: Alpaca Crypto fee schedule.
10068
+ */
10069
+ const ALPACA_CRYPTO_TAKER_FEE_TIERS_BPS = [
10070
+ [0, 25],
10071
+ [100_000, 22],
10072
+ [500_000, 20],
10073
+ [1_000_000, 18],
10074
+ [10_000_000, 15],
10075
+ [25_000_000, 13],
10076
+ [50_000_000, 12],
10077
+ [100_000_000, 10],
10078
+ ];
10079
+ /**
10080
+ * Resolve the applicable Alpaca crypto taker fee (in bps) for a trailing
10081
+ * 30-day USD volume. Defaults to the tier-1 rate when the volume is unknown.
10082
+ * @param trailing30dVolumeUsd - Trailing 30-day traded notional in USD.
10083
+ * @returns The taker fee in basis points.
10084
+ */
10085
+ function resolveCryptoTakerBps(trailing30dVolumeUsd) {
10086
+ let bps = ALPACA_CRYPTO_TAKER_FEE_TIERS_BPS[0][1];
10087
+ for (const [threshold, tierBps] of ALPACA_CRYPTO_TAKER_FEE_TIERS_BPS) {
10088
+ if (trailing30dVolumeUsd >= threshold) {
10089
+ bps = tierBps;
10090
+ }
10091
+ else {
10092
+ break;
10093
+ }
10094
+ }
10095
+ return bps;
10096
+ }
10097
+ /**
10098
+ * Computes the realized transaction cost (fees + regulatory charges) for the
10099
+ * Alpaca order backing a single {@link types.Action}, branching on the order's
10100
+ * actual asset class. Returns 0 only when there is genuinely no order to price
10101
+ * (no linked order id, order not found, or nothing filled) — never as a
10102
+ * fabricated success.
10103
+ * @param action - The action whose linked Alpaca order should be priced.
10104
+ * @param trade - The parent trade (supplies the Alpaca account id).
10105
+ * @param alpacaAccount - The Alpaca account supplying broker credentials.
10106
+ * @returns The total fee in account currency (USD).
10107
+ */
9761
10108
  const calculateFees = async (action, trade, alpacaAccount) => {
9762
- let fee = 0;
9763
10109
  const alpacaOrderId = action.alpacaOrderId;
9764
10110
  if (!alpacaOrderId)
9765
- return fee;
10111
+ return 0;
9766
10112
  const order = await getOrder$1({
9767
10113
  adapticAccountId: trade.alpacaAccountId,
9768
10114
  alpacaApiKey: alpacaAccount.APIKey,
9769
10115
  alpacaApiSecret: alpacaAccount.APISecret,
9770
10116
  }, alpacaOrderId);
9771
10117
  if (!order)
9772
- return fee;
9773
- const assetType = "STOCK";
9774
- Number(order.qty) || 0;
9775
- order.notional || 0;
9776
- Number(order.filled_avg_price || order.limit_price || order.stop_price) ||
10118
+ return 0;
10119
+ const filledQty = Number(order.filled_qty) || 0;
10120
+ const filledPrice = Number(order.filled_avg_price ?? order.limit_price ?? order.stop_price) ||
9777
10121
  0;
9778
- switch (assetType) {
9779
- case "STOCK":
9780
- // Currently zero fees for stocks via Alpaca
9781
- fee = 0;
9782
- break;
9783
- default:
9784
- fee = 0;
9785
- break;
10122
+ // Realized notional prefers the actual fill (qty * avg price); it falls back
10123
+ // to the order's notional field for dollar-notional (fractional) orders.
10124
+ const notional = filledQty > 0 && filledPrice > 0
10125
+ ? filledQty * filledPrice
10126
+ : Number(order.notional ?? 0) || 0;
10127
+ if (notional <= 0)
10128
+ return 0;
10129
+ const isSell = order.side === "sell";
10130
+ switch (order.asset_class) {
10131
+ case "crypto": {
10132
+ // Crypto fees are bps of notional. Without a known 30-day volume we use
10133
+ // the conservative tier-1 taker rate.
10134
+ const takerBps = resolveCryptoTakerBps(0);
10135
+ return (notional * takerBps) / BPS_PER_UNIT;
10136
+ }
10137
+ case "us_option": {
10138
+ const contracts = filledQty > 0 ? filledQty : Number(order.qty) || 0;
10139
+ const occFee = Math.min(contracts * OCC_CLEARING_FEE_PER_CONTRACT, OCC_CLEARING_FEE_MAX_PER_TRADE);
10140
+ const orfFee = contracts * OPTIONS_REGULATORY_FEE_PER_CONTRACT;
10141
+ let fee = occFee + orfFee;
10142
+ if (isSell) {
10143
+ // Option premium is quoted per share; SEC fee applies to the full
10144
+ // principal (premium * contract multiplier).
10145
+ const optionPrincipal = notional * OPTIONS_CONTRACT_MULTIPLIER;
10146
+ const secFee = optionPrincipal * SEC_SECTION31_FEE_PER_USD;
10147
+ const taf = Math.min(contracts * FINRA_TAF_OPTIONS_PER_CONTRACT, FINRA_TAF_MAX_PER_TRADE);
10148
+ fee += secFee + taf;
10149
+ }
10150
+ return fee;
10151
+ }
10152
+ case "us_equity":
10153
+ default: {
10154
+ // Alpaca charges USD 0 commission on US equities; only sell-side
10155
+ // regulatory charges (SEC Section 31 + FINRA TAF) apply.
10156
+ if (!isSell)
10157
+ return 0;
10158
+ const secFee = notional * SEC_SECTION31_FEE_PER_USD;
10159
+ const taf = Math.min(filledQty * FINRA_TAF_EQUITY_PER_SHARE, FINRA_TAF_MAX_PER_TRADE);
10160
+ return secFee + taf;
10161
+ }
9786
10162
  }
9787
- return fee;
9788
10163
  };
9789
10164
  const computeTotalFees = async (trade) => {
9790
10165
  let totalFees = 0;
@@ -10653,16 +11028,76 @@ async function calculateExpenseRatio({ accountId, client, alpacaAccount, }) {
10653
11028
  return "N/A";
10654
11029
  }
10655
11030
  const equity = parseFloat(accountDetails.equity);
10656
- // Fetch portfolio expenses from your system (Assuming you have this data)
10657
- const expenses = await getPortfolioExpensesFromYourSystem();
10658
- // Calculate expense ratio
11031
+ // Fetch the account's real trailing fee expenses from Alpaca account
11032
+ // activities. A genuine data-source failure yields "N/A" (unknown) rather
11033
+ // than a fabricated 0.00%.
11034
+ const auth = {
11035
+ adapticAccountId: alpacaAccountId,
11036
+ alpacaApiKey: alpacaAccount?.APIKey,
11037
+ alpacaApiSecret: alpacaAccount?.APISecret,
11038
+ };
11039
+ let expenses;
11040
+ try {
11041
+ expenses = await fetchTrailingFeeExpenses(auth);
11042
+ }
11043
+ catch (error) {
11044
+ getLogger().warn("Failed to fetch Alpaca account fee activities for expense ratio.", { error });
11045
+ return "N/A";
11046
+ }
11047
+ // Calculate expense ratio (trailing fees as a percentage of current equity).
10659
11048
  const expenseRatio = (expenses / equity) * 100;
10660
11049
  return `${expenseRatio.toFixed(2)}%`;
10661
11050
  }
10662
- // Mock function to represent fetching expenses from your system
10663
- async function getPortfolioExpensesFromYourSystem(_accountId) {
10664
- // Implement this function based on your data storage
10665
- return 0; // Placeholder
11051
+ /** Trailing window over which account fees are aggregated for the expense ratio. */
11052
+ const EXPENSE_TRAILING_WINDOW_DAYS = 365;
11053
+ /** Milliseconds in one day. */
11054
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
11055
+ /** Alpaca account-activity types that represent fees/regulatory charges. */
11056
+ const FEE_ACTIVITY_TYPES = "FEE,REG,CFEE";
11057
+ /** Page size for the paginated Alpaca account-activities endpoint. */
11058
+ const ACTIVITIES_PAGE_SIZE = 100;
11059
+ /** Hard cap on activity pages to bound pagination on unexpected responses. */
11060
+ const ACTIVITIES_MAX_PAGES = 1000;
11061
+ /**
11062
+ * Aggregates the account's fee/regulatory charges over the trailing window from
11063
+ * the Alpaca account-activities endpoint, following id-based pagination.
11064
+ * @param auth - Alpaca authentication (account id and/or direct API keys).
11065
+ * @returns Total fees in account currency (USD) as a positive number.
11066
+ */
11067
+ async function fetchTrailingFeeExpenses(auth) {
11068
+ const after = new Date(Date.now() - EXPENSE_TRAILING_WINDOW_DAYS * MS_PER_DAY).toISOString();
11069
+ let total = 0;
11070
+ let pageToken;
11071
+ for (let page = 0; page < ACTIVITIES_MAX_PAGES; page++) {
11072
+ const queryParams = new URLSearchParams({
11073
+ activity_types: FEE_ACTIVITY_TYPES,
11074
+ after,
11075
+ page_size: String(ACTIVITIES_PAGE_SIZE),
11076
+ });
11077
+ if (pageToken) {
11078
+ queryParams.append("page_token", pageToken);
11079
+ }
11080
+ const activities = await makeRequest(auth, {
11081
+ endpoint: "/account/activities",
11082
+ method: "GET",
11083
+ queryString: `?${queryParams.toString()}`,
11084
+ });
11085
+ if (!Array.isArray(activities) || activities.length === 0) {
11086
+ break;
11087
+ }
11088
+ for (const activity of activities) {
11089
+ const amount = parseFloat(activity.net_amount ?? "");
11090
+ if (Number.isFinite(amount)) {
11091
+ // Fee entries are debits (negative net_amount); accumulate magnitude.
11092
+ total += Math.abs(amount);
11093
+ }
11094
+ }
11095
+ if (activities.length < ACTIVITIES_PAGE_SIZE) {
11096
+ break;
11097
+ }
11098
+ pageToken = activities[activities.length - 1].id;
11099
+ }
11100
+ return total;
10666
11101
  }
10667
11102
  /**
10668
11103
  * Calculates the liquidity ratio for a given Alpaca account.
@@ -11322,6 +11757,58 @@ async function calculateInformationRatio(portfolioHistory, benchmarkBars) {
11322
11757
  }
11323
11758
  return informationRatio.toFixed(4);
11324
11759
  }
11760
+ /**
11761
+ * Maps a portfolio-history timeframe token to the Alpaca market-data
11762
+ * {@link TimeFrame} accepted by the historical-bars endpoint. Benchmark
11763
+ * comparison is daily by default when no timeframe is supplied.
11764
+ * @param timeframe - The portfolio-history timeframe token.
11765
+ * @returns The equivalent Alpaca market-data timeframe.
11766
+ */
11767
+ function toAlpacaTimeFrame(timeframe) {
11768
+ switch (timeframe) {
11769
+ case "1Min":
11770
+ return "1Min";
11771
+ case "5Min":
11772
+ return "5Min";
11773
+ case "15Min":
11774
+ return "15Min";
11775
+ case "1H":
11776
+ return "1Hour";
11777
+ case "1D":
11778
+ return "1Day";
11779
+ default:
11780
+ return "1Day";
11781
+ }
11782
+ }
11783
+ /** Milliseconds per second, for RFC-3339 → Unix-second conversion. */
11784
+ const MS_PER_SECOND = 1000;
11785
+ /**
11786
+ * Fetches benchmark OHLCV bars from the wrapped Alpaca market-data vendor and
11787
+ * maps them into {@link BenchmarkBar}s (Unix-second timestamp + close price)
11788
+ * expected by the alpha/beta/information-ratio calculators.
11789
+ * @param request - Benchmark symbol, RFC-3339 start/end, and timeframe token.
11790
+ * @returns The benchmark bars, sorted ascending by time; empty if none.
11791
+ */
11792
+ async function fetchBenchmarkBars(request) {
11793
+ const { symbol, start, end, timeframe } = request;
11794
+ const response = await marketDataAPI.getHistoricalBars({
11795
+ symbols: [symbol],
11796
+ timeframe: toAlpacaTimeFrame(timeframe),
11797
+ start,
11798
+ end,
11799
+ sort: "asc",
11800
+ });
11801
+ const bars = response.bars[symbol];
11802
+ if (!Array.isArray(bars) || bars.length === 0) {
11803
+ return [];
11804
+ }
11805
+ return bars
11806
+ .map((bar) => ({
11807
+ t: Math.floor(new Date(bar.t).getTime() / MS_PER_SECOND),
11808
+ c: bar.c,
11809
+ }))
11810
+ .filter((bar) => Number.isFinite(bar.t) && Number.isFinite(bar.c));
11811
+ }
11325
11812
  /**
11326
11813
  * Fetches performance metrics for a given Alpaca account.
11327
11814
  * @param params - The parameters for fetching performance metrics.
@@ -11386,7 +11873,10 @@ async function fetchPerformanceMetrics({ params, client, accountId, alpacaAccoun
11386
11873
  getLogger().error("[fetchPerformanceMetrics] Error fetching portfolio history:", error);
11387
11874
  throw new Error("Failed to retrieve portfolio history data");
11388
11875
  }
11389
- // Fetch benchmark data with enhanced error handling
11876
+ // Fetch benchmark data directly from the wrapped Alpaca market-data vendor.
11877
+ // (Previously this hit a relative "/api/market-data/historical-prices"
11878
+ // Next.js route that only resolves in a browser; in a Node/engine runtime
11879
+ // the relative fetch always threw, silently zeroing out alpha/beta/IR.)
11390
11880
  const benchmarkSymbol = "SPY";
11391
11881
  let benchmarkBars = [];
11392
11882
  try {
@@ -11397,24 +11887,16 @@ async function fetchPerformanceMetrics({ params, client, accountId, alpacaAccoun
11397
11887
  : params?.period
11398
11888
  ? params?.period
11399
11889
  : "1Y",
11400
- outputFormat: "unix-ms",
11890
+ outputFormat: "iso",
11401
11891
  intraday_reporting: params?.intraday_reporting,
11402
11892
  });
11403
- const response = await fetch(`/api/market-data/historical-prices?symbol=${benchmarkSymbol}&start=${start.toString()}&end=${end.toString()}&timeframe=${params.timeframe}`, {
11404
- method: "GET",
11405
- headers: {
11406
- "Content-Type": "application/json",
11407
- },
11408
- signal: createTimeoutSignal(DEFAULT_TIMEOUTS.GENERAL),
11893
+ benchmarkBars = await fetchBenchmarkBars({
11894
+ symbol: benchmarkSymbol,
11895
+ start: String(start),
11896
+ end: String(end),
11897
+ timeframe: params.timeframe,
11409
11898
  });
11410
- if (!response.ok) {
11411
- const errorText = await response.text();
11412
- throw new Error(`Failed to fetch benchmark data: ${response.statusText} - ${errorText}`);
11413
- }
11414
- benchmarkBars = await response.json();
11415
- if (!benchmarkBars ||
11416
- !Array.isArray(benchmarkBars) ||
11417
- benchmarkBars.length === 0) {
11899
+ if (benchmarkBars.length === 0) {
11418
11900
  throw new Error("Received empty or invalid benchmark data");
11419
11901
  }
11420
11902
  }
@@ -14081,17 +14563,17 @@ var hasRequiredBrowser;
14081
14563
  function requireBrowser () {
14082
14564
  if (hasRequiredBrowser) return browser.exports;
14083
14565
  hasRequiredBrowser = 1;
14084
- (function (module, exports) {
14566
+ (function (module, exports$1) {
14085
14567
  /**
14086
14568
  * This is the web browser implementation of `debug()`.
14087
14569
  */
14088
14570
 
14089
- exports.formatArgs = formatArgs;
14090
- exports.save = save;
14091
- exports.load = load;
14092
- exports.useColors = useColors;
14093
- exports.storage = localstorage();
14094
- exports.destroy = (() => {
14571
+ exports$1.formatArgs = formatArgs;
14572
+ exports$1.save = save;
14573
+ exports$1.load = load;
14574
+ exports$1.useColors = useColors;
14575
+ exports$1.storage = localstorage();
14576
+ exports$1.destroy = (() => {
14095
14577
  let warned = false;
14096
14578
 
14097
14579
  return () => {
@@ -14106,7 +14588,7 @@ function requireBrowser () {
14106
14588
  * Colors.
14107
14589
  */
14108
14590
 
14109
- exports.colors = [
14591
+ exports$1.colors = [
14110
14592
  '#0000CC',
14111
14593
  '#0000FF',
14112
14594
  '#0033CC',
@@ -14271,7 +14753,7 @@ function requireBrowser () {
14271
14753
  *
14272
14754
  * @api public
14273
14755
  */
14274
- exports.log = console.debug || console.log || (() => {});
14756
+ exports$1.log = console.debug || console.log || (() => {});
14275
14757
 
14276
14758
  /**
14277
14759
  * Save `namespaces`.
@@ -14282,9 +14764,9 @@ function requireBrowser () {
14282
14764
  function save(namespaces) {
14283
14765
  try {
14284
14766
  if (namespaces) {
14285
- exports.storage.setItem('debug', namespaces);
14767
+ exports$1.storage.setItem('debug', namespaces);
14286
14768
  } else {
14287
- exports.storage.removeItem('debug');
14769
+ exports$1.storage.removeItem('debug');
14288
14770
  }
14289
14771
  } catch (error) {
14290
14772
  // Swallow
@@ -14301,7 +14783,7 @@ function requireBrowser () {
14301
14783
  function load() {
14302
14784
  let r;
14303
14785
  try {
14304
- r = exports.storage.getItem('debug') || exports.storage.getItem('DEBUG') ;
14786
+ r = exports$1.storage.getItem('debug') || exports$1.storage.getItem('DEBUG') ;
14305
14787
  } catch (error) {
14306
14788
  // Swallow
14307
14789
  // XXX (@Qix-) should we be logging these?
@@ -14337,7 +14819,7 @@ function requireBrowser () {
14337
14819
  }
14338
14820
  }
14339
14821
 
14340
- module.exports = requireCommon()(exports);
14822
+ module.exports = requireCommon()(exports$1);
14341
14823
 
14342
14824
  const {formatters} = module.exports;
14343
14825
 
@@ -14526,7 +15008,7 @@ var hasRequiredNode$1;
14526
15008
  function requireNode$1 () {
14527
15009
  if (hasRequiredNode$1) return node$1.exports;
14528
15010
  hasRequiredNode$1 = 1;
14529
- (function (module, exports) {
15011
+ (function (module, exports$1) {
14530
15012
  const tty = require$$1$1;
14531
15013
  const util = require$$1$2;
14532
15014
 
@@ -14534,13 +15016,13 @@ function requireNode$1 () {
14534
15016
  * This is the Node.js implementation of `debug()`.
14535
15017
  */
14536
15018
 
14537
- exports.init = init;
14538
- exports.log = log;
14539
- exports.formatArgs = formatArgs;
14540
- exports.save = save;
14541
- exports.load = load;
14542
- exports.useColors = useColors;
14543
- exports.destroy = util.deprecate(
15019
+ exports$1.init = init;
15020
+ exports$1.log = log;
15021
+ exports$1.formatArgs = formatArgs;
15022
+ exports$1.save = save;
15023
+ exports$1.load = load;
15024
+ exports$1.useColors = useColors;
15025
+ exports$1.destroy = util.deprecate(
14544
15026
  () => {},
14545
15027
  'Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`.'
14546
15028
  );
@@ -14549,7 +15031,7 @@ function requireNode$1 () {
14549
15031
  * Colors.
14550
15032
  */
14551
15033
 
14552
- exports.colors = [6, 2, 3, 4, 5, 1];
15034
+ exports$1.colors = [6, 2, 3, 4, 5, 1];
14553
15035
 
14554
15036
  try {
14555
15037
  // Optional dependency (as in, doesn't need to be installed, NOT like optionalDependencies in package.json)
@@ -14557,7 +15039,7 @@ function requireNode$1 () {
14557
15039
  const supportsColor = requireSupportsColor();
14558
15040
 
14559
15041
  if (supportsColor && (supportsColor.stderr || supportsColor).level >= 2) {
14560
- exports.colors = [
15042
+ exports$1.colors = [
14561
15043
  20,
14562
15044
  21,
14563
15045
  26,
@@ -14646,7 +15128,7 @@ function requireNode$1 () {
14646
15128
  * $ DEBUG_COLORS=no DEBUG_DEPTH=10 DEBUG_SHOW_HIDDEN=enabled node script.js
14647
15129
  */
14648
15130
 
14649
- exports.inspectOpts = Object.keys(process.env).filter(key => {
15131
+ exports$1.inspectOpts = Object.keys(process.env).filter(key => {
14650
15132
  return /^debug_/i.test(key);
14651
15133
  }).reduce((obj, key) => {
14652
15134
  // Camel-case
@@ -14678,8 +15160,8 @@ function requireNode$1 () {
14678
15160
  */
14679
15161
 
14680
15162
  function useColors() {
14681
- return 'colors' in exports.inspectOpts ?
14682
- Boolean(exports.inspectOpts.colors) :
15163
+ return 'colors' in exports$1.inspectOpts ?
15164
+ Boolean(exports$1.inspectOpts.colors) :
14683
15165
  tty.isatty(process.stderr.fd);
14684
15166
  }
14685
15167
 
@@ -14705,7 +15187,7 @@ function requireNode$1 () {
14705
15187
  }
14706
15188
 
14707
15189
  function getDate() {
14708
- if (exports.inspectOpts.hideDate) {
15190
+ if (exports$1.inspectOpts.hideDate) {
14709
15191
  return '';
14710
15192
  }
14711
15193
  return new Date().toISOString() + ' ';
@@ -14716,7 +15198,7 @@ function requireNode$1 () {
14716
15198
  */
14717
15199
 
14718
15200
  function log(...args) {
14719
- return process.stderr.write(util.formatWithOptions(exports.inspectOpts, ...args) + '\n');
15201
+ return process.stderr.write(util.formatWithOptions(exports$1.inspectOpts, ...args) + '\n');
14720
15202
  }
14721
15203
 
14722
15204
  /**
@@ -14756,13 +15238,13 @@ function requireNode$1 () {
14756
15238
  function init(debug) {
14757
15239
  debug.inspectOpts = {};
14758
15240
 
14759
- const keys = Object.keys(exports.inspectOpts);
15241
+ const keys = Object.keys(exports$1.inspectOpts);
14760
15242
  for (let i = 0; i < keys.length; i++) {
14761
- debug.inspectOpts[keys[i]] = exports.inspectOpts[keys[i]];
15243
+ debug.inspectOpts[keys[i]] = exports$1.inspectOpts[keys[i]];
14762
15244
  }
14763
15245
  }
14764
15246
 
14765
- module.exports = requireCommon()(exports);
15247
+ module.exports = requireCommon()(exports$1);
14766
15248
 
14767
15249
  const {formatters} = module.exports;
14768
15250
 
@@ -15336,7 +15818,7 @@ function requireFollowRedirects () {
15336
15818
  // Wraps the key/value object of protocols with redirect functionality
15337
15819
  function wrap(protocols) {
15338
15820
  // Default settings
15339
- var exports = {
15821
+ var exports$1 = {
15340
15822
  maxRedirects: 21,
15341
15823
  maxBodyLength: 10 * 1024 * 1024,
15342
15824
  };
@@ -15346,7 +15828,7 @@ function requireFollowRedirects () {
15346
15828
  Object.keys(protocols).forEach(function (scheme) {
15347
15829
  var protocol = scheme + ":";
15348
15830
  var nativeProtocol = nativeProtocols[protocol] = protocols[scheme];
15349
- var wrappedProtocol = exports[scheme] = Object.create(nativeProtocol);
15831
+ var wrappedProtocol = exports$1[scheme] = Object.create(nativeProtocol);
15350
15832
 
15351
15833
  // Executes a request, following redirects
15352
15834
  function request(input, options, callback) {
@@ -15369,8 +15851,8 @@ function requireFollowRedirects () {
15369
15851
 
15370
15852
  // Set defaults
15371
15853
  options = Object.assign({
15372
- maxRedirects: exports.maxRedirects,
15373
- maxBodyLength: exports.maxBodyLength,
15854
+ maxRedirects: exports$1.maxRedirects,
15855
+ maxBodyLength: exports$1.maxBodyLength,
15374
15856
  }, input, options);
15375
15857
  options.nativeProtocols = nativeProtocols;
15376
15858
  if (!isString(options.host) && !isString(options.hostname)) {
@@ -15395,7 +15877,7 @@ function requireFollowRedirects () {
15395
15877
  get: { value: get, configurable: true, enumerable: true, writable: true },
15396
15878
  });
15397
15879
  });
15398
- return exports;
15880
+ return exports$1;
15399
15881
  }
15400
15882
 
15401
15883
  function noop() { /* empty */ }
@@ -17001,7 +17483,7 @@ var hasRequiredLodash;
17001
17483
  function requireLodash () {
17002
17484
  if (hasRequiredLodash) return lodash$1.exports;
17003
17485
  hasRequiredLodash = 1;
17004
- (function (module, exports) {
17486
+ (function (module, exports$1) {
17005
17487
  (function() {
17006
17488
 
17007
17489
  /** Used as a safe reference for `undefined` in pre-ES5 environments. */
@@ -17432,7 +17914,7 @@ function requireLodash () {
17432
17914
  var root = freeGlobal || freeSelf || Function('return this')();
17433
17915
 
17434
17916
  /** Detect free variable `exports`. */
17435
- var freeExports = exports && !exports.nodeType && exports;
17917
+ var freeExports = exports$1 && !exports$1.nodeType && exports$1;
17436
17918
 
17437
17919
  /** Detect free variable `module`. */
17438
17920
  var freeModule = freeExports && 'object' == 'object' && module && !module.nodeType && module;
@@ -35262,12 +35744,12 @@ var hasRequiredIsBuffer;
35262
35744
  function requireIsBuffer () {
35263
35745
  if (hasRequiredIsBuffer) return isBuffer.exports;
35264
35746
  hasRequiredIsBuffer = 1;
35265
- (function (module, exports) {
35747
+ (function (module, exports$1) {
35266
35748
  var root = require_root(),
35267
35749
  stubFalse = requireStubFalse();
35268
35750
 
35269
35751
  /** Detect free variable `exports`. */
35270
- var freeExports = exports && !exports.nodeType && exports;
35752
+ var freeExports = exports$1 && !exports$1.nodeType && exports$1;
35271
35753
 
35272
35754
  /** Detect free variable `module`. */
35273
35755
  var freeModule = freeExports && 'object' == 'object' && module && !module.nodeType && module;
@@ -35487,11 +35969,11 @@ var hasRequired_nodeUtil;
35487
35969
  function require_nodeUtil () {
35488
35970
  if (hasRequired_nodeUtil) return _nodeUtil.exports;
35489
35971
  hasRequired_nodeUtil = 1;
35490
- (function (module, exports) {
35972
+ (function (module, exports$1) {
35491
35973
  var freeGlobal = require_freeGlobal();
35492
35974
 
35493
35975
  /** Detect free variable `exports`. */
35494
- var freeExports = exports && !exports.nodeType && exports;
35976
+ var freeExports = exports$1 && !exports$1.nodeType && exports$1;
35495
35977
 
35496
35978
  /** Detect free variable `module`. */
35497
35979
  var freeModule = freeExports && 'object' == 'object' && module && !module.nodeType && module;
@@ -44413,7 +44895,7 @@ var hasRequiredSafeBuffer;
44413
44895
  function requireSafeBuffer () {
44414
44896
  if (hasRequiredSafeBuffer) return safeBuffer.exports;
44415
44897
  hasRequiredSafeBuffer = 1;
44416
- (function (module, exports) {
44898
+ (function (module, exports$1) {
44417
44899
  /* eslint-disable node/no-deprecated-api */
44418
44900
  var buffer = require$$0$5;
44419
44901
  var Buffer = buffer.Buffer;
@@ -44428,8 +44910,8 @@ function requireSafeBuffer () {
44428
44910
  module.exports = buffer;
44429
44911
  } else {
44430
44912
  // Copy properties from require('buffer')
44431
- copyProps(buffer, exports);
44432
- exports.Buffer = SafeBuffer;
44913
+ copyProps(buffer, exports$1);
44914
+ exports$1.Buffer = SafeBuffer;
44433
44915
  }
44434
44916
 
44435
44917
  function SafeBuffer (arg, encodingOrOffset, length) {
@@ -47627,22 +48109,22 @@ var hasRequiredReadable;
47627
48109
  function requireReadable () {
47628
48110
  if (hasRequiredReadable) return readable.exports;
47629
48111
  hasRequiredReadable = 1;
47630
- (function (module, exports) {
48112
+ (function (module, exports$1) {
47631
48113
  var Stream = require$$0$4;
47632
48114
  if (process.env.READABLE_STREAM === 'disable' && Stream) {
47633
48115
  module.exports = Stream.Readable;
47634
48116
  Object.assign(module.exports, Stream);
47635
48117
  module.exports.Stream = Stream;
47636
48118
  } else {
47637
- exports = module.exports = require_stream_readable();
47638
- exports.Stream = Stream || exports;
47639
- exports.Readable = exports;
47640
- exports.Writable = require_stream_writable();
47641
- exports.Duplex = require_stream_duplex();
47642
- exports.Transform = require_stream_transform();
47643
- exports.PassThrough = require_stream_passthrough();
47644
- exports.finished = requireEndOfStream();
47645
- exports.pipeline = requirePipeline();
48119
+ exports$1 = module.exports = require_stream_readable();
48120
+ exports$1.Stream = Stream || exports$1;
48121
+ exports$1.Readable = exports$1;
48122
+ exports$1.Writable = require_stream_writable();
48123
+ exports$1.Duplex = require_stream_duplex();
48124
+ exports$1.Transform = require_stream_transform();
48125
+ exports$1.PassThrough = require_stream_passthrough();
48126
+ exports$1.finished = requireEndOfStream();
48127
+ exports$1.pipeline = requirePipeline();
47646
48128
  }
47647
48129
  } (readable, readable.exports));
47648
48130
  return readable.exports;
@@ -49077,12 +49559,12 @@ var hasRequiredWebsocket;
49077
49559
  function requireWebsocket () {
49078
49560
  if (hasRequiredWebsocket) return websocket$1;
49079
49561
  hasRequiredWebsocket = 1;
49080
- (function (exports) {
49562
+ (function (exports$1) {
49081
49563
  var __importDefault = (websocket$1 && websocket$1.__importDefault) || function (mod) {
49082
49564
  return (mod && mod.__esModule) ? mod : { "default": mod };
49083
49565
  };
49084
- Object.defineProperty(exports, "__esModule", { value: true });
49085
- exports.AlpacaWebsocket = exports.ERROR = exports.CONN_ERROR = exports.EVENT = exports.STATE = void 0;
49566
+ Object.defineProperty(exports$1, "__esModule", { value: true });
49567
+ exports$1.AlpacaWebsocket = exports$1.ERROR = exports$1.CONN_ERROR = exports$1.EVENT = exports$1.STATE = void 0;
49086
49568
  const events_1 = __importDefault(require$$0$1);
49087
49569
  const ws_1 = __importDefault(requireWs());
49088
49570
  const msgpack5_1 = __importDefault(requireMsgpack5());
@@ -49096,7 +49578,7 @@ function requireWebsocket () {
49096
49578
  STATE["DISCONNECTED"] = "disconnected";
49097
49579
  STATE["WAITING_TO_CONNECT"] = "waiting to connect";
49098
49580
  STATE["WAITING_TO_RECONNECT"] = "waiting to reconnect";
49099
- })(STATE || (exports.STATE = STATE = {}));
49581
+ })(STATE || (exports$1.STATE = STATE = {}));
49100
49582
  // Client events
49101
49583
  var EVENT;
49102
49584
  (function (EVENT) {
@@ -49115,9 +49597,9 @@ function requireWebsocket () {
49115
49597
  EVENT["CORRECTIONS"] = "corrections";
49116
49598
  EVENT["ORDERBOOKS"] = "orderbooks";
49117
49599
  EVENT["NEWS"] = "news";
49118
- })(EVENT || (exports.EVENT = EVENT = {}));
49600
+ })(EVENT || (exports$1.EVENT = EVENT = {}));
49119
49601
  // Connection errors by code
49120
- exports.CONN_ERROR = new Map([
49602
+ exports$1.CONN_ERROR = new Map([
49121
49603
  [400, "invalid syntax"],
49122
49604
  [401, "not authenticated"],
49123
49605
  [402, "auth failed"],
@@ -49136,7 +49618,7 @@ function requireWebsocket () {
49136
49618
  ERROR["MISSING_SECERT_KEY"] = "missing secret key";
49137
49619
  ERROR["MISSING_API_KEY"] = "missing api key";
49138
49620
  ERROR["UNEXPECTED_MESSAGE"] = "unexpected message";
49139
- })(ERROR || (exports.ERROR = ERROR = {}));
49621
+ })(ERROR || (exports$1.ERROR = ERROR = {}));
49140
49622
  class AlpacaWebsocket extends events_1.default.EventEmitter {
49141
49623
  constructor(options) {
49142
49624
  super();
@@ -49293,7 +49775,7 @@ function requireWebsocket () {
49293
49775
  this.updateSubscriptions(data[0]);
49294
49776
  break;
49295
49777
  case "error":
49296
- this.emit(EVENT.CLIENT_ERROR, exports.CONN_ERROR.get(data[0].code));
49778
+ this.emit(EVENT.CLIENT_ERROR, exports$1.CONN_ERROR.get(data[0].code));
49297
49779
  break;
49298
49780
  default:
49299
49781
  this.dataHandler(data);
@@ -49322,7 +49804,7 @@ function requireWebsocket () {
49322
49804
  }
49323
49805
  }
49324
49806
  }
49325
- exports.AlpacaWebsocket = AlpacaWebsocket;
49807
+ exports$1.AlpacaWebsocket = AlpacaWebsocket;
49326
49808
  } (websocket$1));
49327
49809
  return websocket$1;
49328
49810
  }
@@ -49956,8 +50438,8 @@ var hasRequiredWebsockets;
49956
50438
  function requireWebsockets () {
49957
50439
  if (hasRequiredWebsockets) return websockets;
49958
50440
  hasRequiredWebsockets = 1;
49959
- (function (exports) {
49960
- Object.defineProperty(exports, "__esModule", { value: true });
50441
+ (function (exports$1) {
50442
+ Object.defineProperty(exports$1, "__esModule", { value: true });
49961
50443
  const events = require$$0$1;
49962
50444
  const WebSocket = requireWs();
49963
50445
  const entity = requireEntity();
@@ -49972,7 +50454,7 @@ function requireWebsockets () {
49972
50454
  STATE.DISCONNECTED = "disconnected";
49973
50455
  STATE.WAITING_TO_CONNECT = "waiting to connect";
49974
50456
  STATE.WAITING_TO_RECONNECT = "waiting to reconnect";
49975
- })((STATE = exports.STATE || (exports.STATE = {})));
50457
+ })((STATE = exports$1.STATE || (exports$1.STATE = {})));
49976
50458
  // Client events
49977
50459
  var EVENT;
49978
50460
  (function (EVENT) {
@@ -49986,7 +50468,7 @@ function requireWebsockets () {
49986
50468
  EVENT.STOCK_QUOTES = "stock_quotes";
49987
50469
  EVENT.STOCK_AGG_SEC = "stock_agg_sec";
49988
50470
  EVENT.STOCK_AGG_MIN = "stock_agg_min";
49989
- })((EVENT = exports.EVENT || (exports.EVENT = {})));
50471
+ })((EVENT = exports$1.EVENT || (exports$1.EVENT = {})));
49990
50472
  // Connection errors Each of these will also emit EVENT.ERROR
49991
50473
  var ERROR;
49992
50474
  (function (ERROR) {
@@ -49995,7 +50477,7 @@ function requireWebsockets () {
49995
50477
  ERROR.MISSING_API_KEY = "missing api key";
49996
50478
  ERROR.MISSING_SECRET_KEY = "missing secret key";
49997
50479
  ERROR.UNKNOWN = "unknown error";
49998
- })((ERROR = exports.ERROR || (exports.ERROR = {})));
50480
+ })((ERROR = exports$1.ERROR || (exports$1.ERROR = {})));
49999
50481
  /**
50000
50482
  * AlpacaStreamClient manages a connection to Alpaca's websocket api
50001
50483
  */
@@ -50308,7 +50790,7 @@ function requireWebsockets () {
50308
50790
  }
50309
50791
  }
50310
50792
  }
50311
- exports.AlpacaStreamClient = AlpacaStreamClient;
50793
+ exports$1.AlpacaStreamClient = AlpacaStreamClient;
50312
50794
  } (websockets));
50313
50795
  return websockets;
50314
50796
  }
@@ -50560,13 +51042,13 @@ var hasRequiredDist;
50560
51042
  function requireDist () {
50561
51043
  if (hasRequiredDist) return dist$1.exports;
50562
51044
  hasRequiredDist = 1;
50563
- (function (module, exports) {
51045
+ (function (module, exports$1) {
50564
51046
  var __importDefault = (dist && dist.__importDefault) || function (mod) {
50565
51047
  return (mod && mod.__esModule) ? mod : { "default": mod };
50566
51048
  };
50567
- Object.defineProperty(exports, "__esModule", { value: true });
51049
+ Object.defineProperty(exports$1, "__esModule", { value: true });
50568
51050
  const alpaca_trade_api_1 = __importDefault(requireAlpacaTradeApi());
50569
- exports.default = alpaca_trade_api_1.default;
51051
+ exports$1.default = alpaca_trade_api_1.default;
50570
51052
  module.exports = alpaca_trade_api_1.default;
50571
51053
  } (dist$1, dist$1.exports));
50572
51054
  return dist$1.exports;
@@ -62675,6 +63157,7 @@ class StampedeProtectedCache {
62675
63157
  coalescedRequests: 0,
62676
63158
  backgroundRefreshes: 0,
62677
63159
  refreshErrors: 0,
63160
+ loadTimeouts: 0,
62678
63161
  };
62679
63162
  constructor(options) {
62680
63163
  this.options = {
@@ -62948,6 +63431,7 @@ class StampedeProtectedCache {
62948
63431
  coalescedRequests: this.stats.coalescedRequests,
62949
63432
  backgroundRefreshes: this.stats.backgroundRefreshes,
62950
63433
  refreshErrors: this.stats.refreshErrors,
63434
+ loadTimeouts: this.stats.loadTimeouts,
62951
63435
  };
62952
63436
  }
62953
63437
  /**
@@ -62998,8 +63482,9 @@ class StampedeProtectedCache {
62998
63482
  this.options.logger.debug("Request coalesced", { key });
62999
63483
  return existingPromise;
63000
63484
  }
63001
- // Create new promise and store it
63002
- const promise = this.loadAndCache(key, loader, ttl);
63485
+ // Create new promise and store it — bounded by loadTimeoutMs so a
63486
+ // never-settling loader cannot pin the key (see loadWithTimeout).
63487
+ const promise = this.loadWithTimeout(key, loader, ttl);
63003
63488
  this.pendingRefreshes.set(key, promise);
63004
63489
  try {
63005
63490
  const result = await promise;
@@ -63010,6 +63495,51 @@ class StampedeProtectedCache {
63010
63495
  this.pendingRefreshes.delete(key);
63011
63496
  }
63012
63497
  }
63498
+ /**
63499
+ * Race {@link loadAndCache} against the configured load timeout.
63500
+ *
63501
+ * On timeout: rejects with a typed timeout error, evicts the pending
63502
+ * single-flight entry so the NEXT caller retries with a fresh loader
63503
+ * (instead of coalescing onto the hung one), and attaches a settlement
63504
+ * observer to the abandoned loader so its eventual resolution/rejection
63505
+ * is logged rather than surfacing as an unhandled rejection.
63506
+ */
63507
+ async loadWithTimeout(key, loader, ttl) {
63508
+ const timeoutMs = this.options.loadTimeoutMs ?? 30000;
63509
+ const loadPromise = this.loadAndCache(key, loader, ttl);
63510
+ let timeoutHandle;
63511
+ const timeoutPromise = new Promise((_, reject) => {
63512
+ timeoutHandle = setTimeout(() => {
63513
+ this.stats.loadTimeouts++;
63514
+ // Evict the pin so the next caller retries fresh.
63515
+ this.pendingRefreshes.delete(key);
63516
+ this.options.logger.warn("Cache loader timed out — pin evicted", {
63517
+ key,
63518
+ timeoutMs,
63519
+ });
63520
+ // Observe the abandoned loader; never let it become unhandled.
63521
+ loadPromise
63522
+ .then(() => {
63523
+ this.options.logger.warn("Abandoned cache loader eventually resolved", { key });
63524
+ })
63525
+ .catch((error) => {
63526
+ this.options.logger.warn("Abandoned cache loader eventually rejected", {
63527
+ key,
63528
+ error: error instanceof Error ? error.message : String(error),
63529
+ });
63530
+ });
63531
+ reject(new Error(`StampedeProtectedCache loader timed out after ${timeoutMs}ms for key "${key}"`));
63532
+ }, timeoutMs);
63533
+ });
63534
+ try {
63535
+ return await Promise.race([loadPromise, timeoutPromise]);
63536
+ }
63537
+ finally {
63538
+ if (timeoutHandle !== undefined) {
63539
+ clearTimeout(timeoutHandle);
63540
+ }
63541
+ }
63542
+ }
63013
63543
  /**
63014
63544
  * Load data and cache it
63015
63545
  */
@@ -63159,6 +63689,7 @@ const DEFAULT_CACHE_OPTIONS = {
63159
63689
  minJitter: 0.9, // 90%
63160
63690
  maxJitter: 1.1, // 110%
63161
63691
  enableBackgroundRefresh: true,
63692
+ loadTimeoutMs: 30000, // 30s hard loader ceiling (anti-pinning)
63162
63693
  };
63163
63694
 
63164
63695
  /**