@adaptic/utils 0.0.997 → 0.0.999

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -4299,6 +4299,15 @@ const API_RETRY_CONFIGS = {
4299
4299
  };
4300
4300
 
4301
4301
  const limitPriceSlippagePercent100 = 0.1; // 0.1%
4302
+ /**
4303
+ * Alpaca's maximum page size for GET /orders — also our explicit default.
4304
+ * Alpaca's silent server-side default is 50, which truncates order
4305
+ * visibility on protective-stop / qty-reservation paths; we never let it
4306
+ * apply (mirrors ORDER_CHUNK_SIZE in src/alpaca/legacy/orders.ts).
4307
+ */
4308
+ const ORDER_PAGE_LIMIT = 500;
4309
+ /** Delay between order pagination pages to stay clear of rate limits. */
4310
+ const ORDER_PAGINATION_DELAY_MS = 300;
4302
4311
  /**
4303
4312
  Websocket example
4304
4313
  const alpacaAPI = createAlpacaTradingAPI(credentials); // type AlpacaCredentials
@@ -4634,10 +4643,25 @@ class AlpacaTradingAPI {
4634
4643
  return positions;
4635
4644
  }
4636
4645
  /**
4637
- * Get all orders
4646
+ * Get all orders, with explicit paging.
4647
+ *
4648
+ * When `params.limit` is provided it is treated as a caller-controlled cap
4649
+ * and a single request is made (preserves the previous contract for
4650
+ * callers that run their own page walks, e.g. phantom-order lookup).
4651
+ *
4652
+ * When `limit` is omitted, Alpaca's silent server-side default of 50
4653
+ * records is NOT allowed to apply — that default truncated open-order
4654
+ * visibility on protective-stop and qty-reservation paths. Instead the
4655
+ * API maximum (500) is requested explicitly and, whenever a full page
4656
+ * comes back, the result is paginated with a `submitted_at` cursor walk
4657
+ * (`until` for desc — the default — or `after` for asc) until a short
4658
+ * page indicates the window is exhausted. Pages are deduplicated by
4659
+ * order id so same-timestamp boundary orders can never be double
4660
+ * counted, and the walk terminates if a full page yields no new orders.
4661
+ *
4638
4662
  * @param params (GetOrdersParams) - optional parameters to filter the orders
4639
4663
  * - status: 'open' | 'closed' | 'all'
4640
- * - limit: number
4664
+ * - limit: number — caller-controlled cap; disables pagination
4641
4665
  * - after: string
4642
4666
  * - until: string
4643
4667
  * - direction: 'asc' | 'desc'
@@ -4647,11 +4671,54 @@ class AlpacaTradingAPI {
4647
4671
  * @returns all orders
4648
4672
  */
