@adaptic/utils 0.0.1002 → 0.0.1003

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -3,6 +3,7 @@ import { format, sub, set, add, startOfDay, endOfDay, isBefore, differenceInMill
3
3
  import { formatInTimeZone, toZonedTime, fromZonedTime } from 'date-fns-tz';
4
4
  import require$$0$4, { EventEmitter } from 'events';
5
5
  import WebSocket from 'ws';
6
+ import { createHash } from 'node:crypto';
6
7
  import ms from 'ms';
7
8
  import require$$0$1 from 'fs';
8
9
  import require$$1 from 'path';
@@ -2403,6 +2404,19 @@ class DataFormatError extends AdapticUtilsError {
2403
2404
  * const result = await makeAlpacaApiCall();
2404
2405
  * ```
2405
2406
  */
2407
+ /** Number of milliseconds in one second, used for token-refill timing math. */
2408
+ const MS_PER_SECOND$1 = 1000;
2409
+ /**
2410
+ * Minimum delay (ms) for a scheduled queue wake-up. Guards against a `0`/`NaN`
2411
+ * delay when the token deficit rounds down, ensuring the timer always makes
2412
+ * forward progress rather than busy-looping on the event loop.
2413
+ */
2414
+ const MIN_WAKE_DELAY_MS = 1;
2415
+ /**
2416
+ * Number of whole tokens required to release a single queued request. The token
2417
+ * bucket consumes exactly one token per admitted request.
2418
+ */
2419
+ const TOKENS_PER_REQUEST = 1;
2406
2420
  /**
2407
2421
  * Token bucket rate limiter implementation
2408
2422
  *
@@ -2418,6 +2432,13 @@ class TokenBucketRateLimiter {
2418
2432
  queue = [];
2419
2433
  timeoutMs;
2420
2434
  processingQueue = false;
2435
+ /**
2436
+ * Single pending timer that wakes the limiter to refill tokens and drain the
2437
+ * queue. Without this, a queued request would only be released by a
2438
+ * subsequent {@link acquire} call and would otherwise stall until its own
2439
+ * timeout fired. `null` means no wake-up is currently scheduled.
2440
+ */
2441
+ wakeTimer = null;
2421
2442
  /**
2422
2443
  * Creates a new rate limiter instance
2423
2444
  *
@@ -2491,8 +2512,48 @@ class TokenBucketRateLimiter {
2491
2512
  reject(error);
2492
2513
  }, this.timeoutMs);
2493
2514
  this.queue.push({ resolve, reject, timeoutHandle });
2515
+ // Ensure the queue is actively drained even if no further acquire() calls
2516
+ // arrive: schedule a wake-up to refill tokens and release this request.
2517
+ this.scheduleQueueWake();
2494
2518
  });
2495
2519
  }
2520
+ /**
2521
+ * Schedules a single wake-up timer that refills tokens and drains the queue.
2522
+ *
2523
+ * The delay is the time required to accrue the tokens still needed to release
2524
+ * the next queued request at the configured refill rate. Only one timer is
2525
+ * ever outstanding (guarded by {@link wakeTimer}); the timer is `unref`'d so
2526
+ * it never keeps the Node.js process alive on its own. When it fires it
2527
+ * refills, drains what it can, and re-arms itself if work remains.
2528
+ */
2529
+ scheduleQueueWake() {
2530
+ // A wake-up is already pending, or there is nothing to wake for.
2531
+ if (this.wakeTimer !== null || this.queue.length === 0) {
2532
+ return;
2533
+ }
2534
+ const tokensNeeded = Math.max(0, TOKENS_PER_REQUEST - this.tokens);
2535
+ const deficitMs = Math.max(MIN_WAKE_DELAY_MS, Math.ceil((tokensNeeded / this.config.refillRate) * MS_PER_SECOND$1));
2536
+ const timer = setTimeout(() => {
2537
+ this.wakeTimer = null;
2538
+ // refill() drains the queue via processQueue(); if requests remain
2539
+ // afterwards, processQueue() re-arms the wake-up.
2540
+ this.refill();
2541
+ }, deficitMs);
2542
+ // Do not let a pending rate-limiter wake-up keep the process alive.
2543
+ if (typeof timer.unref === "function") {
2544
+ timer.unref();
2545
+ }
2546
+ this.wakeTimer = timer;
2547
+ }
2548
+ /**
2549
+ * Clears any pending wake-up timer.
2550
+ */
2551
+ clearWakeTimer() {
2552
+ if (this.wakeTimer !== null) {
2553
+ clearTimeout(this.wakeTimer);
2554
+ this.wakeTimer = null;
2555
+ }
2556
+ }
2496
2557
  /**
2497
2558
  * Refills tokens based on elapsed time and processes queued requests
2498
2559
  *
@@ -2537,6 +2598,15 @@ class TokenBucketRateLimiter {
2537
2598
  finally {
2538
2599
  this.processingQueue = false;
2539
2600
  }
2601
+ // Keep the wake-up state consistent with the queue: if requests are still
2602
+ // waiting (tokens ran out mid-drain), ensure a wake-up is armed; otherwise
2603
+ // release any pending timer so it cannot fire needlessly.
2604
+ if (this.queue.length > 0) {
2605
+ this.scheduleQueueWake();
2606
+ }
2607
+ else {
2608
+ this.clearWakeTimer();
2609
+ }
2540
2610
  }
2541
2611
  /**
2542
2612
  * Gets the current number of available tokens
@@ -2568,6 +2638,7 @@ class TokenBucketRateLimiter {
2568
2638
  clearTimeout(request.timeoutHandle);
2569
2639
  request.reject(new RateLimitError(`Rate limiter reset for ${this.config.label}`, this.config.label, undefined));
2570
2640
  }
2641
+ this.clearWakeTimer();
2571
2642
  this.queue = [];
2572
2643
  this.tokens = this.config.maxTokens;
2573
2644
  this.lastRefill = Date.now();
@@ -4317,6 +4388,46 @@ const limitPriceSlippagePercent100 = 0.1; // 0.1%
4317
4388
  const ORDER_PAGE_LIMIT = 500;
4318
4389
  /** Delay between order pagination pages to stay clear of rate limits. */
4319
4390
  const ORDER_PAGINATION_DELAY_MS = 300;
4391
+ /**
4392
+ * HTTP status at or above which a Multi-Status (207) sub-result is a failure.
4393
+ * Alpaca's bulk `DELETE /orders` and `DELETE /positions` endpoints return a 207
4394
+ * envelope whose top-level status is 2xx even when individual orders/positions
4395
+ * failed to cancel or close; each element carries its own per-item HTTP status.
4396
+ * Treating >= 300 as a failure lets the engine failsafe see partial failures
4397
+ * instead of recording a false success.
4398
+ */
4399
+ const HTTP_STATUS_MIN_ERROR = 300;
4400
+ /**
4401
+ * Prefix applied to engine-derived `client_order_id` idempotency keys so they
4402
+ * are visibly attributable in Alpaca's dashboard and can never collide with a
4403
+ * caller-supplied identifier.
4404
+ */
4405
+ const CLIENT_ORDER_ID_PREFIX = "adaptic-";
4406
+ /**
4407
+ * Number of leading hex characters of the SHA-256 digest retained in a derived
4408
+ * `client_order_id`. 32 hex chars = 128 bits of entropy (collision-negligible),
4409
+ * and keeps the full id (prefix + digest = 40 chars) within Alpaca's identifier
4410
+ * length limit.
4411
+ */
4412
+ const CLIENT_ORDER_ID_HASH_LENGTH = 32;
4413
+ /**
4414
+ * Idempotency window (ms) used when deriving a default `client_order_id`.
4415
+ *
4416
+ * A client request timeout followed by an automatic retry re-submits the SAME
4417
+ * logical order. Deriving the id from the order's semantic parameters plus the
4418
+ * current time-window bucket makes Alpaca reject the retried duplicate
4419
+ * (`client_order_id must be unique`) instead of double-filling. The window is
4420
+ * deliberately much larger than the 30s Alpaca request timeout so a full
4421
+ * timeout+retry sequence lands in the same bucket, while a genuinely new but
4422
+ * otherwise-identical order placed in a later window still receives a distinct
4423
+ * id.
4424
+ *
4425
+ * This derived default is a best-effort safety net; the guaranteed-idempotent
4426
+ * path is for the caller to pass an explicit `clientOrderId` tied to the
4427
+ * originating signal/decision id (which also permits legitimately-repeated
4428
+ * identical orders inside a single window).
4429
+ */
4430
+ const CLIENT_ORDER_ID_WINDOW_MS = 300_000;
4320
4431
  /**
4321
4432
  Websocket example
4322
4433
  const alpacaAPI = createAlpacaTradingAPI(credentials); // type AlpacaCredentials
@@ -4397,6 +4508,114 @@ class AlpacaTradingAPI {
4397
4508
  ? Math.round(price * 100) / 100
4398
4509
  : Math.round(price * 10000) / 10000;
4399
4510
  };
4511
+ /**
4512
+ * Derive a deterministic `client_order_id` from an order's semantic
4513
+ * parameters so that a client-timeout-triggered retry re-submits the SAME id
4514
+ * and Alpaca rejects the duplicate broker-side instead of double-filling.
4515
+ *
4516
+ * The id is stable for identical parameters within a single
4517
+ * {@link CLIENT_ORDER_ID_WINDOW_MS} bucket and scoped per account. Callers
4518
+ * that must place genuinely distinct yet otherwise-identical orders should
4519
+ * pass an explicit `clientOrderId` rather than relying on this default.
4520
+ *
4521
+ * @param parts - Ordered, stringifiable components uniquely describing the
4522
+ * order (e.g. order kind, symbol, side, quantity, price, intent).
4523
+ * @returns An Alpaca-safe `client_order_id` (prefix + truncated SHA-256 hex).
4524
+ */
4525
+ deriveClientOrderId(parts) {
4526
+ const windowBucket = Math.floor(Date.now() / CLIENT_ORDER_ID_WINDOW_MS);
4527
+ const material = [
4528
+ this.credentials.accountName,
4529
+ windowBucket,
4530
+ ...parts.map((part) => (part === undefined ? "" : String(part))),
4531
+ ].join("|");
4532
+ const digest = createHash("sha256")
4533
+ .update(material)
4534
+ .digest("hex")
4535
+ .slice(0, CLIENT_ORDER_ID_HASH_LENGTH);
4536
+ return `${CLIENT_ORDER_ID_PREFIX}${digest}`;
4537
+ }
4538
+ /**
4539
+ * Collect the human-readable failure entries from a bulk Multi-Status (207)
4540
+ * response body (`DELETE /orders`, `DELETE /positions`). Each element carries
4541
+ * its own per-item HTTP status; any element with status >=
4542
+ * {@link HTTP_STATUS_MIN_ERROR} is a failure the caller must be able to see.
4543
+ *
4544
+ * @param entries - Parsed 207 response array.
4545
+ * @returns One `"<identifier>:<status>"` string per failed entry.
4546
+ */
4547
+ collectMultiStatusFailures(entries) {
4548
+ return entries
4549
+ .filter((entry) => typeof entry.status === "number" &&
4550
+ entry.status >= HTTP_STATUS_MIN_ERROR)
4551
+ .map((entry) => `${entry.symbol ?? entry.id ?? "unknown"}:${entry.status}`);
4552
+ }
4553
+ /**
4554
+ * Flatten a single position with a marketable limit order, deriving the
4555
+ * closing side, intent, and slippage-adjusted limit price from the latest
4556
+ * quote. Throws when no usable quote/price is available for the symbol so the
4557
+ * caller can record a per-position failure via {@link Promise.allSettled}
4558
+ * without aborting the flatten of the remaining positions.
4559
+ *
4560
+ * @param position - The position to close.
4561
+ * @param quotesResponse - Latest quotes keyed by symbol.
4562
+ * @param extendedHours - Whether the closing order is an extended-hours order.
4563
+ */
4564
+ async closePositionWithLimitOrder(position, quotesResponse, extendedHours) {
4565
+ const quote = quotesResponse.quotes[position.symbol];
4566
+ if (!quote) {
4567
+ throw new Error(`No quote available for ${position.symbol}`);
4568
+ }
4569
+ const qty = Math.abs(parseFloat(position.qty));
4570
+ const side = position.side === "long" ? "sell" : "buy";
4571
+ const positionIntent = side === "sell" ? "sell_to_close" : "buy_to_close";
4572
+ // Use bid for sells, ask for buys.
4573
+ const currentPrice = side === "sell" ? quote.bp : quote.ap;
4574
+ if (!currentPrice) {
4575
+ throw new Error(`No valid price available for ${position.symbol}`);
4576
+ }
4577
+ const limitSlippagePercent1 = limitPriceSlippagePercent100 / 100;
4578
+ const limitPrice = side === "sell"
4579
+ ? this.roundPriceForAlpaca(currentPrice * (1 - limitSlippagePercent1)) // Sell slightly lower
4580
+ : this.roundPriceForAlpaca(currentPrice * (1 + limitSlippagePercent1)); // Buy slightly higher
4581
+ this.log(`Creating ${extendedHours ? "extended hours " : ""}limit order to close ${position.symbol} position: ${side} ${qty} shares at $${limitPrice.toFixed(2)}`, {
4582
+ symbol: position.symbol,
4583
+ });
4584
+ await this.createLimitOrder(position.symbol, qty, side, limitPrice, positionIntent, extendedHours);
4585
+ }
4586
+ /**
4587
+ * Flatten every supplied position independently and surface an aggregate
4588
+ * failure if any could not be closed. Positions are attempted concurrently
4589
+ * with {@link Promise.allSettled} so a data gap or broker rejection on one
4590
+ * symbol never silently prevents the others from being flattened.
4591
+ *
4592
+ * @param positions - Positions to flatten.
4593
+ * @param extendedHours - Whether the closing orders are extended-hours orders.
4594
+ * @throws Error listing every symbol that failed to flatten.
4595
+ */
4596
+ async flattenPositionsWithLimitOrders(positions, extendedHours) {
4597
+ const symbols = positions.map((position) => position.symbol);
4598
+ const quotesResponse = await marketDataAPI.getLatestQuotes(symbols);
4599
+ const results = await Promise.allSettled(positions.map((position) => this.closePositionWithLimitOrder(position, quotesResponse, extendedHours)));
4600
+ const failures = [];
4601
+ results.forEach((result, index) => {
4602
+ if (result.status === "rejected") {
4603
+ const symbol = positions[index]?.symbol ?? "unknown";
4604
+ const reason = result.reason instanceof Error
4605
+ ? result.reason.message
4606
+ : String(result.reason);
4607
+ failures.push(`${symbol}: ${reason}`);
4608
+ this.log(`Failed to close position ${symbol}: ${reason}`, {
4609
+ symbol,
4610
+ type: "error",
4611
+ });
4612
+ }
4613
+ });
4614
+ if (failures.length > 0) {
4615
+ throw new Error(`Failed to close ${failures.length} of ${positions.length} positions: ${failures.join("; ")}`);
4616
+ }
4617
+ this.log(`All positions closed: ${symbols.join(", ")}`);
4618
+ }
4400
4619
  handleAuthMessage(data) {
4401
4620
  if (data.status === "authorized") {
4402
4621
  this.authenticated = true;
@@ -4768,21 +4987,31 @@ class AlpacaTradingAPI {
4768
4987
  * @param position_intent (string) - the position intent of the order
4769
4988
  * @returns The created AlpacaOrder with order ID and details
4770
4989
  */
4771
- async createTrailingStop(symbol, qty, side, trailPercent100, position_intent) {
4990
+ async createTrailingStop(symbol, qty, side, trailPercent100, position_intent, clientOrderId) {
4772
4991
  this.log(`Creating trailing stop ${side.toUpperCase()} ${qty} shares for ${symbol} with trail percent ${trailPercent100}%`, {
4773
4992
  symbol,
4774
4993
  });
4994
+ const body = {
4995
+ symbol,
4996
+ qty: Math.abs(qty).toString(),
4997
+ side,
4998
+ position_intent,
4999
+ order_class: "simple",
5000
+ type: "trailing_stop",
5001
+ trail_percent: trailPercent100.toString(), // Already in decimal form (e.g., 4 for 4%)
5002
+ time_in_force: "gtc",
5003
+ client_order_id: clientOrderId ??
5004
+ this.deriveClientOrderId([
5005
+ "trailing_stop",
5006
+ symbol,
5007
+ side,
5008
+ position_intent,
5009
+ Math.abs(qty),
5010
+ trailPercent100,
5011
+ ]),
5012
+ };
4775
5013
  try {
4776
- const order = await this.makeRequest(`/orders`, "POST", {
4777
- symbol,
4778
- qty: Math.abs(qty),
4779
- side,
4780
- position_intent,
4781
- order_class: "simple",
4782
- type: "trailing_stop",
4783
- trail_percent: trailPercent100, // Already in decimal form (e.g., 4 for 4%)
4784
- time_in_force: "gtc",
4785
- });
5014
+ const order = await this.makeRequest(`/orders`, "POST", body);
4786
5015
  this.log(`Trailing stop order created for ${symbol}: orderId=${order.id}, trailPercent=${trailPercent100}%`, { symbol });
4787
5016
  return order;
4788
5017
  }
@@ -4814,9 +5043,15 @@ class AlpacaTradingAPI {
4814
5043
  time_in_force: "day",
4815
5044
  order_class: "simple",
4816
5045
  };
4817
- if (client_order_id !== undefined) {
4818
- body.client_order_id = client_order_id;
4819
- }
5046
+ body.client_order_id =
5047
+ client_order_id ??
5048
+ this.deriveClientOrderId([
5049
+ "market",
5050
+ symbol,
5051
+ side,
5052
+ position_intent,
5053
+ Math.abs(qty),
5054
+ ]);
4820
5055
  try {
4821
5056
  return await this.makeRequest("/orders", "POST", body);
4822
5057
  }
@@ -4917,16 +5152,31 @@ class AlpacaTradingAPI {
4917
5152
  }
4918
5153
  }
4919
5154
  /**
4920
- * Cancel all open orders
5155
+ * Cancel all open orders.
5156
+ *
5157
+ * Alpaca's bulk cancel returns a 207 Multi-Status body whose top-level status
5158
+ * is 2xx even when individual orders failed to cancel; this method inspects
5159
+ * the per-order statuses and throws if any order could not be canceled, so a
5160
+ * caller acting as a live-stop failsafe cannot record success while orders
5161
+ * remain live. Transport/HTTP errors propagate unchanged (matching the
5162
+ * throw-on-failure contract of {@link cancelOrder}).
5163
+ *
5164
+ * @throws Error if the bulk cancel request fails or any individual order
5165
+ * could not be canceled.
4921
5166
  */
4922
5167
  async cancelAllOrders() {
4923
5168
  this.log(`Canceling all open orders`);
4924
- try {
4925
- await this.makeRequest("/orders", "DELETE");
5169
+ const results = await this.makeRequest("/orders", "DELETE");
5170
+ if (!Array.isArray(results)) {
5171
+ return;
4926
5172
  }
4927
- catch (error) {
4928
- this.log(`Error canceling all orders: ${error}`, { type: "error" });
5173
+ const failures = this.collectMultiStatusFailures(results);
5174
+ if (failures.length > 0) {
5175
+ const detail = failures.join(", ");
5176
+ this.log(`Error canceling all orders: ${failures.length}/${results.length} orders failed to cancel (${detail})`, { type: "error" });
5177
+ throw new Error(`Failed to cancel ${failures.length} of ${results.length} orders: ${detail}`);
4929
5178
  }
5179
+ this.log(`Successfully canceled ${results.length} open orders`);
4930
5180
  }
4931
5181
  /**
4932
5182
  * Cancel a specific order by its ID
@@ -4977,9 +5227,17 @@ class AlpacaTradingAPI {
4977
5227
  order_class: "simple",
4978
5228
  extended_hours,
4979
5229
  };
4980
- if (client_order_id !== undefined) {
4981
- body.client_order_id = client_order_id;
4982
- }
5230
+ body.client_order_id =
5231
+ client_order_id ??
5232
+ this.deriveClientOrderId([
5233
+ "limit",
5234
+ symbol,
5235
+ side,
5236
+ position_intent,
5237
+ Math.abs(qty),
5238
+ this.roundPriceForAlpaca(limitPrice),
5239
+ extended_hours,
5240
+ ]);
4983
5241
  try {
4984
5242
  return await this.makeRequest("/orders", "POST", body);
4985
5243
  }
@@ -5007,55 +5265,21 @@ class AlpacaTradingAPI {
5007
5265
  return;
5008
5266
  }
5009
5267
  this.log(`Found ${positions.length} positions to close`);
5010
- // Get latest quotes for all positions
5011
- const symbols = positions.map((position) => position.symbol);
5012
- const quotesResponse = await marketDataAPI.getLatestQuotes(symbols);
5013
- const lengthOfQuotes = Object.keys(quotesResponse.quotes).length;
5014
- if (lengthOfQuotes === 0) {
5015
- this.log("No quotes available for positions, received 0 quotes", {
5016
- type: "error",
5017
- });
5018
- return;
5019
- }
5020
- if (lengthOfQuotes !== positions.length) {
5021
- this.log(`Received ${lengthOfQuotes} quotes for ${positions.length} positions, expected ${positions.length} quotes`, { type: "warn" });
5022
- return;
5023
- }
5024
- // Create limit orders to close each position
5025
- for (const position of positions) {
5026
- const quote = quotesResponse.quotes[position.symbol];
5027
- if (!quote) {
5028
- this.log(`No quote available for ${position.symbol}, skipping limit order`, {
5029
- symbol: position.symbol,
5030
- type: "warn",
5031
- });
5032
- continue;
5033
- }
5034
- const qty = Math.abs(parseFloat(position.qty));
5035
- const side = position.side === "long" ? "sell" : "buy";
5036
- const positionIntent = side === "sell" ? "sell_to_close" : "buy_to_close";
5037
- // Get the current price from the quote
5038
- const currentPrice = side === "sell" ? quote.bp : quote.ap; // Use bid for sells, ask for buys
5039
- if (!currentPrice) {
5040
- this.log(`No valid price available for ${position.symbol}, skipping limit order`, {
5041
- symbol: position.symbol,
5042
- type: "warn",
5043
- });
5044
- continue;
5045
- }
5046
- // Apply slippage from config
5047
- const limitSlippagePercent1 = limitPriceSlippagePercent100 / 100;
5048
- const limitPrice = side === "sell"
5049
- ? this.roundPriceForAlpaca(currentPrice * (1 - limitSlippagePercent1)) // Sell slightly lower
5050
- : this.roundPriceForAlpaca(currentPrice * (1 + limitSlippagePercent1)); // Buy slightly higher
5051
- this.log(`Creating limit order to close ${position.symbol} position: ${side} ${qty} shares at $${limitPrice.toFixed(2)}`, {
5052
- symbol: position.symbol,
5053
- });
5054
- await this.createLimitOrder(position.symbol, qty, side, limitPrice, positionIntent);
5055
- }
5268
+ // Flatten each position independently. A missing quote or broker
5269
+ // rejection on one symbol must never abort the flatten of the others; any
5270
+ // per-position failure is surfaced as an aggregate error.
5271
+ await this.flattenPositionsWithLimitOrders(positions, false);
5056
5272
  }
5057
5273
  else {
5058
- await this.makeRequest("/positions", "DELETE", undefined, options.cancel_orders ? "?cancel_orders=true" : "");
5274
+ const results = await this.makeRequest("/positions", "DELETE", undefined, options.cancel_orders ? "?cancel_orders=true" : "");
5275
+ if (Array.isArray(results)) {
5276
+ const failures = this.collectMultiStatusFailures(results);
5277
+ if (failures.length > 0) {
5278
+ const detail = failures.join(", ");
5279
+ this.log(`Error closing all positions: ${failures.length}/${results.length} positions failed to close (${detail})`, { type: "error" });
5280
+ throw new Error(`Failed to close ${failures.length} of ${results.length} positions: ${detail}`);
5281
+ }
5282
+ }
5059
5283
  }
5060
5284
  }
5061
5285
  /**
@@ -5072,44 +5296,22 @@ class AlpacaTradingAPI {
5072
5296
  this.log("No positions to close");
5073
5297
  return;
5074
5298
  }
5075
- await this.cancelAllOrders();
5076
- this.log(`Cancelled all open orders`);
5077
- // Get latest quotes for all positions
5078
- const symbols = positions.map((position) => position.symbol);
5079
- const quotesResponse = await marketDataAPI.getLatestQuotes(symbols);
5080
- // Create limit orders to close each position
5081
- for (const position of positions) {
5082
- const quote = quotesResponse.quotes[position.symbol];
5083
- if (!quote) {
5084
- this.log(`No quote available for ${position.symbol}, skipping limit order`, {
5085
- symbol: position.symbol,
5086
- type: "warn",
5087
- });
5088
- continue;
5089
- }
5090
- const qty = Math.abs(parseFloat(position.qty));
5091
- const side = position.side === "long" ? "sell" : "buy";
5092
- const positionIntent = side === "sell" ? "sell_to_close" : "buy_to_close";
5093
- // Get the current price from the quote
5094
- const currentPrice = side === "sell" ? quote.bp : quote.ap; // Use bid for sells, ask for buys
5095
- if (!currentPrice) {
5096
- this.log(`No valid price available for ${position.symbol}, skipping limit order`, {
5097
- symbol: position.symbol,
5098
- type: "warn",
5099
- });
5100
- continue;
5101
- }
5102
- // Apply slippage from config
5103
- const limitSlippagePercent1 = limitPriceSlippagePercent100 / 100;
5104
- const limitPrice = side === "sell"
5105
- ? this.roundPriceForAlpaca(currentPrice * (1 - limitSlippagePercent1)) // Sell slightly lower
5106
- : this.roundPriceForAlpaca(currentPrice * (1 + limitSlippagePercent1)); // Buy slightly higher
5107
- this.log(`Creating extended hours limit order to close ${position.symbol} position: ${side} ${qty} shares at $${limitPrice.toFixed(2)}`, {
5108
- symbol: position.symbol,
5109
- });
5110
- await this.createLimitOrder(position.symbol, qty, side, limitPrice, positionIntent, true);
5299
+ // Cancelling stale open orders is secondary to the primary failsafe goal of
5300
+ // flattening positions. A cancel failure is logged but must not abort the
5301
+ // flatten, otherwise a single un-cancelable order would leave every position
5302
+ // open. The flatten step below surfaces its own aggregate failure.
5303
+ try {
5304
+ await this.cancelAllOrders();
5305
+ this.log(`Cancelled all open orders`);
5306
+ }
5307
+ catch (error) {
5308
+ this.log(`Proceeding to flatten despite cancelAllOrders failure: ${error instanceof Error ? error.message : String(error)}`, { type: "error" });
5111
5309
  }
5112
- this.log(`All positions closed: ${positions.map((p) => p.symbol).join(", ")}`);
5310
+ // Flatten each position independently with extended-hours limit orders. A
5311
+ // missing quote or broker rejection on one symbol must never silently leave
5312
+ // the remaining positions open; per-position failures are surfaced as an
5313
+ // aggregate error.
5314
+ await this.flattenPositionsWithLimitOrders(positions, true);
5113
5315
  }
5114
5316
  onTradeUpdate(callback) {
5115
5317
  this.tradeUpdateCallback = callback;
@@ -5188,9 +5390,12 @@ class AlpacaTradingAPI {
5188
5390
  * @param position_intent Position intent (buy_to_open, buy_to_close, sell_to_open, sell_to_close)
5189
5391
  * @param type Order type (market or limit)
5190
5392
  * @param limitPrice Limit price (required for limit orders)
5393
+ * @param clientOrderId Optional idempotency key; a deterministic one is
5394
+ * derived from the order parameters when omitted so a client-timeout retry
5395
+ * is de-duplicated broker-side.
5191
5396
  * @returns The created order
5192
5397
  */
5193
- async createOptionOrder(symbol, qty, side, position_intent, type, limitPrice) {
5398
+ async createOptionOrder(symbol, qty, side, position_intent, type, limitPrice, clientOrderId) {
5194
5399
  if (!Number.isInteger(qty) || qty <= 0) {
5195
5400
  this.log("Quantity must be a positive whole number for option orders", {
5196
5401
  type: "error",
@@ -5215,6 +5420,19 @@ class AlpacaTradingAPI {
5215
5420
  if (type === "limit" && limitPrice !== undefined) {
5216
5421
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
5217
5422
  }
5423
+ orderData.client_order_id =
5424
+ clientOrderId ??
5425
+ this.deriveClientOrderId([
5426
+ "option",
5427
+ type,
5428
+ symbol,
5429
+ side,
5430
+ position_intent,
5431
+ qty,
5432
+ type === "limit" && limitPrice !== undefined
5433
+ ? this.roundPriceForAlpaca(limitPrice)
5434
+ : undefined,
5435
+ ]);
5218
5436
  return this.makeRequest("/orders", "POST", orderData);
5219
5437
  }
5220
5438
  /**
@@ -5223,9 +5441,12 @@ class AlpacaTradingAPI {
5223
5441
  * @param qty Quantity of the multi-leg order (must be a whole number)
5224
5442
  * @param type Order type (market or limit)
5225
5443
  * @param limitPrice Limit price (required for limit orders)
5444
+ * @param clientOrderId Optional idempotency key; a deterministic one is
5445
+ * derived from the legs and order parameters when omitted so a
5446
+ * client-timeout retry is de-duplicated broker-side.
5226
5447
  * @returns The created multi-leg order
5227
5448
  */
5228
- async createMultiLegOptionOrder(legs, qty, type, limitPrice) {
5449
+ async createMultiLegOptionOrder(legs, qty, type, limitPrice, clientOrderId) {
5229
5450
  if (!Number.isInteger(qty) || qty <= 0) {
5230
5451
  this.log("Quantity must be a positive whole number for option orders", {
5231
5452
  type: "error",
@@ -5251,6 +5472,17 @@ class AlpacaTradingAPI {
5251
5472
  if (type === "limit" && limitPrice !== undefined) {
5252
5473
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
5253
5474
  }
5475
+ orderData.client_order_id =
5476
+ clientOrderId ??
5477
+ this.deriveClientOrderId([
5478
+ "mleg",
5479
+ type,
5480
+ qty,
5481
+ type === "limit" && limitPrice !== undefined
5482
+ ? this.roundPriceForAlpaca(limitPrice)
5483
+ : undefined,
5484
+ ...legs.map((leg) => `${leg.symbol}:${leg.side}:${leg.ratio_qty}:${leg.position_intent}`),
5485
+ ]);
5254
5486
  return this.makeRequest("/orders", "POST", orderData);
5255
5487
  }
5256
5488
  /**
@@ -5728,9 +5960,26 @@ class AlpacaTradingAPI {
5728
5960
  extended_hours: extendedHours,
5729
5961
  position_intent: side === "buy" ? "buy_to_open" : "sell_to_open",
5730
5962
  };
5731
- if (clientOrderId) {
5732
- orderData.client_order_id = clientOrderId;
5733
- }
5963
+ orderData.client_order_id =
5964
+ clientOrderId ??
5965
+ this.deriveClientOrderId([
5966
+ "equities",
5967
+ orderClass,
5968
+ type,
5969
+ symbol,
5970
+ side,
5971
+ Math.abs(qty),
5972
+ type === "limit" && limitPrice !== undefined
5973
+ ? this.roundPriceForAlpaca(limitPrice)
5974
+ : undefined,
5975
+ extendedHours,
5976
+ useStopLoss && calculatedStopPrice !== undefined
5977
+ ? this.roundPriceForAlpaca(calculatedStopPrice)
5978
+ : undefined,
5979
+ useTakeProfit && calculatedTakeProfitPrice !== undefined
5980
+ ? this.roundPriceForAlpaca(calculatedTakeProfitPrice)
5981
+ : undefined,
5982
+ ]);
5734
5983
  // Add limit price for limit orders
5735
5984
  if (type === "limit" && limitPrice !== undefined) {
5736
5985
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
@@ -8406,6 +8655,13 @@ var atrNs = /*#__PURE__*/Object.freeze({
8406
8655
  });
8407
8656
 
8408
8657
  const ALPACA_API_BASE = MARKET_DATA_API.CRYPTO;
8658
+ /**
8659
+ * Hard upper bound on the number of paginated news pages fetched in a single
8660
+ * {@link fetchNews} call. Acts as a runaway-loop backstop that is independent of
8661
+ * the caller-supplied `limit`, mirroring the max-page guard the equities
8662
+ * paginator already enforces.
8663
+ */
8664
+ const MAX_NEWS_PAGES = 100;
8409
8665
  /**
8410
8666
  * Fetches cryptocurrency bars for the specified parameters.
8411
8667
  * This function retrieves historical price data for multiple cryptocurrencies.
@@ -8505,17 +8761,25 @@ async function fetchNews(params, auth) {
8505
8761
  include_content: includeContent.toString(),
8506
8762
  limit: limit.toString(),
8507
8763
  });
8508
- const url = `${ALPACA_API_BASE}/news?${queryParams}`;
8509
- logIfDebug(`Fetching news from: ${url}`);
8510
- let newsArticles = [];
8764
+ const authHeaders = {
8765
+ "APCA-API-KEY-ID": auth.APIKey,
8766
+ "APCA-API-SECRET-KEY": auth.APISecret,
8767
+ };
8768
+ const newsArticles = [];
8511
8769
  let pageToken = null;
8512
- let hasMorePages = true;
8513
- while (hasMorePages) {
8770
+ let pageCount = 0;
8771
+ while (pageCount < MAX_NEWS_PAGES) {
8772
+ // Rebuild the request URL on every iteration so the pagination cursor is
8773
+ // actually applied. Using `set` (not `append`) overwrites the previous
8774
+ // cursor instead of accumulating stale `page_token` values across pages.
8514
8775
  if (pageToken) {
8515
- queryParams.append("page_token", pageToken);
8776
+ queryParams.set("page_token", pageToken);
8516
8777
  }
8778
+ const url = `${ALPACA_API_BASE}/news?${queryParams.toString()}`;
8779
+ logIfDebug(`Fetching news from: ${url}`);
8517
8780
  await withRetry(async () => {
8518
8781
  const response = await fetch(url, {
8782
+ headers: authHeaders,
8519
8783
  signal: createTimeoutSignal(DEFAULT_TIMEOUTS.ALPACA_API),
8520
8784
  });
8521
8785
  if (!response.ok) {
@@ -8523,7 +8787,7 @@ async function fetchNews(params, auth) {
8523
8787
  throw new Error(`Alpaca API error (${response.status}): ${errorText}`);
8524
8788
  }
8525
8789
  const data = await response.json();
8526
- newsArticles = newsArticles.concat(data.news.map((article) => ({
8790
+ const pageArticles = (data.news ?? []).map((article) => ({
8527
8791
  id: article.id,
8528
8792
  author: article.author,
8529
8793
  content: article.content,
@@ -8535,11 +8799,17 @@ async function fetchNews(params, auth) {
8535
8799
  url: article.url,
8536
8800
  symbols: article.symbols,
8537
8801
  images: article.images,
8538
- })));
8802
+ }));
8803
+ newsArticles.push(...pageArticles);
8539
8804
  pageToken = data.next_page_token ?? null;
8540
- hasMorePages = !!pageToken;
8541
- logIfDebug(`Received ${data.news.length} news articles. More pages: ${hasMorePages}`);
8805
+ logIfDebug(`Received ${pageArticles.length} news articles. Next page token: ${pageToken ? "present" : "none"}`);
8542
8806
  }, API_RETRY_CONFIGS.CRYPTO, `Crypto.fetchNews(${symbol})`);
8807
+ pageCount++;
8808
+ // Terminate once the API reports no further pages or once we have
8809
+ // accumulated at least the requested number of articles.
8810
+ if (!pageToken || newsArticles.length >= limit) {
8811
+ break;
8812
+ }
8543
8813
  }
8544
8814
  // If sort is "asc" and limit is 10, return only the 10 most recent articles
8545
8815
  if (sort === "asc" && limit === 10) {
@@ -9756,33 +10026,138 @@ const formatIndicesBarData = (data) => {
9756
10026
  };
9757
10027
 
9758
10028
  // price-utils.ts
10029
+ // ---------------------------------------------------------------------------
10030
+ // Transaction-cost (fee) model
10031
+ //
10032
+ // Alpaca's REST order object does not expose the realized per-order fee, so the
10033
+ // transaction cost is reconstructed from the published fee schedules, branching
10034
+ // on the order's ACTUAL asset class (never a hardcoded STOCK). Every rate is a
10035
+ // named constant sourced from Alpaca / SEC / FINRA public schedules (2024-2025)
10036
+ // so it can be audited and updated in one place.
10037
+ // ---------------------------------------------------------------------------
10038
+ /** Basis points in one whole unit (1 = 10,000 bps). */
10039
+ const BPS_PER_UNIT = 10_000;
10040
+ /** Shares represented by one US listed option contract. */
10041
+ const OPTIONS_CONTRACT_MULTIPLIER = 100;
10042
+ /**
10043
+ * SEC Section 31 fee, charged on the principal of SELL orders for equities and
10044
+ * options. FY2024+ rate: USD 8.00 per USD 1,000,000 of principal.
10045
+ */
10046
+ const SEC_SECTION31_FEE_PER_USD = 8.0 / 1_000_000;
10047
+ /** FINRA Trading Activity Fee (TAF) for equity sells: USD per share sold. */
10048
+ const FINRA_TAF_EQUITY_PER_SHARE = 0.000166;
10049
+ /** FINRA TAF for option sells: USD per contract sold. */
10050
+ const FINRA_TAF_OPTIONS_PER_CONTRACT = 0.00279;
10051
+ /** FINRA TAF is capped per trade regardless of size. */
10052
+ const FINRA_TAF_MAX_PER_TRADE = 8.3;
10053
+ /** OCC clearing fee per option contract, capped per trade. */
10054
+ const OCC_CLEARING_FEE_PER_CONTRACT = 0.02;
10055
+ const OCC_CLEARING_FEE_MAX_PER_TRADE = 55.0;
10056
+ /**
10057
+ * Options Regulatory Fee (ORF) pass-through, charged on both sides, USD per
10058
+ * contract. Published, exchange-set pass-through rate.
10059
+ */
10060
+ const OPTIONS_REGULATORY_FEE_PER_CONTRACT = 0.02685;
10061
+ /**
10062
+ * Alpaca crypto TAKER fee schedule as `[minTrailing30dVolumeUsd, takerBps]`,
10063
+ * ordered ascending by volume threshold. Market orders are takers; absent a
10064
+ * known trailing-30-day volume we conservatively select the tier-1 (highest)
10065
+ * taker rate. Source: Alpaca Crypto fee schedule.
10066
+ */
10067
+ const ALPACA_CRYPTO_TAKER_FEE_TIERS_BPS = [
10068
+ [0, 25],
10069
+ [100_000, 22],
10070
+ [500_000, 20],
10071
+ [1_000_000, 18],
10072
+ [10_000_000, 15],
10073
+ [25_000_000, 13],
10074
+ [50_000_000, 12],
10075
+ [100_000_000, 10],
10076
+ ];
10077
+ /**
10078
+ * Resolve the applicable Alpaca crypto taker fee (in bps) for a trailing
10079
+ * 30-day USD volume. Defaults to the tier-1 rate when the volume is unknown.
10080
+ * @param trailing30dVolumeUsd - Trailing 30-day traded notional in USD.
10081
+ * @returns The taker fee in basis points.
10082
+ */
10083
+ function resolveCryptoTakerBps(trailing30dVolumeUsd) {
10084
+ let bps = ALPACA_CRYPTO_TAKER_FEE_TIERS_BPS[0][1];
10085
+ for (const [threshold, tierBps] of ALPACA_CRYPTO_TAKER_FEE_TIERS_BPS) {
10086
+ if (trailing30dVolumeUsd >= threshold) {
10087
+ bps = tierBps;
10088
+ }
10089
+ else {
10090
+ break;
10091
+ }
10092
+ }
10093
+ return bps;
10094
+ }
10095
+ /**
10096
+ * Computes the realized transaction cost (fees + regulatory charges) for the
10097
+ * Alpaca order backing a single {@link types.Action}, branching on the order's
10098
+ * actual asset class. Returns 0 only when there is genuinely no order to price
10099
+ * (no linked order id, order not found, or nothing filled) — never as a
10100
+ * fabricated success.
10101
+ * @param action - The action whose linked Alpaca order should be priced.
10102
+ * @param trade - The parent trade (supplies the Alpaca account id).
10103
+ * @param alpacaAccount - The Alpaca account supplying broker credentials.
10104
+ * @returns The total fee in account currency (USD).
10105
+ */
9759
10106
  const calculateFees = async (action, trade, alpacaAccount) => {
9760
- let fee = 0;
9761
10107
  const alpacaOrderId = action.alpacaOrderId;
9762
10108
  if (!alpacaOrderId)
9763
- return fee;
10109
+ return 0;
9764
10110
  const order = await getOrder$1({
9765
10111
  adapticAccountId: trade.alpacaAccountId,
9766
10112
  alpacaApiKey: alpacaAccount.APIKey,
9767
10113
  alpacaApiSecret: alpacaAccount.APISecret,
9768
10114
  }, alpacaOrderId);
9769
10115
  if (!order)
9770
- return fee;
9771
- const assetType = "STOCK";
9772
- Number(order.qty) || 0;
9773
- order.notional || 0;
9774
- Number(order.filled_avg_price || order.limit_price || order.stop_price) ||
10116
+ return 0;
10117
+ const filledQty = Number(order.filled_qty) || 0;
10118
+ const filledPrice = Number(order.filled_avg_price ?? order.limit_price ?? order.stop_price) ||
9775
10119
  0;
9776
- switch (assetType) {
9777
- case "STOCK":
9778
- // Currently zero fees for stocks via Alpaca
9779
- fee = 0;
9780
- break;
9781
- default:
9782
- fee = 0;
9783
- break;
10120
+ // Realized notional prefers the actual fill (qty * avg price); it falls back
10121
+ // to the order's notional field for dollar-notional (fractional) orders.
10122
+ const notional = filledQty > 0 && filledPrice > 0
10123
+ ? filledQty * filledPrice
10124
+ : Number(order.notional ?? 0) || 0;
10125
+ if (notional <= 0)
10126
+ return 0;
10127
+ const isSell = order.side === "sell";
10128
+ switch (order.asset_class) {
10129
+ case "crypto": {
10130
+ // Crypto fees are bps of notional. Without a known 30-day volume we use
10131
+ // the conservative tier-1 taker rate.
10132
+ const takerBps = resolveCryptoTakerBps(0);
10133
+ return (notional * takerBps) / BPS_PER_UNIT;
10134
+ }
10135
+ case "us_option": {
10136
+ const contracts = filledQty > 0 ? filledQty : Number(order.qty) || 0;
10137
+ const occFee = Math.min(contracts * OCC_CLEARING_FEE_PER_CONTRACT, OCC_CLEARING_FEE_MAX_PER_TRADE);
10138
+ const orfFee = contracts * OPTIONS_REGULATORY_FEE_PER_CONTRACT;
10139
+ let fee = occFee + orfFee;
10140
+ if (isSell) {
10141
+ // Option premium is quoted per share; SEC fee applies to the full
10142
+ // principal (premium * contract multiplier).
10143
+ const optionPrincipal = notional * OPTIONS_CONTRACT_MULTIPLIER;
10144
+ const secFee = optionPrincipal * SEC_SECTION31_FEE_PER_USD;
10145
+ const taf = Math.min(contracts * FINRA_TAF_OPTIONS_PER_CONTRACT, FINRA_TAF_MAX_PER_TRADE);
10146
+ fee += secFee + taf;
10147
+ }
10148
+ return fee;
10149
+ }
10150
+ case "us_equity":
10151
+ default: {
10152
+ // Alpaca charges USD 0 commission on US equities; only sell-side
10153
+ // regulatory charges (SEC Section 31 + FINRA TAF) apply.
10154
+ if (!isSell)
10155
+ return 0;
10156
+ const secFee = notional * SEC_SECTION31_FEE_PER_USD;
10157
+ const taf = Math.min(filledQty * FINRA_TAF_EQUITY_PER_SHARE, FINRA_TAF_MAX_PER_TRADE);
10158
+ return secFee + taf;
10159
+ }
9784
10160
  }
9785
- return fee;
9786
10161
  };
9787
10162
  const computeTotalFees = async (trade) => {
9788
10163
  let totalFees = 0;
@@ -10651,16 +11026,76 @@ async function calculateExpenseRatio({ accountId, client, alpacaAccount, }) {
10651
11026
  return "N/A";
10652
11027
  }
10653
11028
  const equity = parseFloat(accountDetails.equity);
10654
- // Fetch portfolio expenses from your system (Assuming you have this data)
10655
- const expenses = await getPortfolioExpensesFromYourSystem();
10656
- // Calculate expense ratio
11029
+ // Fetch the account's real trailing fee expenses from Alpaca account
11030
+ // activities. A genuine data-source failure yields "N/A" (unknown) rather
11031
+ // than a fabricated 0.00%.
11032
+ const auth = {
11033
+ adapticAccountId: alpacaAccountId,
11034
+ alpacaApiKey: alpacaAccount?.APIKey,
11035
+ alpacaApiSecret: alpacaAccount?.APISecret,
11036
+ };
11037
+ let expenses;
11038
+ try {
11039
+ expenses = await fetchTrailingFeeExpenses(auth);
11040
+ }
11041
+ catch (error) {
11042
+ getLogger().warn("Failed to fetch Alpaca account fee activities for expense ratio.", { error });
11043
+ return "N/A";
11044
+ }
11045
+ // Calculate expense ratio (trailing fees as a percentage of current equity).
10657
11046
  const expenseRatio = (expenses / equity) * 100;
10658
11047
  return `${expenseRatio.toFixed(2)}%`;
10659
11048
  }
10660
- // Mock function to represent fetching expenses from your system
10661
- async function getPortfolioExpensesFromYourSystem(_accountId) {
10662
- // Implement this function based on your data storage
10663
- return 0; // Placeholder
11049
+ /** Trailing window over which account fees are aggregated for the expense ratio. */
11050
+ const EXPENSE_TRAILING_WINDOW_DAYS = 365;
11051
+ /** Milliseconds in one day. */
11052
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
11053
+ /** Alpaca account-activity types that represent fees/regulatory charges. */
11054
+ const FEE_ACTIVITY_TYPES = "FEE,REG,CFEE";
11055
+ /** Page size for the paginated Alpaca account-activities endpoint. */
11056
+ const ACTIVITIES_PAGE_SIZE = 100;
11057
+ /** Hard cap on activity pages to bound pagination on unexpected responses. */
11058
+ const ACTIVITIES_MAX_PAGES = 1000;
11059
+ /**
11060
+ * Aggregates the account's fee/regulatory charges over the trailing window from
11061
+ * the Alpaca account-activities endpoint, following id-based pagination.
11062
+ * @param auth - Alpaca authentication (account id and/or direct API keys).
11063
+ * @returns Total fees in account currency (USD) as a positive number.
11064
+ */
11065
+ async function fetchTrailingFeeExpenses(auth) {
11066
+ const after = new Date(Date.now() - EXPENSE_TRAILING_WINDOW_DAYS * MS_PER_DAY).toISOString();
11067
+ let total = 0;
11068
+ let pageToken;
11069
+ for (let page = 0; page < ACTIVITIES_MAX_PAGES; page++) {
11070
+ const queryParams = new URLSearchParams({
11071
+ activity_types: FEE_ACTIVITY_TYPES,
11072
+ after,
11073
+ page_size: String(ACTIVITIES_PAGE_SIZE),
11074
+ });
11075
+ if (pageToken) {
11076
+ queryParams.append("page_token", pageToken);
11077
+ }
11078
+ const activities = await makeRequest(auth, {
11079
+ endpoint: "/account/activities",
11080
+ method: "GET",
11081
+ queryString: `?${queryParams.toString()}`,
11082
+ });
11083
+ if (!Array.isArray(activities) || activities.length === 0) {
11084
+ break;
11085
+ }
11086
+ for (const activity of activities) {
11087
+ const amount = parseFloat(activity.net_amount ?? "");
11088
+ if (Number.isFinite(amount)) {
11089
+ // Fee entries are debits (negative net_amount); accumulate magnitude.
11090
+ total += Math.abs(amount);
11091
+ }
11092
+ }
11093
+ if (activities.length < ACTIVITIES_PAGE_SIZE) {
11094
+ break;
11095
+ }
11096
+ pageToken = activities[activities.length - 1].id;
11097
+ }
11098
+ return total;
10664
11099
  }
10665
11100
  /**
10666
11101
  * Calculates the liquidity ratio for a given Alpaca account.
@@ -11320,6 +11755,58 @@ async function calculateInformationRatio(portfolioHistory, benchmarkBars) {
11320
11755
  }
11321
11756
  return informationRatio.toFixed(4);
11322
11757
  }
11758
+ /**
11759
+ * Maps a portfolio-history timeframe token to the Alpaca market-data
11760
+ * {@link TimeFrame} accepted by the historical-bars endpoint. Benchmark
11761
+ * comparison is daily by default when no timeframe is supplied.
11762
+ * @param timeframe - The portfolio-history timeframe token.
11763
+ * @returns The equivalent Alpaca market-data timeframe.
11764
+ */
11765
+ function toAlpacaTimeFrame(timeframe) {
11766
+ switch (timeframe) {
11767
+ case "1Min":
11768
+ return "1Min";
11769
+ case "5Min":
11770
+ return "5Min";
11771
+ case "15Min":
11772
+ return "15Min";
11773
+ case "1H":
11774
+ return "1Hour";
11775
+ case "1D":
11776
+ return "1Day";
11777
+ default:
11778
+ return "1Day";
11779
+ }
11780
+ }
11781
+ /** Milliseconds per second, for RFC-3339 → Unix-second conversion. */
11782
+ const MS_PER_SECOND = 1000;
11783
+ /**
11784
+ * Fetches benchmark OHLCV bars from the wrapped Alpaca market-data vendor and
11785
+ * maps them into {@link BenchmarkBar}s (Unix-second timestamp + close price)
11786
+ * expected by the alpha/beta/information-ratio calculators.
11787
+ * @param request - Benchmark symbol, RFC-3339 start/end, and timeframe token.
11788
+ * @returns The benchmark bars, sorted ascending by time; empty if none.
11789
+ */
11790
+ async function fetchBenchmarkBars(request) {
11791
+ const { symbol, start, end, timeframe } = request;
11792
+ const response = await marketDataAPI.getHistoricalBars({
11793
+ symbols: [symbol],
11794
+ timeframe: toAlpacaTimeFrame(timeframe),
11795
+ start,
11796
+ end,
11797
+ sort: "asc",
11798
+ });
11799
+ const bars = response.bars[symbol];
11800
+ if (!Array.isArray(bars) || bars.length === 0) {
11801
+ return [];
11802
+ }
11803
+ return bars
11804
+ .map((bar) => ({
11805
+ t: Math.floor(new Date(bar.t).getTime() / MS_PER_SECOND),
11806
+ c: bar.c,
11807
+ }))
11808
+ .filter((bar) => Number.isFinite(bar.t) && Number.isFinite(bar.c));
11809
+ }
11323
11810
  /**
11324
11811
  * Fetches performance metrics for a given Alpaca account.
11325
11812
  * @param params - The parameters for fetching performance metrics.
@@ -11384,7 +11871,10 @@ async function fetchPerformanceMetrics({ params, client, accountId, alpacaAccoun
11384
11871
  getLogger().error("[fetchPerformanceMetrics] Error fetching portfolio history:", error);
11385
11872
  throw new Error("Failed to retrieve portfolio history data");
11386
11873
  }
11387
- // Fetch benchmark data with enhanced error handling
11874
+ // Fetch benchmark data directly from the wrapped Alpaca market-data vendor.
11875
+ // (Previously this hit a relative "/api/market-data/historical-prices"
11876
+ // Next.js route that only resolves in a browser; in a Node/engine runtime
11877
+ // the relative fetch always threw, silently zeroing out alpha/beta/IR.)
11388
11878
  const benchmarkSymbol = "SPY";
11389
11879
  let benchmarkBars = [];
11390
11880
  try {
@@ -11395,24 +11885,16 @@ async function fetchPerformanceMetrics({ params, client, accountId, alpacaAccoun
11395
11885
  : params?.period
11396
11886
  ? params?.period
11397
11887
  : "1Y",
11398
- outputFormat: "unix-ms",
11888
+ outputFormat: "iso",
11399
11889
  intraday_reporting: params?.intraday_reporting,
11400
11890
  });
11401
- const response = await fetch(`/api/market-data/historical-prices?symbol=${benchmarkSymbol}&start=${start.toString()}&end=${end.toString()}&timeframe=${params.timeframe}`, {
11402
- method: "GET",
11403
- headers: {
11404
- "Content-Type": "application/json",
11405
- },
11406
- signal: createTimeoutSignal(DEFAULT_TIMEOUTS.GENERAL),
11891
+ benchmarkBars = await fetchBenchmarkBars({
11892
+ symbol: benchmarkSymbol,
11893
+ start: String(start),
11894
+ end: String(end),
11895
+ timeframe: params.timeframe,
11407
11896
  });
11408
- if (!response.ok) {
11409
- const errorText = await response.text();
11410
- throw new Error(`Failed to fetch benchmark data: ${response.statusText} - ${errorText}`);
11411
- }
11412
- benchmarkBars = await response.json();
11413
- if (!benchmarkBars ||
11414
- !Array.isArray(benchmarkBars) ||
11415
- benchmarkBars.length === 0) {
11897
+ if (benchmarkBars.length === 0) {
11416
11898
  throw new Error("Received empty or invalid benchmark data");
11417
11899
  }
11418
11900
  }