@adaptic/utils 0.0.1001 → 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.
Files changed (38) hide show
  1. package/dist/index.cjs +802 -183
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.mjs +800 -184
  4. package/dist/index.mjs.map +1 -1
  5. package/dist/test.js +70 -0
  6. package/dist/test.js.map +1 -1
  7. package/dist/types/__tests__/broker-factory.test.d.ts +2 -0
  8. package/dist/types/__tests__/broker-factory.test.d.ts.map +1 -0
  9. package/dist/types/__tests__/broker-types.test.d.ts +2 -0
  10. package/dist/types/__tests__/broker-types.test.d.ts.map +1 -0
  11. package/dist/types/alpaca/client.d.ts +1 -1
  12. package/dist/types/alpaca/client.d.ts.map +1 -1
  13. package/dist/types/alpaca/legacy/auth.d.ts +27 -0
  14. package/dist/types/alpaca/legacy/auth.d.ts.map +1 -1
  15. package/dist/types/alpaca/legacy/index.d.ts +1 -1
  16. package/dist/types/alpaca/legacy/index.d.ts.map +1 -1
  17. package/dist/types/alpaca-trading-api.d.ts +68 -4
  18. package/dist/types/alpaca-trading-api.d.ts.map +1 -1
  19. package/dist/types/broker/factory.d.ts +70 -0
  20. package/dist/types/broker/factory.d.ts.map +1 -0
  21. package/dist/types/broker/index.d.ts +9 -0
  22. package/dist/types/broker/index.d.ts.map +1 -0
  23. package/dist/types/crypto.d.ts.map +1 -1
  24. package/dist/types/errors/index.d.ts +14 -0
  25. package/dist/types/errors/index.d.ts.map +1 -1
  26. package/dist/types/index.d.ts +4 -3
  27. package/dist/types/index.d.ts.map +1 -1
  28. package/dist/types/performance-metrics.d.ts.map +1 -1
  29. package/dist/types/price-utils.d.ts.map +1 -1
  30. package/dist/types/rate-limiter.d.ts +21 -0
  31. package/dist/types/rate-limiter.d.ts.map +1 -1
  32. package/dist/types/types/alpaca-types.d.ts +2 -0
  33. package/dist/types/types/alpaca-types.d.ts.map +1 -1
  34. package/dist/types/types/broker-types.d.ts +112 -0
  35. package/dist/types/types/broker-types.d.ts.map +1 -0
  36. package/dist/types/types/index.d.ts +1 -0
  37. package/dist/types/types/index.d.ts.map +1 -1
  38. package/package.json +1 -1
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');
@@ -2359,6 +2360,23 @@ class NetworkError extends AdapticUtilsError {
2359
2360
  this.service = service;
2360
2361
  }
2361
2362
  }
2363
+ /**
2364
+ * Unsupported brokerage provider errors
2365
+ * Thrown when a broker operation is requested for a provider that has no
2366
+ * implemented integration (e.g. IBKR or COINBASE before their adapters land,
2367
+ * or an unrecognised provider string from an untyped caller).
2368
+ * Never retryable — the caller must route to a supported provider.
2369
+ */
2370
+ class UnsupportedBrokerError extends AdapticUtilsError {
2371
+ provider;
2372
+ constructor(
2373
+ /** The provider that was requested but is not supported. */
2374
+ provider, cause) {
2375
+ super(`Brokerage provider "${provider}" is not supported. Supported providers: ALPACA`, "UNSUPPORTED_BROKER", "broker", false, // Unsupported providers are never retryable
2376
+ cause);
2377
+ this.provider = provider;
2378
+ }
2379
+ }
2362
2380
  /**
2363
2381
  * Data parsing and format errors
2364
2382
  * Used when API responses cannot be parsed or are in unexpected format
@@ -2388,6 +2406,19 @@ class DataFormatError extends AdapticUtilsError {
2388
2406
  * const result = await makeAlpacaApiCall();
2389
2407
  * ```
2390
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;
2391
2422
  /**
2392
2423
  * Token bucket rate limiter implementation
2393
2424
  *
@@ -2403,6 +2434,13 @@ class TokenBucketRateLimiter {
2403
2434
  queue = [];
2404
2435
  timeoutMs;
2405
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;
2406
2444
  /**
2407
2445
  * Creates a new rate limiter instance
2408
2446
  *
@@ -2476,8 +2514,48 @@ class TokenBucketRateLimiter {
2476
2514
  reject(error);
2477
2515
  }, this.timeoutMs);
2478
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();
2479
2520
  });
2480
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
+ }
2481
2559
  /**
2482
2560
  * Refills tokens based on elapsed time and processes queued requests
2483
2561
  *
@@ -2522,6 +2600,15 @@ class TokenBucketRateLimiter {
2522
2600
  finally {
2523
2601
  this.processingQueue = false;
2524
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
+ }
2525
2612
  }
2526
2613
  /**
2527
2614
  * Gets the current number of available tokens
@@ -2553,6 +2640,7 @@ class TokenBucketRateLimiter {
2553
2640
  clearTimeout(request.timeoutHandle);
2554
2641
  request.reject(new RateLimitError(`Rate limiter reset for ${this.config.label}`, this.config.label, undefined));
2555
2642
  }
2643
+ this.clearWakeTimer();
2556
2644
  this.queue = [];
2557
2645
  this.tokens = this.config.maxTokens;
2558
2646
  this.lastRefill = Date.now();
@@ -4302,6 +4390,46 @@ const limitPriceSlippagePercent100 = 0.1; // 0.1%
4302
4390
  const ORDER_PAGE_LIMIT = 500;
4303
4391
  /** Delay between order pagination pages to stay clear of rate limits. */
