@adaptic/utils 0.0.1007 → 0.0.1008

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 (31) hide show
  1. package/dist/index.cjs +513 -104
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.mjs +514 -106
  4. package/dist/index.mjs.map +1 -1
  5. package/dist/test.js +120 -9
  6. package/dist/test.js.map +1 -1
  7. package/dist/types/__tests__/alpaca-client-order-id.test.d.ts +2 -0
  8. package/dist/types/__tests__/alpaca-client-order-id.test.d.ts.map +1 -0
  9. package/dist/types/__tests__/alpaca-market-data-retry.test.d.ts +2 -0
  10. package/dist/types/__tests__/alpaca-market-data-retry.test.d.ts.map +1 -0
  11. package/dist/types/__tests__/performance-metrics-fees.test.d.ts +2 -0
  12. package/dist/types/__tests__/performance-metrics-fees.test.d.ts.map +1 -0
  13. package/dist/types/__tests__/price-utils-fees.test.d.ts +2 -0
  14. package/dist/types/__tests__/price-utils-fees.test.d.ts.map +1 -0
  15. package/dist/types/alpaca-market-data-api.d.ts.map +1 -1
  16. package/dist/types/alpaca-trading-api.d.ts +93 -8
  17. package/dist/types/alpaca-trading-api.d.ts.map +1 -1
  18. package/dist/types/cache/stampede-protected-cache.d.ts +19 -1
  19. package/dist/types/cache/stampede-protected-cache.d.ts.map +1 -1
  20. package/dist/types/errors/index.d.ts +25 -0
  21. package/dist/types/errors/index.d.ts.map +1 -1
  22. package/dist/types/index.d.ts +1 -1
  23. package/dist/types/index.d.ts.map +1 -1
  24. package/dist/types/performance-metrics.d.ts +31 -1
  25. package/dist/types/performance-metrics.d.ts.map +1 -1
  26. package/dist/types/price-utils.d.ts +12 -0
  27. package/dist/types/price-utils.d.ts.map +1 -1
  28. package/dist/types/rate-limiter.d.ts.map +1 -1
  29. package/dist/types/utils/retry.d.ts +14 -0
  30. package/dist/types/utils/retry.d.ts.map +1 -1
  31. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -2276,6 +2276,80 @@ function isTransientNetworkError(error) {
2276
2276
  }
2277
2277
  return false;
2278
2278
  }
2279
+ /**
2280
+ * Error names that indicate the CLIENT's own request deadline expired (an
2281
+ * `AbortSignal` timeout or an undici per-phase timeout) rather than a
2282
+ * connection-phase fault. These faults have already consumed a full request
2283
+ * timeout, so retrying them is expensive by construction.
2284
+ */
2285
+ const DEADLINE_EXPIRY_ERROR_NAMES = new Set([
2286
+ "AbortError",
2287
+ "TimeoutError",
2288
+ "RequestTimeoutError",
2289
+ "ConnectTimeoutError",
2290
+ "HeadersTimeoutError",
2291
+ "BodyTimeoutError",
2292
+ ]);
2293
+ /**
2294
+ * Error codes that indicate an expired request/phase deadline (vs a fast
2295
+ * connection-phase fault such as `ECONNRESET`/`ECONNREFUSED`).
2296
+ */
2297
+ const DEADLINE_EXPIRY_ERROR_CODES = new Set([
2298
+ "ETIMEDOUT",
2299
+ "ESOCKETTIMEDOUT",
2300
+ "ECONNABORTED",
2301
+ "UND_ERR_CONNECT_TIMEOUT",
2302
+ "UND_ERR_HEADERS_TIMEOUT",
2303
+ "UND_ERR_BODY_TIMEOUT",
2304
+ ]);
2305
+ /** Message-pattern fallback for deadline-expiry errors that lost name/code. */
2306
+ const DEADLINE_EXPIRY_MESSAGE_PATTERNS = [
2307
+ /timed out/i,
2308
+ /timeout/i,
2309
+ /aborted/i,
2310
+ ];
2311
+ /**
2312
+ * Whether an error represents the client's OWN deadline expiring (abort /
2313
+ * timeout) rather than a connection-phase network fault. Both classes are
2314
+ * "transient" per {@link isTransientNetworkError}, but they have very
2315
+ * different retry economics: a connection fault (`ECONNRESET`, `EPIPE`,
2316
+ * refused socket) settles in milliseconds and is cheap to retry, while a
2317
+ * deadline expiry has already consumed the full per-attempt timeout — blindly
2318
+ * retrying it multiplies time-to-failure exactly when the caller most needs
2319
+ * to fail fast. Walks the `error.cause` chain like the transient classifier.
2320
+ *
2321
+ * @param error - The error to classify.
2322
+ * @returns true when the fault is a client deadline/abort expiry.
2323
+ */
2324
+ function isClientDeadlineExpiry(error) {
2325
+ const MAX_CAUSE_DEPTH = 6;
2326
+ let current = error;
2327
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && current; depth++) {
2328
+ if (current instanceof Error || typeof current === "object") {
2329
+ const err = current;
2330
+ if (typeof err.name === "string" &&
2331
+ DEADLINE_EXPIRY_ERROR_NAMES.has(err.name)) {
2332
+ return true;
2333
+ }
2334
+ if (typeof err.code === "string" &&
2335
+ DEADLINE_EXPIRY_ERROR_CODES.has(err.code)) {
2336
+ return true;
2337
+ }
2338
+ if (typeof err.message === "string") {
2339
+ for (const pattern of DEADLINE_EXPIRY_MESSAGE_PATTERNS) {
2340
+ if (pattern.test(err.message)) {
2341
+ return true;
2342
+ }
2343
+ }
2344
+ }
2345
+ current = err.cause;
2346
+ }
2347
+ else {
2348
+ break;
2349
+ }
2350
+ }
2351
+ return false;
2352
+ }
2279
2353
  /**
2280
2354
  * Analyzes an error and determines if it's retryable.
2281
2355
  * @param error - The error to analyze
@@ -2803,6 +2877,33 @@ class DataFormatError extends AdapticUtilsError {
2803
2877
  this.service = service;
2804
2878
  }
2805
2879
  }
2880
+ /**
2881
+ * Broker-side duplicate `client_order_id` rejection (Alpaca HTTP 422,
2882
+ * "client order id must be unique").
2883
+ *
2884
+ * Thrown by the order-creation paths of `AlpacaTradingAPI` so callers can
2885
+ * distinguish "this exact order was already submitted" from a genuine order
2886
+ * rejection. When {@link wasDerived} is `false` the id was caller-supplied and
2887
+ * the caller owns idempotency semantics (a legitimate repeat needs a new
2888
+ * explicit id or an `idempotencyNonce`). When `true`, the wrapper's automatic
2889
+ * recovery (existing-order lookup, then one salted resubmit) was exhausted.
2890
+ *
2891
+ * Never retryable with the same id — resubmitting the identical
2892
+ * `client_order_id` will 422 again.
2893
+ */
2894
+ class DuplicateClientOrderIdError extends AlpacaApiError {
2895
+ clientOrderId;
2896
+ wasDerived;
2897
+ constructor(message,
2898
+ /** The `client_order_id` that collided broker-side. */
2899
+ clientOrderId,
2900
+ /** Whether the colliding id was derived by the wrapper (vs caller-supplied). */
2901
+ wasDerived, cause) {
2902
+ super(message, "DUPLICATE_CLIENT_ORDER_ID", 422, cause);
2903
+ this.clientOrderId = clientOrderId;
2904
+ this.wasDerived = wasDerived;
2905
+ }
2906
+ }
2806
2907
 