4649
4673
  async getOrders(params = {}) {
4674
+ if (params.limit !== undefined) {
4675
+ return this.fetchOrdersPage(params, params.limit);
4676
+ }
4677
+ const isAsc = params.direction === "asc";
4678
+ const allOrders = [];
4679
+ const seenOrderIds = new Set();
4680
+ // Moving cursor: `until` walks backwards for desc (the default),
4681
+ // `after` walks forwards for asc. Both are exclusive on Alpaca's side.
4682
+ let cursor = isAsc ? params.after : params.until;
4683
+ while (true) {
4684
+ const pageParams = {
4685
+ ...params,
4686
+ ...(isAsc ? { after: cursor } : { until: cursor }),
4687
+ };
4688
+ const page = await this.fetchOrdersPage(pageParams, ORDER_PAGE_LIMIT);
4689
+ let addedCount = 0;
4690
+ for (const order of page) {
4691
+ if (!seenOrderIds.has(order.id)) {
4692
+ seenOrderIds.add(order.id);
4693
+ allOrders.push(order);
4694
+ addedCount++;
4695
+ }
4696
+ }
4697
+ // Short page = window exhausted (same termination as legacy getOrders).
4698
+ if (page.length < ORDER_PAGE_LIMIT)
4699
+ break;
4700
+ const lastOrder = page[page.length - 1];
4701
+ // No usable cursor, or a full page of already-seen orders (cursor not
4702
+ // advancing): stop rather than risk an unbounded walk.
4703
+ if (!lastOrder.submitted_at || addedCount === 0)
4704
+ break;
4705
+ cursor = lastOrder.submitted_at;
4706
+ await new Promise((resolve) => setTimeout(resolve, ORDER_PAGINATION_DELAY_MS));
4707
+ }
4708
+ return allOrders;
4709
+ }
4710
+ /**
4711
+ * Issues a single GET /orders request with an explicit `limit` so
4712
+ * Alpaca's silent 50-record server default can never apply.
4713
+ * @param params - order filter parameters (limit is supplied separately)
4714
+ * @param limit - explicit page size to request
4715
+ * @returns one page of orders
4716
+ */
4717
+ async fetchOrdersPage(params, limit) {
4650
4718
  const queryParams = new URLSearchParams();
4651
4719
  if (params.status)
4652
4720
  queryParams.append("status", params.status);
4653
- if (params.limit)
4654
- queryParams.append("limit", params.limit.toString());
4721
+ queryParams.append("limit", limit.toString());
4655
4722
  if (params.after)
4656
4723
  queryParams.append("after", params.after);
4657
4724
  if (params.until)
@@ -4664,9 +4731,10 @@ class AlpacaTradingAPI {
4664
4731
  queryParams.append("symbols", params.symbols.join(","));
4665
4732
  if (params.side)
4666
4733
  queryParams.append("side", params.side);
4667
- const endpoint = `/orders${queryParams.toString() ? `?${queryParams.toString()}` : ""}`;
4734
+ const endpoint = `/orders?${queryParams.toString()}`;
4668
4735
  try {
4669
- return await this.makeRequest(endpoint);
4736
+ const orders = await this.makeRequest(endpoint);
4737
+ return orders ?? [];
4670
4738
  }
4671
4739
  catch (error) {
4672
4740
  this.log(`Error getting orders: ${error}`, { type: "error" });
@@ -5691,12 +5759,49 @@ class AlpacaTradingAPI {
5691
5759
 
5692
5760
  /**
5693
5761
  * Resolves AlpacaAuth into validated API credentials.
5694
- * Supports authentication via adapticAccountId or direct API key/secret.
5762
+ *
5763
+ * Credential precedence (broker connectivity must never depend on the CRUD
5764
+ * backend when the caller already holds valid broker credentials):
5765
+ *
5766
+ * 1. **Inline credentials** — when BOTH `alpacaApiKey` and `alpacaApiSecret`
5767
+ * are present and non-empty AND the account `type` is known (either
5768
+ * `auth.type` is set, or there is no `adapticAccountId` to resolve it
5769
+ * from, in which case `type` defaults to `"PAPER"`), the inline values
5770
+ * are used directly with NO backend round trip. This keeps broker
5771
+ * exits/cancels possible when backend-legacy is degraded and removes
5772
+ * the per-call GraphQL credential refetch from the exit path.
5773
+ * 2. **adapticAccountId lookup** — used only when inline credentials are
5774
+ * absent/empty, or when inline credentials lack an explicit `type` and
5775
+ * an `adapticAccountId` is available to resolve the authoritative
5776
+ * PAPER/LIVE type (a wrong type would route requests to the wrong
5777
+ * Alpaca host). The lookup is a no-cache GraphQL round trip to
5778
+ * backend-legacy via `adaptic.alpacaAccount.get`.
5779
+ *
5695
5780
  * @param auth - The authentication details for Alpaca
5696
5781
  * @returns Validated authentication credentials
5697
5782
  * @throws Error if authentication details are missing or invalid
5698
5783
  */
5699
5784
  async function validateAuth(auth) {
5785
+ const inlineKey = auth.alpacaApiKey && auth.alpacaApiKey.trim().length > 0
5786
+ ? auth.alpacaApiKey
5787
+ : undefined;
5788
+ const inlineSecret = auth.alpacaApiSecret && auth.alpacaApiSecret.trim().length > 0
5789
+ ? auth.alpacaApiSecret
5790
+ : undefined;
5791
+ // Prefer inline credentials whenever the account type is unambiguous:
5792
+ // either the caller supplied it, or there is no adapticAccountId to
5793
+ // resolve the authoritative type from anyway.
5794
+ if (inlineKey && inlineSecret && (auth.type || !auth.adapticAccountId)) {
5795
+ const accountType = auth.type || "PAPER";
5796
+ validateAlpacaCredentials({
5797
+ apiKey: inlineKey,
5798
+ apiSecret: inlineSecret});
5799
+ return {
5800
+ APIKey: inlineKey,
5801
+ APISecret: inlineSecret,
5802
+ type: accountType,
5803
+ };
5804
+ }
5700
5805
  if (auth.adapticAccountId) {
5701
5806
  const client = await getSharedApolloClient();
5702
5807
  const alpacaAccount = (await adaptic$1.alpacaAccount.get({
@@ -5716,17 +5821,6 @@ async function validateAuth(auth) {
5716
5821
  type: alpacaAccount.type,
5717
5822
  };
5718
5823
  }
5719
- else if (auth.alpacaApiKey && auth.alpacaApiSecret) {
5720
- const accountType = auth.type || "PAPER";
5721
- validateAlpacaCredentials({
5722
- apiKey: auth.alpacaApiKey,
5723
- apiSecret: auth.alpacaApiSecret});
5724
- return {
5725
- APIKey: auth.alpacaApiKey,
5726
- APISecret: auth.alpacaApiSecret,
5727
- type: accountType,
5728
- };
5729
- }
5730
5824
  throw new Error("Either adapticAccountId or both alpacaApiKey and alpacaApiSecret must be provided");
5731
5825
  }
5732
5826
 
@@ -7000,11 +7094,33 @@ async function closePosition$1(auth, symbolOrAssetId, params) {
7000
7094
  status: "open",
7001
7095
  symbols: [normalizedSymbol],
7002
7096
  });
7003
- // Orders stuck in pending_cancel / pending_replace are already
7004
- // terminalising (or wedged — see the 42210000 handling above);
7005
- // waiting longer will not change them, so they must not make
7006
- // the verification spin to exhaustion on every close attempt.
7007
- const remainingOrders = allRemainingOrders.filter((o) => o.status !== "pending_cancel" && o.status !== "pending_replace");
7097
+ // pending_cancel / pending_replace handling needs an age split
7098
+ // (2026-06-10 live evidence, both directions):
7099
+ // - FRESH pending (entered the state < 30s ago): the broker is
7100
+ // mid-terminalisation and STILL HOLDS the order's quantity.
7101
+ // Treating it as done made the close fire early and bounce
7102
+ // with 403 40310000 "insufficient qty available" (META/WFC
7103
+ // closes at 17:07Z), so it must stay in `remainingOrders`
7104
+ // and keep the verification loop waiting.
7105
+ // - STALE pending (>= 30s): a wedged zombie (observed 8 days
7106
+ // on one order) that will never terminalise — waiting spins
7107
+ // the loop to exhaustion on every close attempt, so it is
7108
+ // excluded and the close proceeds; the broker arbitrates the
7109
+ // held quantity.
7110
+ const WEDGED_PENDING_AGE_MS = 30_000;
7111
+ const remainingOrders = allRemainingOrders.filter((o) => {
7112
+ if (o.status !== "pending_cancel" &&
7113
+ o.status !== "pending_replace") {
7114
+ return true;
7115
+ }
7116
+ const updatedAtMs = Date.parse(o.updated_at ?? "");
7117
+ if (!Number.isFinite(updatedAtMs)) {
7118
+ // No usable timestamp — conservatively treat as fresh so the
7119
+ // loop waits rather than racing the broker's qty release.
7120
+ return true;
7121
+ }
7122
+ return Date.now() - updatedAtMs < WEDGED_PENDING_AGE_MS;
7123
+ });
7008
7124
  if (remainingOrders.length === 0) {
7009
7125
  getLogger().info(`Cancel verification passed for ${normalizedSymbol} (attempt ${attempt}/${maxVerifyAttempts})`, {
7010
7126
  account: auth.adapticAccountId || "direct",
@@ -7109,6 +7225,10 @@ async function closePosition$1(auth, symbolOrAssetId, params) {
7109
7225
  "APCA-API-KEY-ID": APIKey,
7110
7226
  "APCA-API-SECRET-KEY": APISecret,
7111
7227
  },
7228
+ // The close submission must never hang indefinitely (hung sockets on
7229
+ // NAT idle-reap stall the exit in exactly the fast-tape scenario
7230
+ // where it matters) — bound it like every other call in this file.
7231
+ signal: createTimeoutSignal(DEFAULT_TIMEOUTS.ALPACA_API),
7112
7232
  });
7113
7233
  if (!response.ok) {
7114
7234
  const errorText = await response.text();
@@ -7183,6 +7303,7 @@ async function closeAllPositions$1(auth, params = { cancel_orders: true, useLimi
7183
7303
  "APCA-API-KEY-ID": APIKey,
7184
7304
  "APCA-API-SECRET-KEY": APISecret,
7185
7305
  },
7306
+ signal: createTimeoutSignal(DEFAULT_TIMEOUTS.ALPACA_API),
7186
7307
  });
7187
7308
  if (response.ok) {
7188
7309
  getLogger().info(`Closed crypto position ${position.symbol} via market order`, {
@@ -7316,6 +7437,7 @@ async function closeAllPositionsAfterHours$1(auth, params = { cancel_orders: tru
7316
7437
  "APCA-API-KEY-ID": APIKey,
7317
7438
  "APCA-API-SECRET-KEY": APISecret,
7318
7439
  },
7440
+ signal: createTimeoutSignal(DEFAULT_TIMEOUTS.ALPACA_API),
7319
7441
  });
7320
7442
  if (response.ok) {
7321
7443
  getLogger().info(`Closed crypto position ${position.symbol} via market order`, {