4304
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;
4305
4433
  /**
4306
4434
  Websocket example
4307
4435
  const alpacaAPI = createAlpacaTradingAPI(credentials); // type AlpacaCredentials
@@ -4382,6 +4510,114 @@ class AlpacaTradingAPI {
4382
4510
  ? Math.round(price * 100) / 100
4383
4511
  : Math.round(price * 10000) / 10000;
4384
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
+ }
4385
4621
  handleAuthMessage(data) {
4386
4622
  if (data.status === "authorized") {
4387
4623
  this.authenticated = true;
@@ -4753,21 +4989,31 @@ class AlpacaTradingAPI {
4753
4989
  * @param position_intent (string) - the position intent of the order
4754
4990
  * @returns The created AlpacaOrder with order ID and details
4755
4991
  */
4756
- async createTrailingStop(symbol, qty, side, trailPercent100, position_intent) {
4992
+ async createTrailingStop(symbol, qty, side, trailPercent100, position_intent, clientOrderId) {
4757
4993
  this.log(`Creating trailing stop ${side.toUpperCase()} ${qty} shares for ${symbol} with trail percent ${trailPercent100}%`, {
4758
4994
  symbol,
4759
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
+ };
4760
5015
  try {
4761
- const order = await this.makeRequest(`/orders`, "POST", {
4762
- symbol,
4763
- qty: Math.abs(qty),
4764
- side,
4765
- position_intent,
4766
- order_class: "simple",
4767
- type: "trailing_stop",
4768
- trail_percent: trailPercent100, // Already in decimal form (e.g., 4 for 4%)
4769
- time_in_force: "gtc",
4770
- });
5016
+ const order = await this.makeRequest(`/orders`, "POST", body);
4771
5017
  this.log(`Trailing stop order created for ${symbol}: orderId=${order.id}, trailPercent=${trailPercent100}%`, { symbol });
4772
5018
  return order;
4773
5019
  }
@@ -4799,9 +5045,15 @@ class AlpacaTradingAPI {
4799
5045
  time_in_force: "day",
4800
5046
  order_class: "simple",
4801
5047
  };
4802
- if (client_order_id !== undefined) {
4803
- body.client_order_id = client_order_id;
4804
- }
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
+ ]);
4805
5057
  try {
4806
5058
  return await this.makeRequest("/orders", "POST", body);
4807
5059
  }
@@ -4902,16 +5154,31 @@ class AlpacaTradingAPI {
4902
5154
  }
4903
5155
  }
4904
5156
  /**
4905
- * 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.
4906
5168
  */