2807
2908
  /**
2808
2909
  * Token bucket rate limiter for external API integrations
@@ -2900,8 +3001,11 @@ class TokenBucketRateLimiter {
2900
3001
  async acquire() {
2901
3002
  const logger = getLogger();
2902
3003
  this.refill();
2903
- if (this.tokens > 0) {
2904
- this.tokens--;
3004
+ // Require a WHOLE token: refill() accrues fractionally, and admitting on
3005
+ // any positive fraction would release a full request per accrual tick,
3006
+ // driving the bucket negative and overrunning the configured rate.
3007
+ if (this.tokens >= TOKENS_PER_REQUEST) {
3008
+ this.tokens -= TOKENS_PER_REQUEST;
2905
3009
  logger.debug(`Rate limit token acquired for ${this.config.label}`, {
2906
3010
  remainingTokens: this.tokens,
2907
3011
  queueLength: this.queue.length,
@@ -2997,8 +3101,9 @@ class TokenBucketRateLimiter {
2997
3101
  this.processingQueue = true;
2998
3102
  const logger = getLogger();
2999
3103
  try {
3000
- while (this.queue.length > 0 && this.tokens > 0) {
3001
- this.tokens--;
3104
+ // Whole-token admission — see the matching guard in acquire().
3105
+ while (this.queue.length > 0 && this.tokens >= TOKENS_PER_REQUEST) {
3106
+ this.tokens -= TOKENS_PER_REQUEST;
3002
3107
  const next = this.queue.shift();
3003
3108
  if (next) {
3004
3109
  clearTimeout(next.timeoutHandle);
@@ -3160,6 +3265,23 @@ function transientRetryDelayMs(attempt) {
3160
3265
  const ceiling = TRANSIENT_NETWORK_RETRY_BASE_MS * 2 ** attempt;
3161
3266
  return Math.floor(Math.random() * ceiling);
3162
3267
  }
3268
+ /**
3269
+ * Total-deadline multiple over the per-attempt client timeout for one
3270
+ * {@link AlpacaMarketDataAPI.makeRequest} call INCLUDING transient retries.
3271
+ * The ECONNRESET class this loop targets settles in milliseconds, so the
3272
+ * retries fit comfortably inside 1.5x the single-attempt timeout — while a
3273
+ * request whose attempts each consume the full client timeout is refused
3274
+ * further retries instead of stretching a hot-path read to a multi-minute
3275
+ * stall (worst case before this budget: 3 x (60s limiter wait + 30s fetch)).
3276
+ */
3277
+ const TRANSIENT_RETRY_BUDGET_TIMEOUT_MULTIPLE = 1.5;
3278
+ /**
3279
+ * Maximum retries for a fault classified as the client's OWN deadline expiry
3280
+ * (see {@link isClientDeadlineExpiry}): such a fault already consumed a full
3281
+ * per-attempt timeout, so it is retried at most once — and then only if the
3282
+ * total budget still allows a further full-length attempt.
3283
+ */
3284
+ const CLIENT_DEADLINE_EXPIRY_MAX_RETRIES = 1;
3163
3285
  const log$l = (message, options = { type: "info" }) => {
3164
3286
  log$m(message, { ...options, source: "AlpacaMarketDataAPI" });
3165
3287
  };
@@ -3673,7 +3795,15 @@ class AlpacaMarketDataAPI extends require$$0$1.EventEmitter {
3673
3795
  // Retry ONLY transient connection faults, and only on GET (every
3674
3796
  // market-data read here is idempotent). A non-2xx response is a real
3675
3797
  // answer from Alpaca and is never retried — that path still throws on
3676
- // the first attempt exactly as before.
3798
+ // the first attempt exactly as before. The whole loop is bounded by a
3799
+ // cumulative deadline so retries can never stretch a hot-path read far
3800
+ // beyond a single attempt's timeout: connection-phase faults
3801
+ // (ECONNRESET class, millisecond-scale) retry cheaply, while a fault
3802
+ // that consumed the full client timeout retries at most once and only
3803
+ // when the remaining budget still fits a full-length attempt.
3804
+ const retryLoopStartedAt = Date.now();
3805
+ const totalBudgetMs = Math.round(DEFAULT_TIMEOUTS.ALPACA_API * TRANSIENT_RETRY_BUDGET_TIMEOUT_MULTIPLE);
3806
+ let deadlineExpiryRetries = 0;
3677
3807
  let response;
3678
3808
  let lastNetworkError;
3679
3809
  for (let attempt = 0; attempt < TRANSIENT_NETWORK_RETRY_ATTEMPTS; attempt += 1) {
@@ -3687,12 +3817,21 @@ class AlpacaMarketDataAPI extends require$$0$1.EventEmitter {
3687
3817
  }
3688
3818
  catch (networkErr) {
3689
3819
  lastNetworkError = networkErr;
3820
+ const deadlineExpiry = isClientDeadlineExpiry(networkErr);
3821
+ const nextAttemptFitsBudget = Date.now() - retryLoopStartedAt + DEFAULT_TIMEOUTS.ALPACA_API <=
3822
+ totalBudgetMs;
3690
3823
  const retryable = method === "GET" &&
3691
3824
  isTransientNetworkError(networkErr) &&
3692
- attempt < TRANSIENT_NETWORK_RETRY_ATTEMPTS - 1;
3825
+ attempt < TRANSIENT_NETWORK_RETRY_ATTEMPTS - 1 &&
3826
+ nextAttemptFitsBudget &&
3827
+ (!deadlineExpiry ||
3828
+ deadlineExpiryRetries < CLIENT_DEADLINE_EXPIRY_MAX_RETRIES);
3693
3829
  if (!retryable) {
3694
3830
  throw networkErr;
3695
3831
  }
3832
+ if (deadlineExpiry) {
3833
+ deadlineExpiryRetries += 1;
3834
+ }
3696
3835
  const delayMs = transientRetryDelayMs(attempt);
3697
3836
  log$l(`Transient network fault on ${endpoint} (attempt ${attempt + 1}/${TRANSIENT_NETWORK_RETRY_ATTEMPTS}); retrying in ${delayMs}ms`, { type: "warn" });
3698
3837
  await new Promise((resolve) => setTimeout(resolve, delayMs));
@@ -3713,8 +3852,7 @@ class AlpacaMarketDataAPI extends require$$0$1.EventEmitter {
3713
3852
  });
3714
3853
  throw new Error(`Market Data API error (${response.status}): ${errorText}`);
3715
3854
  }
3716
- const data = await response.json();
3717
- return data;
3855
+ return (await response.json());
3718
3856
  }
