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