4907
5169
  async cancelAllOrders() {
4908
5170
  this.log(`Canceling all open orders`);
4909
- try {
4910
- await this.makeRequest("/orders", "DELETE");
5171
+ const results = await this.makeRequest("/orders", "DELETE");
5172
+ if (!Array.isArray(results)) {
5173
+ return;
4911
5174
  }
4912
- catch (error) {
4913
- 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}`);
4914
5180
  }
5181
+ this.log(`Successfully canceled ${results.length} open orders`);
4915
5182
  }
4916
5183
  /**
4917
5184
  * Cancel a specific order by its ID
@@ -4962,9 +5229,17 @@ class AlpacaTradingAPI {
4962
5229
  order_class: "simple",
4963
5230
  extended_hours,
4964
5231
  };
4965
- if (client_order_id !== undefined) {
4966
- body.client_order_id = client_order_id;
4967
- }
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
+ ]);
4968
5243
  try {
4969
5244
  return await this.makeRequest("/orders", "POST", body);
4970
5245
  }
@@ -4992,55 +5267,21 @@ class AlpacaTradingAPI {
4992
5267
  return;
4993
5268
  }
4994
5269
  this.log(`Found ${positions.length} positions to close`);
4995
- // Get latest quotes for all positions
4996
- const symbols = positions.map((position) => position.symbol);
4997
- const quotesResponse = await marketDataAPI.getLatestQuotes(symbols);
4998
- const lengthOfQuotes = Object.keys(quotesResponse.quotes).length;
4999
- if (lengthOfQuotes === 0) {
5000
- this.log("No quotes available for positions, received 0 quotes", {
5001
- type: "error",
5002
- });
5003
- return;
5004
- }
5005
- if (lengthOfQuotes !== positions.length) {
5006
- this.log(`Received ${lengthOfQuotes} quotes for ${positions.length} positions, expected ${positions.length} quotes`, { type: "warn" });
5007
- return;
5008
- }
5009
- // Create limit orders to close each position
5010
- for (const position of positions) {
5011
- const quote = quotesResponse.quotes[position.symbol];
5012
- if (!quote) {
5013
- this.log(`No quote available for ${position.symbol}, skipping limit order`, {
5014
- symbol: position.symbol,
5015
- type: "warn",
5016
- });
5017
- continue;
5018
- }
5019
- const qty = Math.abs(parseFloat(position.qty));
5020
- const side = position.side === "long" ? "sell" : "buy";
5021
- const positionIntent = side === "sell" ? "sell_to_close" : "buy_to_close";
5022
- // Get the current price from the quote
5023
- const currentPrice = side === "sell" ? quote.bp : quote.ap; // Use bid for sells, ask for buys
5024
- if (!currentPrice) {
5025
- this.log(`No valid price available for ${position.symbol}, skipping limit order`, {
5026
- symbol: position.symbol,
5027
- type: "warn",
5028
- });
5029
- continue;
5030
- }
5031
- // Apply slippage from config
5032
- const limitSlippagePercent1 = limitPriceSlippagePercent100 / 100;
5033
- const limitPrice = side === "sell"
5034
- ? this.roundPriceForAlpaca(currentPrice * (1 - limitSlippagePercent1)) // Sell slightly lower
5035
- : this.roundPriceForAlpaca(currentPrice * (1 + limitSlippagePercent1)); // Buy slightly higher
5036
- this.log(`Creating limit order to close ${position.symbol} position: ${side} ${qty} shares at $${limitPrice.toFixed(2)}`, {
5037
- symbol: position.symbol,
5038
- });
5039
- await this.createLimitOrder(position.symbol, qty, side, limitPrice, positionIntent);
5040
- }
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);
5041
5274
  }
5042
5275
  else {
5043
- 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
+ }
5044
5285
  }
5045
5286
  }
5046
5287
  /**
@@ -5057,44 +5298,22 @@ class AlpacaTradingAPI {
5057
5298
  this.log("No positions to close");
5058
5299
  return;
5059
5300
  }
5060
- await this.cancelAllOrders();
5061
- this.log(`Cancelled all open orders`);
5062
- // Get latest quotes for all positions
5063
- const symbols = positions.map((position) => position.symbol);
5064
- const quotesResponse = await marketDataAPI.getLatestQuotes(symbols);
5065
- // Create limit orders to close each position
5066
- for (const position of positions) {
5067
- const quote = quotesResponse.quotes[position.symbol];
5068
- if (!quote) {
5069
- this.log(`No quote available for ${position.symbol}, skipping limit order`, {
5070
- symbol: position.symbol,
5071
- type: "warn",
5072
- });
5073
- continue;
5074
- }
5075
- const qty = Math.abs(parseFloat(position.qty));
5076
- const side = position.side === "long" ? "sell" : "buy";
5077
- const positionIntent = side === "sell" ? "sell_to_close" : "buy_to_close";
5078
- // Get the current price from the quote
5079
- const currentPrice = side === "sell" ? quote.bp : quote.ap; // Use bid for sells, ask for buys
5080
- if (!currentPrice) {
5081
- this.log(`No valid price available for ${position.symbol}, skipping limit order`, {
5082
- symbol: position.symbol,
5083
- type: "warn",
5084
- });
5085
- continue;
5086
- }
5087
- // Apply slippage from config
5088
- const limitSlippagePercent1 = limitPriceSlippagePercent100 / 100;
5089
- const limitPrice = side === "sell"
5090
- ? this.roundPriceForAlpaca(currentPrice * (1 - limitSlippagePercent1)) // Sell slightly lower
5091
- : this.roundPriceForAlpaca(currentPrice * (1 + limitSlippagePercent1)); // Buy slightly higher
5092
- this.log(`Creating extended hours limit order to close ${position.symbol} position: ${side} ${qty} shares at $${limitPrice.toFixed(2)}`, {
5093
- symbol: position.symbol,
5094
- });
5095
- 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`);
5308
+ }
5309
+ catch (error) {
5310
+ this.log(`Proceeding to flatten despite cancelAllOrders failure: ${error instanceof Error ? error.message : String(error)}`, { type: "error" });
5096
5311
  }
5097
- this.log(`All positions closed: ${positions.map((p) => p.symbol).join(", ")}`);
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);
5098
5317
  }