3719
3857
  catch (err) {
3720
3858
  const error = err;
@@ -4401,7 +4539,7 @@ class AlpacaMarketDataAPI extends require$$0$1.EventEmitter {
4401
4539
  });
4402
4540
  throw new Error(`Alpaca news API error (${response.status}): ${errorText}`);
4403
4541
  }
4404
- const data = await response.json();
4542
+ const data = (await response.json());
4405
4543
  if (!data.news || !Array.isArray(data.news)) {
4406
4544
  log$l(`No news data found in Alpaca response for ${symbol}`, {
4407
4545
  type: "warn",
@@ -4484,9 +4622,45 @@ const CLIENT_ORDER_ID_HASH_LENGTH = 32;
4484
4622
  * This derived default is a best-effort safety net; the guaranteed-idempotent
4485
4623
  * path is for the caller to pass an explicit `clientOrderId` tied to the
4486
4624
  * originating signal/decision id (which also permits legitimately-repeated
4487
- * identical orders inside a single window).
4625
+ * identical orders inside a single window). Callers that intentionally repeat
4626
+ * an identical order inside one window without managing explicit ids can pass
4627
+ * an `idempotencyNonce` (e.g. an attempt counter or signal id) instead — the
4628
+ * nonce is folded into the derived id, so each distinct nonce yields a
4629
+ * distinct id while a timeout+retry of the SAME attempt still collides
4630
+ * broker-side as intended.
4631
+ *
4632
+ * When a DERIVED id is 422-rejected as a duplicate, {@link
4633
+ * AlpacaTradingAPI.postOrderWithIdempotencyRecovery} recovers instead of
4634
+ * failing the caller: if the previously-submitted order is still live (or
4635
+ * filled) it is returned as idempotent success; if it is terminally dead
4636
+ * (canceled/expired/rejected) the order is resubmitted exactly once with a
4637
+ * fresh random salt. Caller-SUPPLIED ids are never recovered — they surface a
4638
+ * typed {@link DuplicateClientOrderIdError} so the caller can distinguish a
4639
+ * duplicate from a genuine rejection.
4488
4640
  */
4489
4641
  const CLIENT_ORDER_ID_WINDOW_MS = 300_000;
4642
+ /**
4643
+ * Matches Alpaca's 422 duplicate-idempotency-key rejection message. Alpaca has
4644
+ * used both "client_order_id must be unique" and "client order id must be
4645
+ * unique" across API revisions, so separators are matched loosely.
4646
+ */
4647
+ const DUPLICATE_CLIENT_ORDER_ID_PATTERN = /client[\s_-]?order[\s_-]?id must be unique/i;
4648
+ /** HTTP status Alpaca uses for duplicate `client_order_id` rejections. */
4649
+ const DUPLICATE_CLIENT_ORDER_ID_STATUS = 422;
4650
+ /**
4651
+ * Order statuses in which a previously-submitted order can never execute.
4652
+ * A derived-id duplicate colliding with an order in one of these states is a
4653
+ * legitimate NEW order (e.g. cancel-then-recreate of an identical trailing
4654
+ * stop) and is resubmitted with a fresh salt; any other status means the
4655
+ * original order is live or executed, so it is returned as idempotent success.
4656
+ */
4657
+ const TERMINAL_DEAD_ORDER_STATUSES = new Set([
4658
+ "canceled",
4659
+ "expired",
4660
+ "rejected",
4661
+ "replaced",
4662
+ "done_for_day",
4663
+ ]);
4490
4664
  /**
4491
4665
  Websocket example
4492
4666
  const alpacaAPI = createAlpacaTradingAPI(credentials); // type AlpacaCredentials
@@ -4594,6 +4768,142 @@ class AlpacaTradingAPI {
4594
4768
  .slice(0, CLIENT_ORDER_ID_HASH_LENGTH);
4595
4769
  return `${CLIENT_ORDER_ID_PREFIX}${digest}`;
4596
4770
  }
4771
+ /**
4772
+ * Running count of derived-id duplicate collisions resolved by returning the
4773
+ * already-submitted order. Emitted in log metadata so operators can see the
4774
+ * idempotency net firing.
4775
+ */
4776
+ idempotentDuplicateReturns = 0;
4777
+ /**
4778
+ * Running count of derived-id duplicate collisions resolved by resubmitting
4779
+ * once with a fresh random salt (the colliding order was terminally dead).
4780
+ */
4781
+ saltedDuplicateResubmits = 0;
4782
+ /**
4783
+ * Whether an error thrown by {@link makeRequest} is Alpaca's 422
4784
+ * duplicate-`client_order_id` rejection.
4785
+ * @param error - The error thrown by the order POST.
4786
+ * @returns true when the error is a duplicate-idempotency-key rejection.
4787
+ */
4788
+ isDuplicateClientOrderIdRejection(error) {
4789
+ if (!(error instanceof Error))
4790
+ return false;
4791
+ return (error.message.includes(`(${DUPLICATE_CLIENT_ORDER_ID_STATUS})`) &&
4792
+ DUPLICATE_CLIENT_ORDER_ID_PATTERN.test(error.message));
4793
+ }
4794
+ /**
4795
+ * Look up an order by its `client_order_id` (Alpaca
4796
+ * `GET /orders:by_client_order_id`).
4797
+ * @param clientOrderId - The idempotency key the order was submitted with.
4798
+ * @returns The order, or null when no order exists for the id (404).
4799
+ */
4800
+ async getOrderByClientOrderId(clientOrderId) {
4801
+ try {
4802
+ return await this.makeRequest("/orders:by_client_order_id", "GET", undefined, `?client_order_id=${encodeURIComponent(clientOrderId)}`);
4803
+ }
4804
+ catch (error) {
4805
+ if (error instanceof Error && error.message.includes("(404)")) {
4806
+ return null;
4807
+ }
4808
+ throw error;
4809
+ }
4810
+ }
4811
+ /**
4812
+ * POST an order body with duplicate-`client_order_id` recovery.
4813
+ *
4814
+ * Sets `client_order_id` (explicit id wins; otherwise derived from
4815
+ * `deriveParts`), submits, and on Alpaca's 422 duplicate rejection:
4816
+ *
4817
+ * - **Caller-supplied id**: throws a typed
4818
+ * {@link DuplicateClientOrderIdError} — the caller owns idempotency
4819
+ * semantics and must decide whether the duplicate is success or a bug.
4820
+ * - **Derived id, colliding order live/filled**: returns the existing order
4821
+ * as idempotent success (this is the timeout+retry case the derived id
4822
+ * exists to de-duplicate).
4823
+ * - **Derived id, colliding order terminally dead** (canceled / expired /
4824
+ * rejected — e.g. cancel-then-recreate of an identical trailing stop):
4825
+ * resubmits exactly once with a fresh random salt so the legitimate new
4826
+ * order is not blocked. A second duplicate rejection throws the typed
4827
+ * error.
4828
+ * - **Derived id, status lookup fails**: fails CLOSED with the typed error —
4829
+ * the colliding order may be live, so resubmitting could double-fill; the
4830
+ * caller's next cycle retries when the lookup can succeed.
4831
+ *
4832
+ * Both recovery outcomes increment log-visible counters.
4833
+ *
4834
+ * @param body - The order payload (its `client_order_id` is set here).
4835
+ * @param options - Idempotency inputs: optional explicit id and the derive
4836
+ * parts used both for the default id and for the salted resubmit.
4837
+ * @returns The created (or pre-existing, on idempotent recovery) order.
4838
+ */
4839
+ async postOrderWithIdempotencyRecovery(body, options) {
4840
+ const derived = options.explicitClientOrderId === undefined;
4841
+ const clientOrderId = options.explicitClientOrderId ??
4842
+ this.deriveClientOrderId(options.deriveParts);
4843
+ body.client_order_id = clientOrderId;
4844
+ const requestBody = body;
4845
+ try {
4846
+ return await this.makeRequest("/orders", "POST", requestBody);
4847
+ }
4848
+ catch (error) {
4849
+ if (!this.isDuplicateClientOrderIdRejection(error)) {
4850
+ throw error;
4851
+ }
4852
+ if (!derived) {
4853
+ throw new DuplicateClientOrderIdError(`Duplicate client_order_id "${clientOrderId}" rejected by Alpaca (caller-supplied id)`, clientOrderId, false, error);
4854
+ }
4855
+ let existing = null;
4856
+ try {
4857
+ existing = await this.getOrderByClientOrderId(clientOrderId);
4858
+ }
4859
+ catch (lookupError) {
4860
+ // Fail CLOSED: the 422 proves an order with this id exists, but its
4861
+ // status is unverifiable. Resubmitting here could double-fill a live
4862
+ // order (the timeout+retry case), which is strictly worse than the
4863
+ // caller retrying on its next cycle — by then the lookup will resolve.
4864
+ this.log(`Duplicate-order lookup failed for ${clientOrderId}; failing closed (no resubmit) to avoid a possible double order: ${lookupError instanceof Error
4865
+ ? lookupError.message
4866
+ : String(lookupError)}`, { symbol: options.logSymbol, type: "error" });
4867
+ throw new DuplicateClientOrderIdError(`Duplicate client_order_id "${clientOrderId}" rejected by Alpaca and the existing-order lookup failed; refusing to resubmit (possible live duplicate)`, clientOrderId, true, lookupError);
4868
+ }
4869
+ if (existing && !TERMINAL_DEAD_ORDER_STATUSES.has(existing.status)) {
4870
+ this.idempotentDuplicateReturns++;
4871
+ this.log(`Derived client_order_id ${clientOrderId} already submitted (status=${existing.status}); returning existing order ${existing.id} as idempotent success`, {
4872
+ symbol: options.logSymbol,
4873
+ type: "warn",
4874
+ metadata: {
4875
+ outcome: "idempotent_return",
4876
+ idempotentDuplicateReturns: this.idempotentDuplicateReturns,
4877
+ },
4878
+ });
4879
+ return existing;
4880
+ }
4881
+ const saltedId = this.deriveClientOrderId([
4882
+ ...options.deriveParts,
4883
+ "resubmit",
4884
+ node_crypto.randomUUID(),
4885
+ ]);
4886
+ this.saltedDuplicateResubmits++;
4887
+ this.log(`Derived client_order_id ${clientOrderId} collided with a ${existing ? `terminal (${existing.status})` : "missing"} order; resubmitting once with fresh salt ${saltedId}`, {
4888
+ symbol: options.logSymbol,
4889
+ type: "warn",
4890
+ metadata: {
4891
+ outcome: "salted_resubmit",
4892
+ saltedDuplicateResubmits: this.saltedDuplicateResubmits,
4893
+ },
4894
+ });
4895
+ body.client_order_id = saltedId;
4896
+ try {
4897
+ return await this.makeRequest("/orders", "POST", requestBody);
4898
+ }
4899
+ catch (resubmitError) {
4900
+ if (this.isDuplicateClientOrderIdRejection(resubmitError)) {
4901
+ throw new DuplicateClientOrderIdError(`Salted resubmit of duplicate client_order_id "${clientOrderId}" was itself rejected as a duplicate ("${saltedId}")`, saltedId, true, resubmitError);
4902
+ }
4903
+ throw resubmitError;
4904
+ }
4905
+ }
4906
+ }
4597
4907
  /**
4598
4908
  * Collect the human-readable failure entries from a bulk Multi-Status (207)
4599
4909
  * response body (`DELETE /orders`, `DELETE /positions`). Each element carries
@@ -4896,7 +5206,7 @@ class AlpacaTradingAPI {
4896
5206
  }
4897
5207
  const contentType = response.headers.get("content-type");
4898
5208
  if (contentType && contentType.includes("application/json")) {
4899
- return await response.json();
5209
+ return (await response.json());
4900
5210
  }
4901
5211
  // For non-JSON responses, return the text content
4902
5212
  const textContent = await response.text();
@@ -5044,9 +5354,15 @@ class AlpacaTradingAPI {
5044
5354
  * @param side (string) - the side of the order
5045
5355
  * @param trailPercent100 (number) - the trail percent of the order (scale 100, i.e. 0.5 = 0.5%)
5046
5356
  * @param position_intent (string) - the position intent of the order
5357
+ * @param clientOrderId - Optional explicit idempotency key; when supplied it
5358
+ * is used verbatim and duplicate rejections surface as
5359
+ * {@link DuplicateClientOrderIdError}.
5360
+ * @param idempotencyNonce - Optional attempt/signal discriminator folded into
5361
+ * the derived idempotency key so an intentionally-repeated identical order
5362
+ * inside one derivation window receives a distinct id.
5047
5363
  * @returns The created AlpacaOrder with order ID and details
5048
5364
  */
5049
- async createTrailingStop(symbol, qty, side, trailPercent100, position_intent, clientOrderId) {
5365
+ async createTrailingStop(symbol, qty, side, trailPercent100, position_intent, clientOrderId, idempotencyNonce) {
5050
5366
  this.log(`Creating trailing stop ${side.toUpperCase()} ${qty} shares for ${symbol} with trail percent ${trailPercent100}%`, {
5051
5367
  symbol,
5052
5368
  });
@@ -5059,18 +5375,21 @@ class AlpacaTradingAPI {
5059
5375
  type: "trailing_stop",
5060
5376
  trail_percent: trailPercent100.toString(), // Already in decimal form (e.g., 4 for 4%)
5061
5377
  time_in_force: "gtc",
5062
- client_order_id: clientOrderId ??
5063
- this.deriveClientOrderId([
5378
+ };
5379
+ try {
5380
+ const order = await this.postOrderWithIdempotencyRecovery(body, {
5381
+ explicitClientOrderId: clientOrderId,
5382
+ deriveParts: [
5064
5383
  "trailing_stop",
5065
5384
  symbol,
5066
5385
  side,
5067
5386
  position_intent,
5068
5387
  Math.abs(qty),
5069
5388
  trailPercent100,
5070
- ]),
5071
- };
5072
- try {
5073
- const order = await this.makeRequest(`/orders`, "POST", body);
5389
+ ...(idempotencyNonce !== undefined ? [String(idempotencyNonce)] : []),
5390
+ ],
5391
+ logSymbol: symbol,
5392
+ });
5074
5393
  this.log(`Trailing stop order created for ${symbol}: orderId=${order.id}, trailPercent=${trailPercent100}%`, { symbol });
5075
5394
  return order;
5076
5395
  }
@@ -5088,8 +5407,14 @@ class AlpacaTradingAPI {
5088
5407
  * @param qty (number) - the quantity of the order
5089
5408
  * @param side (string) - the side of the order
5090
5409
  * @param position_intent (string) - the position intent of the order. Important for knowing if a position needs a trailing stop.
5410
+ * @param client_order_id - Optional explicit idempotency key; duplicate
5411
+ * rejections of an explicit id surface as
5412
+ * {@link DuplicateClientOrderIdError}.
5413
+ * @param idempotencyNonce - Optional attempt/signal discriminator folded into
5414
+ * the derived idempotency key so an intentionally-repeated identical order
5415
+ * inside one derivation window receives a distinct id.
5091
5416
  */
5092
- async createMarketOrder(symbol, qty, side, position_intent, client_order_id) {
5417
+ async createMarketOrder(symbol, qty, side, position_intent, client_order_id, idempotencyNonce) {
5093
5418
  this.log(`Creating market order for ${symbol}: ${side} ${qty} shares (${position_intent})`, {
5094
5419
  symbol,
5095
5420
  });
@@ -5102,17 +5427,19 @@ class AlpacaTradingAPI {
5102
5427
  time_in_force: "day",
5103
5428
  order_class: "simple",
5104
5429
  };
5105
- body.client_order_id =
5106
- client_order_id ??
5107
- this.deriveClientOrderId([
5430
+ try {
5431
+ return await this.postOrderWithIdempotencyRecovery(body, {
5432
+ explicitClientOrderId: client_order_id,
5433
+ deriveParts: [
5108
5434
  "market",
5109
5435
  symbol,
5110
5436
  side,
5111
5437
  position_intent,
5112
5438
  Math.abs(qty),
5113
- ]);
5114
- try {
5115
- return await this.makeRequest("/orders", "POST", body);
5439
+ ...(idempotencyNonce !== undefined ? [String(idempotencyNonce)] : []),
5440
+ ],
5441
+ logSymbol: symbol,
5442
+ });
5116
5443
  }
5117
5444
  catch (error) {
5118
5445
  this.log(`Error creating market order: ${error}`, { type: "error" });
@@ -5269,9 +5596,14 @@ class AlpacaTradingAPI {
5269
5596
  * @param limitPrice (number) - the limit price of the order
5270
5597
  * @param position_intent (string) - the position intent of the order
5271
5598
  * @param extended_hours (boolean) - whether the order is in extended hours
5272
- * @param client_order_id (string) - the client order id of the order
5599
+ * @param client_order_id - Optional explicit idempotency key; duplicate
5600
+ * rejections of an explicit id surface as
5601
+ * {@link DuplicateClientOrderIdError}.
5602
+ * @param idempotencyNonce - Optional attempt/signal discriminator folded into
5603
+ * the derived idempotency key so an intentionally-repeated identical order
5604
+ * inside one derivation window receives a distinct id.
5273
5605
  */
5274
- async createLimitOrder(symbol, qty, side, limitPrice, position_intent, extended_hours = false, client_order_id) {
5606
+ async createLimitOrder(symbol, qty, side, limitPrice, position_intent, extended_hours = false, client_order_id, idempotencyNonce) {
5275
5607
  this.log(`Creating limit order for ${symbol}: ${side} ${qty} shares at $${limitPrice.toFixed(2)} (${position_intent})`, {
5276
5608
  symbol,
5277
5609
  });
@@ -5286,9 +5618,10 @@ class AlpacaTradingAPI {
5286
5618
  order_class: "simple",
5287
5619
  extended_hours,
5288
5620
  };
5289
- body.client_order_id =
5290
- client_order_id ??
5291
- this.deriveClientOrderId([
5621
+ try {
5622
+ return await this.postOrderWithIdempotencyRecovery(body, {
5623
+ explicitClientOrderId: client_order_id,
5624
+ deriveParts: [
5292
5625
  "limit",
5293
5626
  symbol,
5294
5627
  side,
@@ -5296,9 +5629,10 @@ class AlpacaTradingAPI {
5296
5629
  Math.abs(qty),
5297
5630
  this.roundPriceForAlpaca(limitPrice),
5298
5631
  extended_hours,
5299
- ]);
5300
- try {
5301
- return await this.makeRequest("/orders", "POST", body);
5632
+ ...(idempotencyNonce !== undefined ? [String(idempotencyNonce)] : []),
5633
+ ],
5634
+ logSymbol: symbol,
5635
+ });
5302
5636
  }
5303
5637
  catch (error) {
5304
5638
  this.log(`Error creating limit order: ${error}`, { type: "error" });
@@ -5451,10 +5785,14 @@ class AlpacaTradingAPI {
5451
5785
  * @param limitPrice Limit price (required for limit orders)
5452
5786
  * @param clientOrderId Optional idempotency key; a deterministic one is
5453
5787
  * derived from the order parameters when omitted so a client-timeout retry
5454
- * is de-duplicated broker-side.
5788
+ * is de-duplicated broker-side. Duplicate rejections of an explicit id
5789
+ * surface as {@link DuplicateClientOrderIdError}.
5790
+ * @param idempotencyNonce Optional attempt/signal discriminator folded into
5791
+ * the derived idempotency key so an intentionally-repeated identical order
5792
+ * inside one derivation window receives a distinct id.
5455
5793
  * @returns The created order
5456
5794
  */
5457
- async createOptionOrder(symbol, qty, side, position_intent, type, limitPrice, clientOrderId) {
5795
+ async createOptionOrder(symbol, qty, side, position_intent, type, limitPrice, clientOrderId, idempotencyNonce) {
5458
5796
  if (!Number.isInteger(qty) || qty <= 0) {
5459
5797
  this.log("Quantity must be a positive whole number for option orders", {
5460
5798
  type: "error",
@@ -5479,20 +5817,22 @@ class AlpacaTradingAPI {
5479
5817
  if (type === "limit" && limitPrice !== undefined) {
5480
5818
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
5481
5819
  }
5482
- orderData.client_order_id =
5483
- clientOrderId ??
5484
- this.deriveClientOrderId([
5485
- "option",
5486
- type,
5487
- symbol,
5488
- side,
5489
- position_intent,
5490
- qty,
5491
- type === "limit" && limitPrice !== undefined
5492
- ? this.roundPriceForAlpaca(limitPrice)
5493
- : undefined,
5494
- ]);
5495
- return this.makeRequest("/orders", "POST", orderData);
5820
+ return this.postOrderWithIdempotencyRecovery(orderData, {
5821
+ explicitClientOrderId: clientOrderId,
5822
+ deriveParts: [
5823
+ "option",
5824
+ type,
5825
+ symbol,
5826
+ side,
5827
+ position_intent,
5828
+ qty,
5829
+ type === "limit" && limitPrice !== undefined
5830
+ ? this.roundPriceForAlpaca(limitPrice)
5831
+ : undefined,
5832
+ ...(idempotencyNonce !== undefined ? [String(idempotencyNonce)] : []),
5833
+ ],
5834
+ logSymbol: symbol,
5835
+ });
5496
5836
  }
5497
5837
  /**
5498
5838
  * Create a multi-leg option order
@@ -5502,10 +5842,14 @@ class AlpacaTradingAPI {
5502
5842
  * @param limitPrice Limit price (required for limit orders)
5503
5843
  * @param clientOrderId Optional idempotency key; a deterministic one is
5504
5844
  * derived from the legs and order parameters when omitted so a
5505
- * client-timeout retry is de-duplicated broker-side.
5845
+ * client-timeout retry is de-duplicated broker-side. Duplicate rejections
5846
+ * of an explicit id surface as {@link DuplicateClientOrderIdError}.
5847
+ * @param idempotencyNonce Optional attempt/signal discriminator folded into
5848
+ * the derived idempotency key so an intentionally-repeated identical order
5849
+ * inside one derivation window receives a distinct id.
5506
5850
  * @returns The created multi-leg order
5507
5851
  */
5508
- async createMultiLegOptionOrder(legs, qty, type, limitPrice, clientOrderId) {
5852
+ async createMultiLegOptionOrder(legs, qty, type, limitPrice, clientOrderId, idempotencyNonce) {
5509
5853
  if (!Number.isInteger(qty) || qty <= 0) {
5510
5854
  this.log("Quantity must be a positive whole number for option orders", {
5511
5855
  type: "error",
@@ -5531,18 +5875,20 @@ class AlpacaTradingAPI {
5531
5875
  if (type === "limit" && limitPrice !== undefined) {
5532
5876
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
5533
5877
  }
5534
- orderData.client_order_id =
5535
- clientOrderId ??
5536
- this.deriveClientOrderId([
5537
- "mleg",
5538
- type,
5539
- qty,
5540
- type === "limit" && limitPrice !== undefined
5541
- ? this.roundPriceForAlpaca(limitPrice)
5542
- : undefined,
5543
- ...legs.map((leg) => `${leg.symbol}:${leg.side}:${leg.ratio_qty}:${leg.position_intent}`),
5544
- ]);
5545
- return this.makeRequest("/orders", "POST", orderData);
5878
+ return this.postOrderWithIdempotencyRecovery(orderData, {
5879
+ explicitClientOrderId: clientOrderId,
5880
+ deriveParts: [
5881
+ "mleg",
5882
+ type,
5883
+ qty,
5884
+ type === "limit" && limitPrice !== undefined
5885
+ ? this.roundPriceForAlpaca(limitPrice)
5886
+ : undefined,
5887
+ ...legs.map((leg) => `${leg.symbol}:${leg.side}:${leg.ratio_qty}:${leg.position_intent}`),
5888
+ ...(idempotencyNonce !== undefined ? [String(idempotencyNonce)] : []),
5889
+ ],
5890
+ logSymbol: legSymbols,
5891
+ });
5546
5892
  }
5547
5893
  /**
5548
5894
  * Exercise an option contract
@@ -5915,7 +6261,7 @@ class AlpacaTradingAPI {
5915
6261
  */
5916
6262
  async createEquitiesTrade(params, options) {
5917
6263
  const { symbol, qty, side, referencePrice } = params;
5918
- const { type = "market", limitPrice, extendedHours = false, useStopLoss = false, stopPrice, stopPercent100, useTakeProfit = false, takeProfitPrice, takeProfitPercent100, clientOrderId, } = options || {};
6264
+ const { type = "market", limitPrice, extendedHours = false, useStopLoss = false, stopPrice, stopPercent100, useTakeProfit = false, takeProfitPrice, takeProfitPercent100, clientOrderId, idempotencyNonce, } = options || {};
5919
6265
  // Validation: Extended hours + market order is not allowed
5920
6266
  if (extendedHours && type === "market") {
5921
6267
  this.log("Cannot create market order with extended hours enabled", {
@@ -6019,26 +6365,25 @@ class AlpacaTradingAPI {
6019
6365
  extended_hours: extendedHours,
6020
6366
  position_intent: side === "buy" ? "buy_to_open" : "sell_to_open",
6021
6367
  };
6022
- orderData.client_order_id =
6023
- clientOrderId ??
6024
- this.deriveClientOrderId([
6025
- "equities",
6026
- orderClass,
6027
- type,
6028
- symbol,
6029
- side,
6030
- Math.abs(qty),
6031
- type === "limit" && limitPrice !== undefined
6032
- ? this.roundPriceForAlpaca(limitPrice)
6033
- : undefined,
6034
- extendedHours,
6035
- useStopLoss && calculatedStopPrice !== undefined
6036
- ? this.roundPriceForAlpaca(calculatedStopPrice)
6037
- : undefined,
6038
- useTakeProfit && calculatedTakeProfitPrice !== undefined
6039
- ? this.roundPriceForAlpaca(calculatedTakeProfitPrice)
6040
- : undefined,
6041
- ]);
6368
+ const deriveParts = [
6369
+ "equities",
6370
+ orderClass,
6371
+ type,
6372
+ symbol,
6373
+ side,
6374
+ Math.abs(qty),
6375
+ type === "limit" && limitPrice !== undefined
6376
+ ? this.roundPriceForAlpaca(limitPrice)
6377
+ : undefined,
6378
+ extendedHours,
6379
+ useStopLoss && calculatedStopPrice !== undefined
6380
+ ? this.roundPriceForAlpaca(calculatedStopPrice)
6381
+ : undefined,
6382
+ useTakeProfit && calculatedTakeProfitPrice !== undefined
6383
+ ? this.roundPriceForAlpaca(calculatedTakeProfitPrice)
6384
+ : undefined,
6385
+ ...(idempotencyNonce !== undefined ? [String(idempotencyNonce)] : []),
6386
+ ];
6042
6387
  // Add limit price for limit orders
6043
6388
  if (type === "limit" && limitPrice !== undefined) {
6044
6389
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
@@ -6062,7 +6407,11 @@ class AlpacaTradingAPI {
6062
6407
  symbol,
6063
6408
  });
6064
6409
  try {
6065
- return await this.makeRequest("/orders", "POST", orderData);
6410
+ return await this.postOrderWithIdempotencyRecovery(orderData, {
6411
+ explicitClientOrderId: clientOrderId,
6412
+ deriveParts,
6413
+ logSymbol: symbol,
6414
+ });
6066
6415
  }
6067
6416
  catch (error) {
6068
6417
  this.log(`Error creating equities trade: ${error}`, {
@@ -11045,7 +11394,9 @@ async function calculateTotalReturnYTD(portfolioHistory) {
11045
11394
  * @param accountId - The ID of the Alpaca account.
11046
11395
  * @param client - The Apollo client instance.
11047
11396
  * @param alpacaAccount - The Alpaca account object.
11048
- * @returns A promise that resolves to a string representing the expense ratio in percentage format.
11397
+ * @returns A promise that resolves to a string representing the expense ratio
11398
+ * in percentage format, or "N/A" when the ratio cannot be computed honestly
11399
+ * (missing account, fee-fetch failure, or non-positive/non-finite equity).
11049
11400
  */
11050
11401
  async function calculateExpenseRatio({ accountId, client, alpacaAccount, }) {
11051
11402
  if (!accountId && !alpacaAccount && !client) {
@@ -11079,12 +11430,15 @@ async function calculateExpenseRatio({ accountId, client, alpacaAccount, }) {
11079
11430
  return "N/A";
11080
11431
  }
11081
11432
  }
11082
- // Validate equity
11083
- if (!accountDetails.equity || isNaN(parseFloat(accountDetails.equity))) {
11084
- getLogger().warn("Invalid equity value.");
11433
+ // Validate equity. A drained or freshly-funded account reports equity "0"
11434
+ // (and a broken feed can yield NaN or a negative string); dividing by it
11435
+ // would fabricate "Infinity%"/"NaN%" — return the deliberate "N/A"
11436
+ // (unknown) instead, consistent with the fee-fetch failure path below.
11437
+ const equity = parseFloat(accountDetails.equity);
11438
+ if (!Number.isFinite(equity) || equity <= 0) {
11439
+ getLogger().warn("Non-positive or non-finite equity value; cannot compute expense ratio.", { equity: accountDetails.equity });
11085
11440
  return "N/A";
11086
11441
  }
11087
- const equity = parseFloat(accountDetails.equity);
11088
11442
  // Fetch the account's real trailing fee expenses from Alpaca account
11089
11443
  // activities. A genuine data-source failure yields "N/A" (unknown) rather
11090
11444
  // than a fabricated 0.00%.
@@ -11410,8 +11764,13 @@ async function calculateAlphaAndBeta(portfolioHistory, benchmarkBars) {
11410
11764
  const alignedPortfolioReturns = [];
11411
11765
  const alignedBenchmarkReturns = [];
11412
11766
  for (const timestamp of commonTimestamps) {
11767
+ // commonTimestamps is the key intersection of both maps, so both lookups
11768
+ // are guaranteed present; the guard replaces a non-null assertion.
11413
11769
  const portfolioRet = portfolioReturnsMap.get(timestamp);
11414
11770
  const benchmarkRet = benchmarkReturnsMap.get(timestamp);
11771
+ if (portfolioRet === undefined || benchmarkRet === undefined) {
11772
+ continue;
11773
+ }
11415
11774
  if (isFinite(portfolioRet) && isFinite(benchmarkRet)) {
11416
11775
  alignedPortfolioReturns.push(portfolioRet);
11417
11776
  alignedBenchmarkReturns.push(benchmarkRet);
@@ -11672,8 +12031,13 @@ function alignReturnsByDate(portfolioHistory, benchmarkBars) {
11672
12031
  const alignedPortfolioReturns = [];
11673
12032
  const alignedBenchmarkReturns = [];
11674
12033
  for (const timestamp of commonTimestamps) {
12034
+ // commonTimestamps is the key intersection of both maps, so both lookups
12035
+ // are guaranteed present; the guard replaces a non-null assertion.
11675
12036
  const portfolioRet = portfolioReturnsMap.get(timestamp);
11676
12037
  const benchmarkRet = benchmarkReturnsMap.get(timestamp);
12038
+ if (portfolioRet === undefined || benchmarkRet === undefined) {
12039
+ continue;
12040
+ }
11677
12041
  alignedPortfolioReturns.push(portfolioRet);
11678
12042
  alignedBenchmarkReturns.push(benchmarkRet);
11679
12043
  }
@@ -11790,8 +12154,13 @@ async function calculateInformationRatio(portfolioHistory, benchmarkBars) {
11790
12154
  // Extract aligned returns
11791
12155
  const activeReturns = [];
11792
12156
  for (const timestamp of commonTimestamps) {
12157
+ // commonTimestamps is the key intersection of both maps, so both lookups
12158
+ // are guaranteed present; the guard replaces a non-null assertion.
11793
12159
  const portfolioRet = portfolioReturnsMap.get(timestamp);
11794
12160
  const benchmarkRet = benchmarkReturnsMap.get(timestamp);
12161
+ if (portfolioRet === undefined || benchmarkRet === undefined) {
12162
+ continue;
12163
+ }
11795
12164
  activeReturns.push(portfolioRet - benchmarkRet);
11796
12165
  }
11797
12166
  const n = activeReturns.length;
@@ -63149,6 +63518,14 @@ class LRUCache {
63149
63518
  }
63150
63519
  }
63151
63520
 
63521
+ /**
63522
+ * Default hard ceiling (ms) on a single loader invocation. Resolved at
63523
+ * construction time into `options.loadTimeoutMs` so the effective value is
63524
+ * always a validated number (see the constructor guard) — mirrors the
63525
+ * engine-local copy's constructor-time resolution ahead of consolidating the
63526
+ * two implementations onto this one.
63527
+ */
63528
+ const DEFAULT_LOAD_TIMEOUT_MS = 30_000;
63152
63529
  /**
63153
63530
  * StampedeProtectedCache provides three-layer protection against cache stampedes
63154
63531
  *
@@ -63217,12 +63594,17 @@ class StampedeProtectedCache {
63217
63594
  loadTimeouts: 0,
63218
63595
  };
63219
63596
  constructor(options) {
63597
+ if (options.loadTimeoutMs !== undefined &&
63598
+ (!Number.isFinite(options.loadTimeoutMs) || options.loadTimeoutMs <= 0)) {
63599
+ throw new RangeError(`StampedeProtectedCache loadTimeoutMs must be a positive finite number of milliseconds; received ${String(options.loadTimeoutMs)}`);
63600
+ }
63220
63601
  this.options = {
63221
63602
  ...options,
63222
63603
  staleWhileRevalidateTtl: options.staleWhileRevalidateTtl ?? options.defaultTtl * 2,
63223
63604
  minJitter: options.minJitter ?? 0.9,
63224
63605
  maxJitter: options.maxJitter ?? 1.1,
63225
63606
  enableBackgroundRefresh: options.enableBackgroundRefresh ?? true,
63607
+ loadTimeoutMs: options.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS,
63226
63608
  logger: options.logger ?? {
63227
63609
  debug: () => { },
63228
63610
  info: () => { },
@@ -63299,7 +63681,7 @@ class StampedeProtectedCache {
63299
63681
  cached.accessCount++;
63300
63682
  cached.lastAccessedAt = now;
63301
63683
  // Check if entry is still fresh (considering probabilistic expiration)
63302
- const jitteredExpiresAt = this.applyJitter(cached.expiresAt);
63684
+ const jitteredExpiresAt = this.applyJitter(cached.expiresAt, cached.ttl);
63303
63685
  if (now < jitteredExpiresAt) {
63304
63686
  // Fresh hit
63305
63687
  this.stats.hits++;
@@ -63562,12 +63944,16 @@ class StampedeProtectedCache {
63562
63944
  * is logged rather than surfacing as an unhandled rejection.
63563
63945
  */
63564
63946
  async loadWithTimeout(key, loader, ttl) {
63565
- const timeoutMs = this.options.loadTimeoutMs ?? 30000;
63566
- const loadPromise = this.loadAndCache(key, loader, ttl);
63947
+ const timeoutMs = this.options.loadTimeoutMs;
63948
+ const invocation = { abandoned: false };
63949
+ const loadPromise = this.loadAndCache(key, loader, ttl, invocation);
63567
63950
  let timeoutHandle;
63568
63951
  const timeoutPromise = new Promise((_, reject) => {
63569
63952
  timeoutHandle = setTimeout(() => {
63570
63953
  this.stats.loadTimeouts++;
63954
+ // Mark the invocation abandoned BEFORE evicting the pin: a retry that
63955
+ // starts now must never be overwritten by this loader's late result.
63956
+ invocation.abandoned = true;
63571
63957
  // Evict the pin so the next caller retries fresh.
63572
63958
  this.pendingRefreshes.delete(key);
63573
63959
  this.options.logger.warn("Cache loader timed out — pin evicted", {
@@ -63600,11 +63986,19 @@ class StampedeProtectedCache {
63600
63986
  /**
63601
63987
  * Load data and cache it
63602
63988
  */
63603
- async loadAndCache(key, loader, ttl) {
63989
+ async loadAndCache(key, loader, ttl, invocation = { abandoned: false }) {
63604
63990
  const startTime = Date.now();
63605
63991
  try {
63606
63992
  this.options.logger.debug("Loading data", { key });
63607
63993
  const value = await loader(key);
63994
+ if (invocation.abandoned) {
63995
+ // The pin was evicted at loadTimeoutMs and a retry may have cached
63996
+ // fresher data since; writing this late value would overwrite it with
63997
+ // a snapshot fetched before/through the hang.
63998
+ const loadTime = Date.now() - startTime;
63999
+ this.options.logger.warn("Abandoned cache loader resolved late — result discarded", { key, loadTime });
64000
+ return value;
64001
+ }
63608
64002
  // Cache the loaded value
63609
64003
  this.set(key, value, ttl);
63610
64004
  const loadTime = Date.now() - startTime;
@@ -63619,11 +64013,15 @@ class StampedeProtectedCache {
63619
64013
  error,
63620
64014
  loadTime,
63621
64015
  });
63622
- // Update cached entry with error if it exists
63623
- const cached = this.cache.get(key);
63624
- if (cached) {
63625
- cached.lastError = error;
63626
- cached.isRefreshing = false;
64016
+ // Update cached entry with error if it exists — unless this invocation
64017
+ // was abandoned, in which case the entry may already belong to a
64018
+ // fresher retry and must not be marked with a stale error.
64019
+ if (!invocation.abandoned) {
64020
+ const cached = this.cache.get(key);
64021
+ if (cached) {
64022
+ cached.lastError = error;
64023
+ cached.isRefreshing = false;
64024
+ }
63627
64025
  }
63628
64026
  throw error;
63629
64027
  }
@@ -63655,13 +64053,23 @@ class StampedeProtectedCache {
63655
64053
  });
63656
64054
  }
63657
64055
  /**
63658
- * Apply probabilistic jitter to expiration time
64056
+ * Apply probabilistic jitter to an entry's expiration time.
64057
+ *
64058
+ * The jitter must scale with the ENTRY's own TTL: using `defaultTtl` here
64059
+ * (as this method originally did) mis-anchored `createdAt` for any entry
64060
+ * cached with a custom TTL, swinging its effective expiry by up to
64061
+ * ±(defaultTtl - ttl) — a 50ms entry under a 5s default could randomly
64062
+ * read as already-expired at write time or fresh for 10x its TTL.
64063
+ *
64064
+ * @param originalExpiresAt - The entry's unjittered expiry timestamp (ms).
64065
+ * @param ttl - The TTL (ms) the entry was cached with.
64066
+ * @returns The jittered expiry timestamp.
63659
64067
  */
63660
- applyJitter(originalExpiresAt) {
64068
+ applyJitter(originalExpiresAt, ttl) {
63661
64069
  const range = this.options.maxJitter - this.options.minJitter;
63662
64070
  const jitter = this.options.minJitter + Math.random() * range;
63663
- const createdAt = originalExpiresAt - this.options.defaultTtl;
63664
- const jitteredTtl = this.options.defaultTtl * jitter;
64071
+ const createdAt = originalExpiresAt - ttl;
64072
+ const jitteredTtl = ttl * jitter;
63665
64073
  return createdAt + jitteredTtl;
63666
64074
  }
63667
64075
  /**
@@ -63746,7 +64154,7 @@ const DEFAULT_CACHE_OPTIONS = {
63746
64154
  minJitter: 0.9, // 90%
63747
64155
  maxJitter: 1.1, // 110%
63748
64156
  enableBackgroundRefresh: true,
63749
- loadTimeoutMs: 30000, // 30s hard loader ceiling (anti-pinning)
64157
+ loadTimeoutMs: DEFAULT_LOAD_TIMEOUT_MS, // hard loader ceiling (anti-pinning)
63750
64158
  };
63751
64159
 
63752
64160
  /**
@@ -70628,6 +71036,7 @@ exports.DEFAULT_RISK_FREE_RATE = DEFAULT_RISK_FREE_RATE;
70628
71036
  exports.DEFAULT_TIMEOUTS = DEFAULT_TIMEOUTS;
70629
71037
  exports.DEFAULT_TRADING_POLICY = DEFAULT_TRADING_POLICY;
70630
71038
  exports.DataFormatError = DataFormatError;
71039
+ exports.DuplicateClientOrderIdError = DuplicateClientOrderIdError;
70631
71040
  exports.HttpClientError = HttpClientError;
70632
71041
  exports.HttpServerError = HttpServerError;
70633
71042
  exports.KEEP_ALIVE_DEFAULTS = KEEP_ALIVE_DEFAULTS;