5099
5318
  onTradeUpdate(callback) {
5100
5319
  this.tradeUpdateCallback = callback;
@@ -5173,9 +5392,12 @@ class AlpacaTradingAPI {
5173
5392
  * @param position_intent Position intent (buy_to_open, buy_to_close, sell_to_open, sell_to_close)
5174
5393
  * @param type Order type (market or limit)
5175
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.
5176
5398
  * @returns The created order
5177
5399
  */
5178
- async createOptionOrder(symbol, qty, side, position_intent, type, limitPrice) {
5400
+ async createOptionOrder(symbol, qty, side, position_intent, type, limitPrice, clientOrderId) {
5179
5401
  if (!Number.isInteger(qty) || qty <= 0) {
5180
5402
  this.log("Quantity must be a positive whole number for option orders", {
5181
5403
  type: "error",
@@ -5200,6 +5422,19 @@ class AlpacaTradingAPI {
5200
5422
  if (type === "limit" && limitPrice !== undefined) {
5201
5423
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
5202
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
+ ]);
5203
5438
  return this.makeRequest("/orders", "POST", orderData);
5204
5439
  }
5205
5440
  /**
@@ -5208,9 +5443,12 @@ class AlpacaTradingAPI {
5208
5443
  * @param qty Quantity of the multi-leg order (must be a whole number)
5209
5444
  * @param type Order type (market or limit)
5210
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.
5211
5449
  * @returns The created multi-leg order
5212
5450
  */
5213
- async createMultiLegOptionOrder(legs, qty, type, limitPrice) {
5451
+ async createMultiLegOptionOrder(legs, qty, type, limitPrice, clientOrderId) {
5214
5452
  if (!Number.isInteger(qty) || qty <= 0) {
5215
5453
  this.log("Quantity must be a positive whole number for option orders", {
5216
5454
  type: "error",
@@ -5236,6 +5474,17 @@ class AlpacaTradingAPI {
5236
5474
  if (type === "limit" && limitPrice !== undefined) {
5237
5475
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
5238
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
+ ]);
5239
5488
  return this.makeRequest("/orders", "POST", orderData);
5240
5489
  }
5241
5490
  /**
@@ -5713,9 +5962,26 @@ class AlpacaTradingAPI {
5713
5962
  extended_hours: extendedHours,
5714
5963
  position_intent: side === "buy" ? "buy_to_open" : "sell_to_open",
5715
5964
  };
5716
- if (clientOrderId) {
5717
- orderData.client_order_id = clientOrderId;
5718
- }
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
+ ]);
5719
5985
  // Add limit price for limit orders
5720
5986
  if (type === "limit" && limitPrice !== undefined) {
5721
5987
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
@@ -5773,9 +6039,18 @@ class AlpacaTradingAPI {
5773
6039
  *
5774
6040
  * @param auth - The authentication details for Alpaca
5775
6041
  * @returns Validated authentication credentials
6042
+ * @throws UnsupportedBrokerError if `auth.provider` is set to a non-ALPACA provider
5776
6043
  * @throws Error if authentication details are missing or invalid
5777
6044
  */
5778
6045
  async function validateAuth(auth) {
6046
+ // Multi-broker guard (SP2): this seam only resolves Alpaca credentials.
6047
+ // `auth.provider` is typed as "ALPACA" on AlpacaAuth, but untyped callers
6048
+ // (or future BrokerAuth adapters) may pass other providers at runtime —
6049
+ // fail fast with a typed error instead of silently hitting Alpaca hosts.
6050
+ const requestedProvider = auth.provider;
6051
+ if (requestedProvider !== undefined && requestedProvider !== "ALPACA") {
6052
+ throw new UnsupportedBrokerError(requestedProvider);
6053
+ }
5779
6054
  const inlineKey = auth.alpacaApiKey && auth.alpacaApiKey.trim().length > 0
5780
6055
  ? auth.alpacaApiKey
5781
6056
  : undefined;
@@ -5797,26 +6072,54 @@ async function validateAuth(auth) {
5797
6072
  };
5798
6073
  }
5799
6074
  if (auth.adapticAccountId) {
5800
- const client = await getSharedApolloClient();
5801
- const alpacaAccount = (await adaptic$1.alpacaAccount.get({
5802
- id: auth.adapticAccountId,
5803
- }, client));
5804
- if (!alpacaAccount || !alpacaAccount.APIKey || !alpacaAccount.APISecret) {
5805
- throw new Error("Alpaca account not found or incomplete");
5806
- }
5807
- validateAlpacaCredentials({
5808
- apiKey: alpacaAccount.APIKey,
5809
- apiSecret: alpacaAccount.APISecret,
5810
- isPaper: alpacaAccount.type === "PAPER",
5811
- });
5812
- return {
5813
- APIKey: alpacaAccount.APIKey,
5814
- APISecret: alpacaAccount.APISecret,
5815
- type: alpacaAccount.type,
5816
- };
6075
+ return resolveBrokerCredentials(auth.adapticAccountId);
5817
6076
  }
5818
6077
  throw new Error("Either adapticAccountId or both alpacaApiKey and alpacaApiSecret must be provided");
5819
6078
  }
6079
+ /**
6080
+ * Resolves broker credentials for a backend brokerage-account id.
6081
+ *
6082
+ * This is the SINGLE backend-coupled credential lookup in this package —
6083
+ * every account-id-based credential resolution must flow through here so
6084
+ * that backend model changes touch exactly one function.
6085
+ *
6086
+ * SP2 transition note: today the id is an `AlpacaAccount.id` resolved via
6087
+ * `adaptic.alpacaAccount.get`. When backend-legacy publishes the
6088
+ * `BrokerageAccount` model (backfilled with `id = AlpacaAccount.id`, so the
6089
+ * id space is identical), the switch to `adaptic.brokerageAccount.get`
6090
+ * happens INSIDE this function only, following the sequencing rule in
6091
+ * CLAUDE.md ("Multi-Broker Sequencing Rule"): backend-legacy publishes →
6092
+ * utils bumps the dependency and switches this helper → utils publishes →
6093
+ * engine bumps its pin. Do not reference `brokerageAccount` anywhere in
6094
+ * this package before the pinned backend-legacy version exports it.
6095
+ *
6096
+ * The lookup is a no-cache GraphQL round trip to backend-legacy; callers
6097
+ * holding inline credentials should never reach it (see `validateAuth`
6098
+ * precedence).
6099
+ *
6100
+ * @param brokerageAccountId - Backend brokerage-account id (currently the AlpacaAccount id)
6101
+ * @returns Validated authentication credentials
6102
+ * @throws Error if the account is not found or its credentials are incomplete
6103
+ */
6104
+ async function resolveBrokerCredentials(brokerageAccountId) {
6105
+ const client = await getSharedApolloClient();
6106
+ const alpacaAccount = (await adaptic$1.alpacaAccount.get({
6107
+ id: brokerageAccountId,
6108
+ }, client));
6109
+ if (!alpacaAccount || !alpacaAccount.APIKey || !alpacaAccount.APISecret) {
6110
+ throw new Error("Alpaca account not found or incomplete");
6111
+ }
6112
+ validateAlpacaCredentials({
6113
+ apiKey: alpacaAccount.APIKey,
6114
+ apiSecret: alpacaAccount.APISecret,
6115
+ isPaper: alpacaAccount.type === "PAPER",
6116
+ });
6117
+ return {
6118
+ APIKey: alpacaAccount.APIKey,
6119
+ APISecret: alpacaAccount.APISecret,
6120
+ type: alpacaAccount.type,
6121
+ };
6122
+ }
5820
6123
 
5821
6124
  /**
5822
6125
  * Legacy Alpaca Utility Functions
@@ -7946,6 +8249,7 @@ var index$1 = /*#__PURE__*/Object.freeze({
7946
8249
  getOrders: getOrders$1,
7947
8250
  makeRequest: makeRequest,
7948
8251
  replaceOrder: replaceOrder$1,
8252
+ resolveBrokerCredentials: resolveBrokerCredentials,
7949
8253
  roundPriceForAlpaca: roundPriceForAlpaca$5,
7950
8254
  updateConfiguration: updateConfiguration,
7951
8255
  validateAuth: validateAuth
@@ -8353,6 +8657,13 @@ var atrNs = /*#__PURE__*/Object.freeze({
8353
8657
  });
8354
8658
 
8355
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;
8356
8667
  /**
8357
8668
  * Fetches cryptocurrency bars for the specified parameters.
8358
8669
  * This function retrieves historical price data for multiple cryptocurrencies.
@@ -8452,17 +8763,25 @@ async function fetchNews(params, auth) {
8452
8763
  include_content: includeContent.toString(),
8453
8764
  limit: limit.toString(),
8454
8765
  });
8455
- const url = `${ALPACA_API_BASE}/news?${queryParams}`;
8456
- logIfDebug(`Fetching news from: ${url}`);
8457
- let newsArticles = [];
8766
+ const authHeaders = {
8767
+ "APCA-API-KEY-ID": auth.APIKey,
8768
+ "APCA-API-SECRET-KEY": auth.APISecret,
8769
+ };
8770
+ const newsArticles = [];
8458
8771
  let pageToken = null;
8459
- let hasMorePages = true;
8460
- 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.
8461
8777
  if (pageToken) {
8462
- queryParams.append("page_token", pageToken);
8778
+ queryParams.set("page_token", pageToken);
8463
8779
  }
8780
+ const url = `${ALPACA_API_BASE}/news?${queryParams.toString()}`;
8781
+ logIfDebug(`Fetching news from: ${url}`);
8464
8782
  await withRetry(async () => {
8465
8783
  const response = await fetch(url, {
8784
+ headers: authHeaders,
8466
8785
  signal: createTimeoutSignal(DEFAULT_TIMEOUTS.ALPACA_API),
8467
8786
  });
8468
8787
  if (!response.ok) {
@@ -8470,7 +8789,7 @@ async function fetchNews(params, auth) {
8470
8789
  throw new Error(`Alpaca API error (${response.status}): ${errorText}`);
8471
8790
  }
8472
8791
  const data = await response.json();
8473
- newsArticles = newsArticles.concat(data.news.map((article) => ({
8792
+ const pageArticles = (data.news ?? []).map((article) => ({
8474
8793
  id: article.id,
8475
8794
  author: article.author,
8476
8795
  content: article.content,
@@ -8482,11 +8801,17 @@ async function fetchNews(params, auth) {
8482
8801
  url: article.url,
8483
8802
  symbols: article.symbols,
8484
8803
  images: article.images,
8485
- })));
8804
+ }));
8805
+ newsArticles.push(...pageArticles);
8486
8806
  pageToken = data.next_page_token ?? null;
8487
- hasMorePages = !!pageToken;
8488
- logIfDebug(`Received ${data.news.length} news articles. More pages: ${hasMorePages}`);
8807
+ logIfDebug(`Received ${pageArticles.length} news articles. Next page token: ${pageToken ? "present" : "none"}`);
8489
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
+ }
8490
8815
  }
8491
8816
  // If sort is "asc" and limit is 10, return only the 10 most recent articles
8492
8817
  if (sort === "asc" && limit === 10) {
@@ -9703,33 +10028,138 @@ const formatIndicesBarData = (data) => {
9703
10028
  };
9704
10029
 
9705
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
+ */
9706
10108
  const calculateFees = async (action, trade, alpacaAccount) => {
9707
- let fee = 0;
9708
10109
  const alpacaOrderId = action.alpacaOrderId;
9709
10110
  if (!alpacaOrderId)
9710
- return fee;
10111
+ return 0;
9711
10112
  const order = await getOrder$1({
9712
10113
  adapticAccountId: trade.alpacaAccountId,
9713
10114
  alpacaApiKey: alpacaAccount.APIKey,
9714
10115
  alpacaApiSecret: alpacaAccount.APISecret,
9715
10116
  }, alpacaOrderId);
9716
10117
  if (!order)
9717
- return fee;
9718
- const assetType = "STOCK";
9719
- Number(order.qty) || 0;
9720
- order.notional || 0;
9721
- 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) ||
9722
10121
  0;
9723
- switch (assetType) {
9724
- case "STOCK":
9725
- // Currently zero fees for stocks via Alpaca
9726
- fee = 0;
9727
- break;
9728
- default:
9729
- fee = 0;
9730
- 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
+ }
9731
10162
  }
9732
- return fee;
9733
10163
  };
9734
10164
  const computeTotalFees = async (trade) => {
9735
10165
  let totalFees = 0;
@@ -10598,16 +11028,76 @@ async function calculateExpenseRatio({ accountId, client, alpacaAccount, }) {
10598
11028
  return "N/A";
10599
11029
  }
10600
11030
  const equity = parseFloat(accountDetails.equity);
10601
- // Fetch portfolio expenses from your system (Assuming you have this data)
10602
- const expenses = await getPortfolioExpensesFromYourSystem();
10603
- // 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).
10604
11048
  const expenseRatio = (expenses / equity) * 100;
10605
11049
  return `${expenseRatio.toFixed(2)}%`;
10606
11050
  }
10607
- // Mock function to represent fetching expenses from your system
10608
- async function getPortfolioExpensesFromYourSystem(_accountId) {
10609
- // Implement this function based on your data storage
10610
- 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;
10611
11101
  }
10612
11102
  /**
10613
11103
  * Calculates the liquidity ratio for a given Alpaca account.
@@ -11168,9 +11658,16 @@ function calculateBetaFromReturns(portfolioReturns, benchmarkReturns) {
11168
11658
  const denom = n > 1 ? n - 1 : 1;
11169
11659
  covariance /= denom;
11170
11660
  variance /= denom;
11171
- // Handle zero variance
11172
- if (variance === 0) {
11173
- getLogger().warn("Benchmark variance is zero. Setting beta to 0.");
11661
+ // Handle zero (or numerically-degenerate) variance. A constant benchmark
11662
+ // series can still produce a tiny nonzero variance because the computed
11663
+ // mean differs from the constant by an ulp; dividing covariance by that
11664
+ // rounding noise yields a meaningless beta. Treat any variance at or
11665
+ // below the summation noise floor — (n * eps * |mean|)^2, the square of
11666
+ // the worst-case naive-summation error — as zero. When the mean is
11667
+ // exactly 0 this reduces to the exact zero check.
11668
+ const varianceNoiseFloor = (n * Number.EPSILON * Math.abs(averageBenchmarkReturn)) ** 2;
11669
+ if (variance <= varianceNoiseFloor) {
11670
+ getLogger().warn("Benchmark variance is zero or below the floating-point noise floor. Setting beta to 0.");
11174
11671
  return {
11175
11672
  beta: 0,
11176
11673
  covariance,
@@ -11260,6 +11757,58 @@ async function calculateInformationRatio(portfolioHistory, benchmarkBars) {
11260
11757
  }
11261
11758
  return informationRatio.toFixed(4);
11262
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
+ }
11263
11812
  /**
11264
11813
  * Fetches performance metrics for a given Alpaca account.
11265
11814
  * @param params - The parameters for fetching performance metrics.
@@ -11324,7 +11873,10 @@ async function fetchPerformanceMetrics({ params, client, accountId, alpacaAccoun
11324
11873
  getLogger().error("[fetchPerformanceMetrics] Error fetching portfolio history:", error);
11325
11874
  throw new Error("Failed to retrieve portfolio history data");
11326
11875
  }
11327
- // 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.)
11328
11880
  const benchmarkSymbol = "SPY";
11329
11881
  let benchmarkBars = [];
11330
11882
  try {
@@ -11335,24 +11887,16 @@ async function fetchPerformanceMetrics({ params, client, accountId, alpacaAccoun
11335
11887
  : params?.period
11336
11888
  ? params?.period
11337
11889
  : "1Y",
11338
- outputFormat: "unix-ms",
11890
+ outputFormat: "iso",
11339
11891
  intraday_reporting: params?.intraday_reporting,
11340
11892
  });
11341
- const response = await fetch(`/api/market-data/historical-prices?symbol=${benchmarkSymbol}&start=${start.toString()}&end=${end.toString()}&timeframe=${params.timeframe}`, {
11342
- method: "GET",
11343
- headers: {
11344
- "Content-Type": "application/json",
11345
- },
11346
- signal: createTimeoutSignal(DEFAULT_TIMEOUTS.GENERAL),
11893
+ benchmarkBars = await fetchBenchmarkBars({
11894
+ symbol: benchmarkSymbol,
11895
+ start: String(start),
11896
+ end: String(end),
11897
+ timeframe: params.timeframe,
11347
11898
  });
11348
- if (!response.ok) {
11349
- const errorText = await response.text();
11350
- throw new Error(`Failed to fetch benchmark data: ${response.statusText} - ${errorText}`);
11351
- }
11352
- benchmarkBars = await response.json();
11353
- if (!benchmarkBars ||
11354
- !Array.isArray(benchmarkBars) ||
11355
- benchmarkBars.length === 0) {
11899
+ if (benchmarkBars.length === 0) {
11356
11900
  throw new Error("Received empty or invalid benchmark data");
11357
11901
  }
11358
11902
  }
@@ -12349,8 +12893,33 @@ const timeDiffString = (milliseconds) => {
12349
12893
  return parts.join(", ");
12350
12894
  };
12351
12895
 
12896
+ /**
12897
+ * Multi-broker foundation types
12898
+ *
12899
+ * Provider-agnostic brokerage types for the org → fund → brokerageAccount →
12900
+ * broker alignment (SP2). These are strictly ADDITIVE: the existing
12901
+ * Alpaca-specific types (`AlpacaAuth`, `AlpacaCredentials`,
12902
+ * `AlpacaClientConfig`) remain the canonical shapes consumed by the engine
12903
+ * and are unchanged. New provider-aware call sites should prefer these
12904
+ * types; only ALPACA is implemented today — IBKR and COINBASE arms are
12905
+ * typed placeholders that resolve to `UnsupportedBrokerError` at runtime.
12906
+ *
12907
+ * @module @adaptic/utils/types/broker-types
12908
+ */
12909
+ /**
12910
+ * Type guard narrowing {@link BrokerCredentials} to the implemented
12911
+ * ALPACA arm.
12912
+ *
12913
+ * @param credentials - Any broker credentials union member
12914
+ * @returns True when the credentials belong to the ALPACA provider
12915
+ */
12916
+ function isAlpacaBrokerCredentials(credentials) {
12917
+ return credentials.provider === "ALPACA";
12918
+ }
12919
+
12352
12920
  var Types = /*#__PURE__*/Object.freeze({
12353
- __proto__: null
12921
+ __proto__: null,
12922
+ isAlpacaBrokerCredentials: isAlpacaBrokerCredentials
12354
12923
  });
12355
12924
 
12356
12925
  /**
@@ -50663,12 +51232,16 @@ class AlpacaClient {
50663
51232
  }
50664
51233
  // Client cache for connection pooling
50665
51234
  const clientCache = new Map();
51235
+ // Provider discriminant for cache-key scoping (multi-broker SP2 seam):
51236
+ // keeps Alpaca pool entries disjoint from future providers that might
51237
+ // reuse an identical apiKey string.
51238
+ const ALPACA_PROVIDER = "ALPACA";
50666
51239
  /**
50667
51240
  * Create or get a cached Alpaca client
50668
- * Uses apiKey as cache key for connection pooling
51241
+ * Uses provider + apiKey + accountType as cache key for connection pooling
50669
51242
  */
50670
51243
  function createAlpacaClient(config) {
50671
- const cacheKey = `${config.apiKey}-${config.accountType}`;
51244
+ const cacheKey = `${ALPACA_PROVIDER}-${config.apiKey}-${config.accountType}`;
50672
51245
  if (clientCache.has(cacheKey)) {
50673
51246
  log$k(`Returning cached client for ${config.accountType}`, { type: "debug" });
50674
51247
  return clientCache.get(cacheKey);
@@ -68788,6 +69361,49 @@ function verifyFetchKeepAlive() {
68788
69361
  };
68789
69362
  }
68790
69363
 
69364
+ /**
69365
+ * Broker Client Factory
69366
+ *
69367
+ * Provider-agnostic entry point for broker trading clients (SP2 multi-broker
69368
+ * seam). Strictly ADDITIVE: `createAlpacaClient`, `createAlpacaTradingAPI`,
69369
+ * and `createAlpacaMarketDataAPI` remain the canonical Alpaca factories and
69370
+ * are unchanged. Only ALPACA is implemented — all other providers throw a
69371
+ * typed {@link UnsupportedBrokerError}.
69372
+ *
69373
+ * @module @adaptic/utils/broker
69374
+ */
69375
+ /**
69376
+ * Create (or reuse from cache) a broker trading client for the given
69377
+ * credentials.
69378
+ *
69379
+ * ALPACA delegates to `createAlpacaClient`, whose connection-pool cache key
69380
+ * is provider-scoped (`ALPACA-<apiKey>-<accountType>`), so a future
69381
+ * provider reusing an identical apiKey string can never collide with an
69382
+ * Alpaca client. All other providers — including unknown provider strings
69383
+ * from untyped callers — throw {@link UnsupportedBrokerError}.
69384
+ *
69385
+ * @param credentials - Discriminated broker credentials union
69386
+ * @returns A provider-appropriate {@link BrokerTradingClient}
69387
+ * @throws UnsupportedBrokerError for any provider other than ALPACA
69388
+ */
69389
+ function createBrokerClient(credentials) {
69390
+ switch (credentials.provider) {
69391
+ case "ALPACA":
69392
+ return createAlpacaClient({
69393
+ apiKey: credentials.apiKey,
69394
+ apiSecret: credentials.apiSecret,
69395
+ accountType: credentials.type,
69396
+ });
69397
+ case "IBKR":
69398
+ case "COINBASE":
69399
+ throw new UnsupportedBrokerError(credentials.provider);
69400
+ }
69401
+ // Unreachable for typed callers (the switch above is exhaustive), but
69402
+ // untyped runtime callers may pass an unrecognised provider string —
69403
+ // fail fast with the same typed error rather than undefined behaviour.
69404
+ throw new UnsupportedBrokerError(String(credentials.provider));
69405
+ }
69406
+
68791
69407
  /**
68792
69408
  * Mirror enums for the trading policy preference system.
68793
69409
  * These enums are used by both the trading engine and the frontend app
@@ -69937,6 +70553,7 @@ exports.TrailingStopValidationError = TrailingStopValidationError;
69937
70553
  exports.USDC_PAIRS = USDC_PAIRS;
69938
70554
  exports.USDT_PAIRS = USDT_PAIRS;
69939
70555
  exports.USD_PAIRS = USD_PAIRS;
70556
+ exports.UnsupportedBrokerError = UnsupportedBrokerError;
69940
70557
  exports.ValidationError = ValidationError;
69941
70558
  exports.ValidationResponseError = ValidationResponseError;
69942
70559
  exports.WEBSOCKET_STREAMS = WEBSOCKET_STREAMS;
@@ -69975,6 +70592,7 @@ exports.createAlpacaClient = createAlpacaClient;
69975
70592
  exports.createAlpacaMarketDataAPI = createAlpacaMarketDataAPI;
69976
70593
  exports.createAlpacaTradingAPI = createAlpacaTradingAPI;
69977
70594
  exports.createBracketOrder = createBracketOrder;
70595
+ exports.createBrokerClient = createBrokerClient;
69978
70596
  exports.createButterflySpread = createButterflySpread;
69979
70597
  exports.createClientFromEnv = createClientFromEnv;
69980
70598
  exports.createCoveredCall = createCoveredCall;
@@ -70105,6 +70723,7 @@ exports.hasStockLiquidity = hasGoodLiquidity$1;
70105
70723
  exports.hasSufficientVolume = hasSufficientVolume;
70106
70724
  exports.httpAgent = httpAgent;
70107
70725
  exports.httpsAgent = httpsAgent;
70726
+ exports.isAlpacaBrokerCredentials = isAlpacaBrokerCredentials;
70108
70727
  exports.isContractTradable = isContractTradable;
70109
70728
  exports.isCryptoPair = isCryptoPair;
70110
70729
  exports.isExpiringWithin = isExpiringWithin;