@adaptic/utils 0.0.1007 → 0.0.1009

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 +548 -104
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.mjs +549 -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 +47 -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 +2 -2
  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.mjs CHANGED
@@ -3,7 +3,7 @@ import { format, sub, set, add, startOfDay, endOfDay, isBefore, differenceInMill
3
3
  import { formatInTimeZone, toZonedTime, fromZonedTime } from 'date-fns-tz';
4
4
  import require$$0$4, { EventEmitter } from 'events';
5
5
  import WebSocket from 'ws';
6
- import { createHash } from 'node:crypto';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
7
  import ms from 'ms';
8
8
  import require$$0$1 from 'fs';
9
9
  import require$$1 from 'path';
@@ -2274,6 +2274,80 @@ function isTransientNetworkError(error) {
2274
2274
  }
2275
2275
  return false;
2276
2276
  }
2277
+ /**
2278
+ * Error names that indicate the CLIENT's own request deadline expired (an
2279
+ * `AbortSignal` timeout or an undici per-phase timeout) rather than a
2280
+ * connection-phase fault. These faults have already consumed a full request
2281
+ * timeout, so retrying them is expensive by construction.
2282
+ */
2283
+ const DEADLINE_EXPIRY_ERROR_NAMES = new Set([
2284
+ "AbortError",
2285
+ "TimeoutError",
2286
+ "RequestTimeoutError",
2287
+ "ConnectTimeoutError",
2288
+ "HeadersTimeoutError",
2289
+ "BodyTimeoutError",
2290
+ ]);
2291
+ /**
2292
+ * Error codes that indicate an expired request/phase deadline (vs a fast
2293
+ * connection-phase fault such as `ECONNRESET`/`ECONNREFUSED`).
2294
+ */
2295
+ const DEADLINE_EXPIRY_ERROR_CODES = new Set([
2296
+ "ETIMEDOUT",
2297
+ "ESOCKETTIMEDOUT",
2298
+ "ECONNABORTED",
2299
+ "UND_ERR_CONNECT_TIMEOUT",
2300
+ "UND_ERR_HEADERS_TIMEOUT",
2301
+ "UND_ERR_BODY_TIMEOUT",
2302
+ ]);
2303
+ /** Message-pattern fallback for deadline-expiry errors that lost name/code. */
2304
+ const DEADLINE_EXPIRY_MESSAGE_PATTERNS = [
2305
+ /timed out/i,
2306
+ /timeout/i,
2307
+ /aborted/i,
2308
+ ];
2309
+ /**
2310
+ * Whether an error represents the client's OWN deadline expiring (abort /
2311
+ * timeout) rather than a connection-phase network fault. Both classes are
2312
+ * "transient" per {@link isTransientNetworkError}, but they have very
2313
+ * different retry economics: a connection fault (`ECONNRESET`, `EPIPE`,
2314
+ * refused socket) settles in milliseconds and is cheap to retry, while a
2315
+ * deadline expiry has already consumed the full per-attempt timeout — blindly
2316
+ * retrying it multiplies time-to-failure exactly when the caller most needs
2317
+ * to fail fast. Walks the `error.cause` chain like the transient classifier.
2318
+ *
2319
+ * @param error - The error to classify.
2320
+ * @returns true when the fault is a client deadline/abort expiry.
2321
+ */
2322
+ function isClientDeadlineExpiry(error) {
2323
+ const MAX_CAUSE_DEPTH = 6;
2324
+ let current = error;
2325
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && current; depth++) {
2326
+ if (current instanceof Error || typeof current === "object") {
2327
+ const err = current;
2328
+ if (typeof err.name === "string" &&
2329
+ DEADLINE_EXPIRY_ERROR_NAMES.has(err.name)) {
2330
+ return true;
2331
+ }
2332
+ if (typeof err.code === "string" &&
2333
+ DEADLINE_EXPIRY_ERROR_CODES.has(err.code)) {
2334
+ return true;
2335
+ }
2336
+ if (typeof err.message === "string") {
2337
+ for (const pattern of DEADLINE_EXPIRY_MESSAGE_PATTERNS) {
2338
+ if (pattern.test(err.message)) {
2339
+ return true;
2340
+ }
2341
+ }
2342
+ }
2343
+ current = err.cause;
2344
+ }
2345
+ else {
2346
+ break;
2347
+ }
2348
+ }
2349
+ return false;
2350
+ }
2277
2351
  /**
2278
2352
  * Analyzes an error and determines if it's retryable.
2279
2353
  * @param error - The error to analyze
@@ -2801,6 +2875,33 @@ class DataFormatError extends AdapticUtilsError {
2801
2875
  this.service = service;
2802
2876
  }
2803
2877
  }
2878
+ /**
2879
+ * Broker-side duplicate `client_order_id` rejection (Alpaca HTTP 422,
2880
+ * "client order id must be unique").
2881
+ *
2882
+ * Thrown by the order-creation paths of `AlpacaTradingAPI` so callers can
2883
+ * distinguish "this exact order was already submitted" from a genuine order
2884
+ * rejection. When {@link wasDerived} is `false` the id was caller-supplied and
2885
+ * the caller owns idempotency semantics (a legitimate repeat needs a new
2886
+ * explicit id or an `idempotencyNonce`). When `true`, the wrapper's automatic
2887
+ * recovery (existing-order lookup, then one salted resubmit) was exhausted.
2888
+ *
2889
+ * Never retryable with the same id — resubmitting the identical
2890
+ * `client_order_id` will 422 again.
2891
+ */
2892
+ class DuplicateClientOrderIdError extends AlpacaApiError {
2893
+ clientOrderId;
2894
+ wasDerived;
2895
+ constructor(message,
2896
+ /** The `client_order_id` that collided broker-side. */
2897
+ clientOrderId,
2898
+ /** Whether the colliding id was derived by the wrapper (vs caller-supplied). */
2899
+ wasDerived, cause) {
2900
+ super(message, "DUPLICATE_CLIENT_ORDER_ID", 422, cause);
2901
+ this.clientOrderId = clientOrderId;
2902
+ this.wasDerived = wasDerived;
2903
+ }
2904
+ }
2804
2905
 
2805
2906
  /**
2806
2907
  * Token bucket rate limiter for external API integrations
@@ -2898,8 +2999,11 @@ class TokenBucketRateLimiter {
2898
2999
  async acquire() {
2899
3000
  const logger = getLogger();
2900
3001
  this.refill();
2901
- if (this.tokens > 0) {
2902
- this.tokens--;
3002
+ // Require a WHOLE token: refill() accrues fractionally, and admitting on
3003
+ // any positive fraction would release a full request per accrual tick,
3004
+ // driving the bucket negative and overrunning the configured rate.
3005
+ if (this.tokens >= TOKENS_PER_REQUEST) {
3006
+ this.tokens -= TOKENS_PER_REQUEST;
2903
3007
  logger.debug(`Rate limit token acquired for ${this.config.label}`, {
2904
3008
  remainingTokens: this.tokens,
2905
3009
  queueLength: this.queue.length,
@@ -2995,8 +3099,9 @@ class TokenBucketRateLimiter {
2995
3099
  this.processingQueue = true;
2996
3100
  const logger = getLogger();
2997
3101
  try {
2998
- while (this.queue.length > 0 && this.tokens > 0) {
2999
- this.tokens--;
3102
+ // Whole-token admission — see the matching guard in acquire().
3103
+ while (this.queue.length > 0 && this.tokens >= TOKENS_PER_REQUEST) {
3104
+ this.tokens -= TOKENS_PER_REQUEST;
3000
3105
  const next = this.queue.shift();
3001
3106
  if (next) {
3002
3107
  clearTimeout(next.timeoutHandle);
@@ -3158,6 +3263,23 @@ function transientRetryDelayMs(attempt) {
3158
3263
  const ceiling = TRANSIENT_NETWORK_RETRY_BASE_MS * 2 ** attempt;
3159
3264
  return Math.floor(Math.random() * ceiling);
3160
3265
  }
3266
+ /**
3267
+ * Total-deadline multiple over the per-attempt client timeout for one
3268
+ * {@link AlpacaMarketDataAPI.makeRequest} call INCLUDING transient retries.
3269
+ * The ECONNRESET class this loop targets settles in milliseconds, so the
3270
+ * retries fit comfortably inside 1.5x the single-attempt timeout — while a
3271
+ * request whose attempts each consume the full client timeout is refused
3272
+ * further retries instead of stretching a hot-path read to a multi-minute
3273
+ * stall (worst case before this budget: 3 x (60s limiter wait + 30s fetch)).
3274
+ */
3275
+ const TRANSIENT_RETRY_BUDGET_TIMEOUT_MULTIPLE = 1.5;
3276
+ /**
3277
+ * Maximum retries for a fault classified as the client's OWN deadline expiry
3278
+ * (see {@link isClientDeadlineExpiry}): such a fault already consumed a full
3279
+ * per-attempt timeout, so it is retried at most once — and then only if the
3280
+ * total budget still allows a further full-length attempt.
3281
+ */
3282
+ const CLIENT_DEADLINE_EXPIRY_MAX_RETRIES = 1;
3161
3283
  const log$l = (message, options = { type: "info" }) => {
3162
3284
  log$m(message, { ...options, source: "AlpacaMarketDataAPI" });
3163
3285
  };
@@ -3671,7 +3793,15 @@ class AlpacaMarketDataAPI extends EventEmitter {
3671
3793
  // Retry ONLY transient connection faults, and only on GET (every
3672
3794
  // market-data read here is idempotent). A non-2xx response is a real
3673
3795
  // answer from Alpaca and is never retried — that path still throws on
3674
- // the first attempt exactly as before.
3796
+ // the first attempt exactly as before. The whole loop is bounded by a
3797
+ // cumulative deadline so retries can never stretch a hot-path read far
3798
+ // beyond a single attempt's timeout: connection-phase faults
3799
+ // (ECONNRESET class, millisecond-scale) retry cheaply, while a fault
3800
+ // that consumed the full client timeout retries at most once and only
3801
+ // when the remaining budget still fits a full-length attempt.
3802
+ const retryLoopStartedAt = Date.now();
3803
+ const totalBudgetMs = Math.round(DEFAULT_TIMEOUTS.ALPACA_API * TRANSIENT_RETRY_BUDGET_TIMEOUT_MULTIPLE);
3804
+ let deadlineExpiryRetries = 0;
3675
3805
  let response;
3676
3806
  let lastNetworkError;
3677
3807
  for (let attempt = 0; attempt < TRANSIENT_NETWORK_RETRY_ATTEMPTS; attempt += 1) {
@@ -3685,12 +3815,21 @@ class AlpacaMarketDataAPI extends EventEmitter {
3685
3815
  }
3686
3816
  catch (networkErr) {
3687
3817
  lastNetworkError = networkErr;
3818
+ const deadlineExpiry = isClientDeadlineExpiry(networkErr);
3819
+ const nextAttemptFitsBudget = Date.now() - retryLoopStartedAt + DEFAULT_TIMEOUTS.ALPACA_API <=
3820
+ totalBudgetMs;
3688
3821
  const retryable = method === "GET" &&
3689
3822
  isTransientNetworkError(networkErr) &&
3690
- attempt < TRANSIENT_NETWORK_RETRY_ATTEMPTS - 1;
3823
+ attempt < TRANSIENT_NETWORK_RETRY_ATTEMPTS - 1 &&
3824
+ nextAttemptFitsBudget &&
3825
+ (!deadlineExpiry ||
3826
+ deadlineExpiryRetries < CLIENT_DEADLINE_EXPIRY_MAX_RETRIES);
3691
3827
  if (!retryable) {
3692
3828
  throw networkErr;
3693
3829
  }
3830
+ if (deadlineExpiry) {
3831
+ deadlineExpiryRetries += 1;
3832
+ }
3694
3833
  const delayMs = transientRetryDelayMs(attempt);
3695
3834
  log$l(`Transient network fault on ${endpoint} (attempt ${attempt + 1}/${TRANSIENT_NETWORK_RETRY_ATTEMPTS}); retrying in ${delayMs}ms`, { type: "warn" });
3696
3835
  await new Promise((resolve) => setTimeout(resolve, delayMs));
@@ -3711,8 +3850,7 @@ class AlpacaMarketDataAPI extends EventEmitter {
3711
3850
  });
3712
3851
  throw new Error(`Market Data API error (${response.status}): ${errorText}`);
3713
3852
  }
3714
- const data = await response.json();
3715
- return data;
3853
+ return (await response.json());
3716
3854
  }
3717
3855
  catch (err) {
3718
3856
  const error = err;
@@ -4399,7 +4537,7 @@ class AlpacaMarketDataAPI extends EventEmitter {
4399
4537
  });
4400
4538
  throw new Error(`Alpaca news API error (${response.status}): ${errorText}`);
4401
4539
  }
4402
- const data = await response.json();
4540
+ const data = (await response.json());
4403
4541
  if (!data.news || !Array.isArray(data.news)) {
4404
4542
  log$l(`No news data found in Alpaca response for ${symbol}`, {
4405
4543
  type: "warn",
@@ -4482,9 +4620,45 @@ const CLIENT_ORDER_ID_HASH_LENGTH = 32;
4482
4620
  * This derived default is a best-effort safety net; the guaranteed-idempotent
4483
4621
  * path is for the caller to pass an explicit `clientOrderId` tied to the
4484
4622
  * originating signal/decision id (which also permits legitimately-repeated
4485
- * identical orders inside a single window).
4623
+ * identical orders inside a single window). Callers that intentionally repeat
4624
+ * an identical order inside one window without managing explicit ids can pass
4625
+ * an `idempotencyNonce` (e.g. an attempt counter or signal id) instead — the
4626
+ * nonce is folded into the derived id, so each distinct nonce yields a
4627
+ * distinct id while a timeout+retry of the SAME attempt still collides
4628
+ * broker-side as intended.
4629
+ *
4630
+ * When a DERIVED id is 422-rejected as a duplicate, {@link
4631
+ * AlpacaTradingAPI.postOrderWithIdempotencyRecovery} recovers instead of
4632
+ * failing the caller: if the previously-submitted order is still live (or
4633
+ * filled) it is returned as idempotent success; if it is terminally dead
4634
+ * (canceled/expired/rejected) the order is resubmitted exactly once with a
4635
+ * fresh random salt. Caller-SUPPLIED ids are never recovered — they surface a
4636
+ * typed {@link DuplicateClientOrderIdError} so the caller can distinguish a
4637
+ * duplicate from a genuine rejection.
4486
4638
  */
4487
4639
  const CLIENT_ORDER_ID_WINDOW_MS = 300_000;
4640
+ /**
4641
+ * Matches Alpaca's 422 duplicate-idempotency-key rejection message. Alpaca has
4642
+ * used both "client_order_id must be unique" and "client order id must be
4643
+ * unique" across API revisions, so separators are matched loosely.
4644
+ */
4645
+ const DUPLICATE_CLIENT_ORDER_ID_PATTERN = /client[\s_-]?order[\s_-]?id must be unique/i;
4646
+ /** HTTP status Alpaca uses for duplicate `client_order_id` rejections. */
4647
+ const DUPLICATE_CLIENT_ORDER_ID_STATUS = 422;
4648
+ /**
4649
+ * Order statuses in which a previously-submitted order can never execute.
4650
+ * A derived-id duplicate colliding with an order in one of these states is a
4651
+ * legitimate NEW order (e.g. cancel-then-recreate of an identical trailing
4652
+ * stop) and is resubmitted with a fresh salt; any other status means the
4653
+ * original order is live or executed, so it is returned as idempotent success.
4654
+ */
4655
+ const TERMINAL_DEAD_ORDER_STATUSES = new Set([
4656
+ "canceled",
4657
+ "expired",
4658
+ "rejected",
4659
+ "replaced",
4660
+ "done_for_day",
4661
+ ]);
4488
4662
  /**
4489
4663
  Websocket example
4490
4664
  const alpacaAPI = createAlpacaTradingAPI(credentials); // type AlpacaCredentials
@@ -4592,6 +4766,142 @@ class AlpacaTradingAPI {
4592
4766
  .slice(0, CLIENT_ORDER_ID_HASH_LENGTH);
4593
4767
  return `${CLIENT_ORDER_ID_PREFIX}${digest}`;
4594
4768
  }
4769
+ /**
4770
+ * Running count of derived-id duplicate collisions resolved by returning the
4771
+ * already-submitted order. Emitted in log metadata so operators can see the
4772
+ * idempotency net firing.
4773
+ */
4774
+ idempotentDuplicateReturns = 0;
4775
+ /**
4776
+ * Running count of derived-id duplicate collisions resolved by resubmitting
4777
+ * once with a fresh random salt (the colliding order was terminally dead).
4778
+ */
4779
+ saltedDuplicateResubmits = 0;
4780
+ /**
4781
+ * Whether an error thrown by {@link makeRequest} is Alpaca's 422
4782
+ * duplicate-`client_order_id` rejection.
4783
+ * @param error - The error thrown by the order POST.
4784
+ * @returns true when the error is a duplicate-idempotency-key rejection.
4785
+ */
4786
+ isDuplicateClientOrderIdRejection(error) {
4787
+ if (!(error instanceof Error))
4788
+ return false;
4789
+ return (error.message.includes(`(${DUPLICATE_CLIENT_ORDER_ID_STATUS})`) &&
4790
+ DUPLICATE_CLIENT_ORDER_ID_PATTERN.test(error.message));
4791
+ }
4792
+ /**
4793
+ * Look up an order by its `client_order_id` (Alpaca
4794
+ * `GET /orders:by_client_order_id`).
4795
+ * @param clientOrderId - The idempotency key the order was submitted with.
4796
+ * @returns The order, or null when no order exists for the id (404).
4797
+ */
4798
+ async getOrderByClientOrderId(clientOrderId) {
4799
+ try {
4800
+ return await this.makeRequest("/orders:by_client_order_id", "GET", undefined, `?client_order_id=${encodeURIComponent(clientOrderId)}`);
4801
+ }
4802
+ catch (error) {
4803
+ if (error instanceof Error && error.message.includes("(404)")) {
4804
+ return null;
4805
+ }
4806
+ throw error;
4807
+ }
4808
+ }
4809
+ /**
4810
+ * POST an order body with duplicate-`client_order_id` recovery.
4811
+ *
4812
+ * Sets `client_order_id` (explicit id wins; otherwise derived from
4813
+ * `deriveParts`), submits, and on Alpaca's 422 duplicate rejection:
4814
+ *
4815
+ * - **Caller-supplied id**: throws a typed
4816
+ * {@link DuplicateClientOrderIdError} — the caller owns idempotency
4817
+ * semantics and must decide whether the duplicate is success or a bug.
4818
+ * - **Derived id, colliding order live/filled**: returns the existing order
4819
+ * as idempotent success (this is the timeout+retry case the derived id
4820
+ * exists to de-duplicate).
4821
+ * - **Derived id, colliding order terminally dead** (canceled / expired /
4822
+ * rejected — e.g. cancel-then-recreate of an identical trailing stop):
4823
+ * resubmits exactly once with a fresh random salt so the legitimate new
4824
+ * order is not blocked. A second duplicate rejection throws the typed
4825
+ * error.
4826
+ * - **Derived id, status lookup fails**: fails CLOSED with the typed error —
4827
+ * the colliding order may be live, so resubmitting could double-fill; the
4828
+ * caller's next cycle retries when the lookup can succeed.
4829
+ *
4830
+ * Both recovery outcomes increment log-visible counters.
4831
+ *
4832
+ * @param body - The order payload (its `client_order_id` is set here).
4833
+ * @param options - Idempotency inputs: optional explicit id and the derive
4834
+ * parts used both for the default id and for the salted resubmit.
4835
+ * @returns The created (or pre-existing, on idempotent recovery) order.
4836
+ */
4837
+ async postOrderWithIdempotencyRecovery(body, options) {
4838
+ const derived = options.explicitClientOrderId === undefined;
4839
+ const clientOrderId = options.explicitClientOrderId ??
4840
+ this.deriveClientOrderId(options.deriveParts);
4841
+ body.client_order_id = clientOrderId;
4842
+ const requestBody = body;
4843
+ try {
4844
+ return await this.makeRequest("/orders", "POST", requestBody);
4845
+ }
4846
+ catch (error) {
4847
+ if (!this.isDuplicateClientOrderIdRejection(error)) {
4848
+ throw error;
4849
+ }
4850
+ if (!derived) {
4851
+ throw new DuplicateClientOrderIdError(`Duplicate client_order_id "${clientOrderId}" rejected by Alpaca (caller-supplied id)`, clientOrderId, false, error);
4852
+ }
4853
+ let existing = null;
4854
+ try {
4855
+ existing = await this.getOrderByClientOrderId(clientOrderId);
4856
+ }
4857
+ catch (lookupError) {
4858
+ // Fail CLOSED: the 422 proves an order with this id exists, but its
4859
+ // status is unverifiable. Resubmitting here could double-fill a live
4860
+ // order (the timeout+retry case), which is strictly worse than the
4861
+ // caller retrying on its next cycle — by then the lookup will resolve.
4862
+ this.log(`Duplicate-order lookup failed for ${clientOrderId}; failing closed (no resubmit) to avoid a possible double order: ${lookupError instanceof Error
4863
+ ? lookupError.message
4864
+ : String(lookupError)}`, { symbol: options.logSymbol, type: "error" });
4865
+ 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);
4866
+ }
4867
+ if (existing && !TERMINAL_DEAD_ORDER_STATUSES.has(existing.status)) {
4868
+ this.idempotentDuplicateReturns++;
4869
+ this.log(`Derived client_order_id ${clientOrderId} already submitted (status=${existing.status}); returning existing order ${existing.id} as idempotent success`, {
4870
+ symbol: options.logSymbol,
4871
+ type: "warn",
4872
+ metadata: {
4873
+ outcome: "idempotent_return",
4874
+ idempotentDuplicateReturns: this.idempotentDuplicateReturns,
4875
+ },
4876
+ });
4877
+ return existing;
4878
+ }
4879
+ const saltedId = this.deriveClientOrderId([
4880
+ ...options.deriveParts,
4881
+ "resubmit",
4882
+ randomUUID(),
4883
+ ]);
4884
+ this.saltedDuplicateResubmits++;
4885
+ this.log(`Derived client_order_id ${clientOrderId} collided with a ${existing ? `terminal (${existing.status})` : "missing"} order; resubmitting once with fresh salt ${saltedId}`, {
4886
+ symbol: options.logSymbol,
4887
+ type: "warn",
4888
+ metadata: {
4889
+ outcome: "salted_resubmit",
4890
+ saltedDuplicateResubmits: this.saltedDuplicateResubmits,
4891
+ },
4892
+ });
4893
+ body.client_order_id = saltedId;
4894
+ try {
4895
+ return await this.makeRequest("/orders", "POST", requestBody);
4896
+ }
4897
+ catch (resubmitError) {
4898
+ if (this.isDuplicateClientOrderIdRejection(resubmitError)) {
4899
+ throw new DuplicateClientOrderIdError(`Salted resubmit of duplicate client_order_id "${clientOrderId}" was itself rejected as a duplicate ("${saltedId}")`, saltedId, true, resubmitError);
4900
+ }
4901
+ throw resubmitError;
4902
+ }
4903
+ }
4904
+ }
4595
4905
  /**
4596
4906
  * Collect the human-readable failure entries from a bulk Multi-Status (207)
4597
4907
  * response body (`DELETE /orders`, `DELETE /positions`). Each element carries
@@ -4894,7 +5204,7 @@ class AlpacaTradingAPI {
4894
5204
  }
4895
5205
  const contentType = response.headers.get("content-type");
4896
5206
  if (contentType && contentType.includes("application/json")) {
4897
- return await response.json();
5207
+ return (await response.json());
4898
5208
  }
4899
5209
  // For non-JSON responses, return the text content
4900
5210
  const textContent = await response.text();
@@ -5042,9 +5352,15 @@ class AlpacaTradingAPI {
5042
5352
  * @param side (string) - the side of the order
5043
5353
  * @param trailPercent100 (number) - the trail percent of the order (scale 100, i.e. 0.5 = 0.5%)
5044
5354
  * @param position_intent (string) - the position intent of the order
5355
+ * @param clientOrderId - Optional explicit idempotency key; when supplied it
5356
+ * is used verbatim and duplicate rejections surface as
5357
+ * {@link DuplicateClientOrderIdError}.
5358
+ * @param idempotencyNonce - Optional attempt/signal discriminator folded into
5359
+ * the derived idempotency key so an intentionally-repeated identical order
5360
+ * inside one derivation window receives a distinct id.
5045
5361
  * @returns The created AlpacaOrder with order ID and details
5046
5362
  */
5047
- async createTrailingStop(symbol, qty, side, trailPercent100, position_intent, clientOrderId) {
5363
+ async createTrailingStop(symbol, qty, side, trailPercent100, position_intent, clientOrderId, idempotencyNonce) {
5048
5364
  this.log(`Creating trailing stop ${side.toUpperCase()} ${qty} shares for ${symbol} with trail percent ${trailPercent100}%`, {
5049
5365
  symbol,
5050
5366
  });
@@ -5057,18 +5373,21 @@ class AlpacaTradingAPI {
5057
5373
  type: "trailing_stop",
5058
5374
  trail_percent: trailPercent100.toString(), // Already in decimal form (e.g., 4 for 4%)
5059
5375
  time_in_force: "gtc",
5060
- client_order_id: clientOrderId ??
5061
- this.deriveClientOrderId([
5376
+ };
5377
+ try {
5378
+ const order = await this.postOrderWithIdempotencyRecovery(body, {
5379
+ explicitClientOrderId: clientOrderId,
5380
+ deriveParts: [
5062
5381
  "trailing_stop",
5063
5382
  symbol,
5064
5383
  side,
5065
5384
  position_intent,
5066
5385
  Math.abs(qty),
5067
5386
  trailPercent100,
5068
- ]),
5069
- };
5070
- try {
5071
- const order = await this.makeRequest(`/orders`, "POST", body);
5387
+ ...(idempotencyNonce !== undefined ? [String(idempotencyNonce)] : []),
5388
+ ],
5389
+ logSymbol: symbol,
5390
+ });
5072
5391
  this.log(`Trailing stop order created for ${symbol}: orderId=${order.id}, trailPercent=${trailPercent100}%`, { symbol });
5073
5392
  return order;
5074
5393
  }
@@ -5086,8 +5405,14 @@ class AlpacaTradingAPI {
5086
5405
  * @param qty (number) - the quantity of the order
5087
5406
  * @param side (string) - the side of the order
5088
5407
  * @param position_intent (string) - the position intent of the order. Important for knowing if a position needs a trailing stop.
5408
+ * @param client_order_id - Optional explicit idempotency key; duplicate
5409
+ * rejections of an explicit id surface as
5410
+ * {@link DuplicateClientOrderIdError}.
5411
+ * @param idempotencyNonce - Optional attempt/signal discriminator folded into
5412
+ * the derived idempotency key so an intentionally-repeated identical order
5413
+ * inside one derivation window receives a distinct id.
5089
5414
  */
5090
- async createMarketOrder(symbol, qty, side, position_intent, client_order_id) {
5415
+ async createMarketOrder(symbol, qty, side, position_intent, client_order_id, idempotencyNonce) {
5091
5416
  this.log(`Creating market order for ${symbol}: ${side} ${qty} shares (${position_intent})`, {
5092
5417
  symbol,
5093
5418
  });
@@ -5100,17 +5425,19 @@ class AlpacaTradingAPI {
5100
5425
  time_in_force: "day",
5101
5426
  order_class: "simple",
5102
5427
  };
5103
- body.client_order_id =
5104
- client_order_id ??
5105
- this.deriveClientOrderId([
5428
+ try {
5429
+ return await this.postOrderWithIdempotencyRecovery(body, {
5430
+ explicitClientOrderId: client_order_id,
5431
+ deriveParts: [
5106
5432
  "market",
5107
5433
  symbol,
5108
5434
  side,
5109
5435
  position_intent,
5110
5436
  Math.abs(qty),
5111
- ]);
5112
- try {
5113
- return await this.makeRequest("/orders", "POST", body);
5437
+ ...(idempotencyNonce !== undefined ? [String(idempotencyNonce)] : []),
5438
+ ],
5439
+ logSymbol: symbol,
5440
+ });
5114
5441
  }
5115
5442
  catch (error) {
5116
5443
  this.log(`Error creating market order: ${error}`, { type: "error" });
@@ -5267,9 +5594,14 @@ class AlpacaTradingAPI {
5267
5594
  * @param limitPrice (number) - the limit price of the order
5268
5595
  * @param position_intent (string) - the position intent of the order
5269
5596
  * @param extended_hours (boolean) - whether the order is in extended hours
5270
- * @param client_order_id (string) - the client order id of the order
5597
+ * @param client_order_id - Optional explicit idempotency key; duplicate
5598
+ * rejections of an explicit id surface as
5599
+ * {@link DuplicateClientOrderIdError}.
5600
+ * @param idempotencyNonce - Optional attempt/signal discriminator folded into
5601
+ * the derived idempotency key so an intentionally-repeated identical order
5602
+ * inside one derivation window receives a distinct id.
5271
5603
  */
5272
- async createLimitOrder(symbol, qty, side, limitPrice, position_intent, extended_hours = false, client_order_id) {
5604
+ async createLimitOrder(symbol, qty, side, limitPrice, position_intent, extended_hours = false, client_order_id, idempotencyNonce) {
5273
5605
  this.log(`Creating limit order for ${symbol}: ${side} ${qty} shares at $${limitPrice.toFixed(2)} (${position_intent})`, {
5274
5606
  symbol,
5275
5607
  });
@@ -5284,9 +5616,10 @@ class AlpacaTradingAPI {
5284
5616
  order_class: "simple",
5285
5617
  extended_hours,
5286
5618
  };
5287
- body.client_order_id =
5288
- client_order_id ??
5289
- this.deriveClientOrderId([
5619
+ try {
5620
+ return await this.postOrderWithIdempotencyRecovery(body, {
5621
+ explicitClientOrderId: client_order_id,
5622
+ deriveParts: [
5290
5623
  "limit",
5291
5624
  symbol,
5292
5625
  side,
@@ -5294,9 +5627,10 @@ class AlpacaTradingAPI {
5294
5627
  Math.abs(qty),
5295
5628
  this.roundPriceForAlpaca(limitPrice),
5296
5629
  extended_hours,
5297
- ]);
5298
- try {
5299
- return await this.makeRequest("/orders", "POST", body);
5630
+ ...(idempotencyNonce !== undefined ? [String(idempotencyNonce)] : []),
5631
+ ],
5632
+ logSymbol: symbol,
5633
+ });
5300
5634
  }
5301
5635
  catch (error) {
5302
5636
  this.log(`Error creating limit order: ${error}`, { type: "error" });
@@ -5449,10 +5783,14 @@ class AlpacaTradingAPI {
5449
5783
  * @param limitPrice Limit price (required for limit orders)
5450
5784
  * @param clientOrderId Optional idempotency key; a deterministic one is
5451
5785
  * derived from the order parameters when omitted so a client-timeout retry
5452
- * is de-duplicated broker-side.
5786
+ * is de-duplicated broker-side. Duplicate rejections of an explicit id
5787
+ * surface as {@link DuplicateClientOrderIdError}.
5788
+ * @param idempotencyNonce Optional attempt/signal discriminator folded into
5789
+ * the derived idempotency key so an intentionally-repeated identical order
5790
+ * inside one derivation window receives a distinct id.
5453
5791
  * @returns The created order
5454
5792
  */
5455
- async createOptionOrder(symbol, qty, side, position_intent, type, limitPrice, clientOrderId) {
5793
+ async createOptionOrder(symbol, qty, side, position_intent, type, limitPrice, clientOrderId, idempotencyNonce) {
5456
5794
  if (!Number.isInteger(qty) || qty <= 0) {
5457
5795
  this.log("Quantity must be a positive whole number for option orders", {
5458
5796
  type: "error",
@@ -5477,20 +5815,22 @@ class AlpacaTradingAPI {
5477
5815
  if (type === "limit" && limitPrice !== undefined) {
5478
5816
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
5479
5817
  }
5480
- orderData.client_order_id =
5481
- clientOrderId ??
5482
- this.deriveClientOrderId([
5483
- "option",
5484
- type,
5485
- symbol,
5486
- side,
5487
- position_intent,
5488
- qty,
5489
- type === "limit" && limitPrice !== undefined
5490
- ? this.roundPriceForAlpaca(limitPrice)
5491
- : undefined,
5492
- ]);
5493
- return this.makeRequest("/orders", "POST", orderData);
5818
+ return this.postOrderWithIdempotencyRecovery(orderData, {
5819
+ explicitClientOrderId: clientOrderId,
5820
+ deriveParts: [
5821
+ "option",
5822
+ type,
5823
+ symbol,
5824
+ side,
5825
+ position_intent,
5826
+ qty,
5827
+ type === "limit" && limitPrice !== undefined
5828
+ ? this.roundPriceForAlpaca(limitPrice)
5829
+ : undefined,
5830
+ ...(idempotencyNonce !== undefined ? [String(idempotencyNonce)] : []),
5831
+ ],
5832
+ logSymbol: symbol,
5833
+ });
5494
5834
  }
5495
5835
  /**
5496
5836
  * Create a multi-leg option order
@@ -5500,10 +5840,14 @@ class AlpacaTradingAPI {
5500
5840
  * @param limitPrice Limit price (required for limit orders)
5501
5841
  * @param clientOrderId Optional idempotency key; a deterministic one is
5502
5842
  * derived from the legs and order parameters when omitted so a
5503
- * client-timeout retry is de-duplicated broker-side.
5843
+ * client-timeout retry is de-duplicated broker-side. Duplicate rejections
5844
+ * of an explicit id surface as {@link DuplicateClientOrderIdError}.
5845
+ * @param idempotencyNonce Optional attempt/signal discriminator folded into
5846
+ * the derived idempotency key so an intentionally-repeated identical order
5847
+ * inside one derivation window receives a distinct id.
5504
5848
  * @returns The created multi-leg order
5505
5849
  */
5506
- async createMultiLegOptionOrder(legs, qty, type, limitPrice, clientOrderId) {
5850
+ async createMultiLegOptionOrder(legs, qty, type, limitPrice, clientOrderId, idempotencyNonce) {
5507
5851
  if (!Number.isInteger(qty) || qty <= 0) {
5508
5852
  this.log("Quantity must be a positive whole number for option orders", {
5509
5853
  type: "error",
@@ -5529,18 +5873,20 @@ class AlpacaTradingAPI {
5529
5873
  if (type === "limit" && limitPrice !== undefined) {
5530
5874
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
5531
5875
  }
5532
- orderData.client_order_id =
5533
- clientOrderId ??
5534
- this.deriveClientOrderId([
5535
- "mleg",
5536
- type,
5537
- qty,
5538
- type === "limit" && limitPrice !== undefined
5539
- ? this.roundPriceForAlpaca(limitPrice)
5540
- : undefined,
5541
- ...legs.map((leg) => `${leg.symbol}:${leg.side}:${leg.ratio_qty}:${leg.position_intent}`),
5542
- ]);
5543
- return this.makeRequest("/orders", "POST", orderData);
5876
+ return this.postOrderWithIdempotencyRecovery(orderData, {
5877
+ explicitClientOrderId: clientOrderId,
5878
+ deriveParts: [
5879
+ "mleg",
5880
+ type,
5881
+ qty,
5882
+ type === "limit" && limitPrice !== undefined
5883
+ ? this.roundPriceForAlpaca(limitPrice)
5884
+ : undefined,
5885
+ ...legs.map((leg) => `${leg.symbol}:${leg.side}:${leg.ratio_qty}:${leg.position_intent}`),
5886
+ ...(idempotencyNonce !== undefined ? [String(idempotencyNonce)] : []),
5887
+ ],
5888
+ logSymbol: legSymbols,
5889
+ });
5544
5890
  }
5545
5891
  /**
5546
5892
  * Exercise an option contract
@@ -5913,7 +6259,7 @@ class AlpacaTradingAPI {
5913
6259
  */
5914
6260
  async createEquitiesTrade(params, options) {
5915
6261
  const { symbol, qty, side, referencePrice } = params;
5916
- const { type = "market", limitPrice, extendedHours = false, useStopLoss = false, stopPrice, stopPercent100, useTakeProfit = false, takeProfitPrice, takeProfitPercent100, clientOrderId, } = options || {};
6262
+ const { type = "market", limitPrice, extendedHours = false, useStopLoss = false, stopPrice, stopPercent100, useTakeProfit = false, takeProfitPrice, takeProfitPercent100, clientOrderId, idempotencyNonce, } = options || {};
5917
6263
  // Validation: Extended hours + market order is not allowed
5918
6264
  if (extendedHours && type === "market") {
5919
6265
  this.log("Cannot create market order with extended hours enabled", {
@@ -6017,26 +6363,25 @@ class AlpacaTradingAPI {
6017
6363
  extended_hours: extendedHours,
6018
6364
  position_intent: side === "buy" ? "buy_to_open" : "sell_to_open",
6019
6365
  };
6020
- orderData.client_order_id =
6021
- clientOrderId ??
6022
- this.deriveClientOrderId([
6023
- "equities",
6024
- orderClass,
6025
- type,
6026
- symbol,
6027
- side,
6028
- Math.abs(qty),
6029
- type === "limit" && limitPrice !== undefined
6030
- ? this.roundPriceForAlpaca(limitPrice)
6031
- : undefined,
6032
- extendedHours,
6033
- useStopLoss && calculatedStopPrice !== undefined
6034
- ? this.roundPriceForAlpaca(calculatedStopPrice)
6035
- : undefined,
6036
- useTakeProfit && calculatedTakeProfitPrice !== undefined
6037
- ? this.roundPriceForAlpaca(calculatedTakeProfitPrice)
6038
- : undefined,
6039
- ]);
6366
+ const deriveParts = [
6367
+ "equities",
6368
+ orderClass,
6369
+ type,
6370
+ symbol,
6371
+ side,
6372
+ Math.abs(qty),
6373
+ type === "limit" && limitPrice !== undefined
6374
+ ? this.roundPriceForAlpaca(limitPrice)
6375
+ : undefined,
6376
+ extendedHours,
6377
+ useStopLoss && calculatedStopPrice !== undefined
6378
+ ? this.roundPriceForAlpaca(calculatedStopPrice)
6379
+ : undefined,
6380
+ useTakeProfit && calculatedTakeProfitPrice !== undefined
6381
+ ? this.roundPriceForAlpaca(calculatedTakeProfitPrice)
6382
+ : undefined,
6383
+ ...(idempotencyNonce !== undefined ? [String(idempotencyNonce)] : []),
6384
+ ];
6040
6385
  // Add limit price for limit orders
6041
6386
  if (type === "limit" && limitPrice !== undefined) {
6042
6387
  orderData.limit_price = this.roundPriceForAlpaca(limitPrice).toString();
@@ -6060,7 +6405,11 @@ class AlpacaTradingAPI {
6060
6405
  symbol,
6061
6406
  });
6062
6407
  try {
6063
- return await this.makeRequest("/orders", "POST", orderData);
6408
+ return await this.postOrderWithIdempotencyRecovery(orderData, {
6409
+ explicitClientOrderId: clientOrderId,
6410
+ deriveParts,
6411
+ logSymbol: symbol,
6412
+ });
6064
6413
  }
6065
6414
  catch (error) {
6066
6415
  this.log(`Error creating equities trade: ${error}`, {
@@ -11043,7 +11392,9 @@ async function calculateTotalReturnYTD(portfolioHistory) {
11043
11392
  * @param accountId - The ID of the Alpaca account.
11044
11393
  * @param client - The Apollo client instance.
11045
11394
  * @param alpacaAccount - The Alpaca account object.
11046
- * @returns A promise that resolves to a string representing the expense ratio in percentage format.
11395
+ * @returns A promise that resolves to a string representing the expense ratio
11396
+ * in percentage format, or "N/A" when the ratio cannot be computed honestly
11397
+ * (missing account, fee-fetch failure, or non-positive/non-finite equity).
11047
11398
  */
11048
11399
  async function calculateExpenseRatio({ accountId, client, alpacaAccount, }) {
11049
11400
  if (!accountId && !alpacaAccount && !client) {
@@ -11077,12 +11428,15 @@ async function calculateExpenseRatio({ accountId, client, alpacaAccount, }) {
11077
11428
  return "N/A";
11078
11429
  }
11079
11430
  }
11080
- // Validate equity
11081
- if (!accountDetails.equity || isNaN(parseFloat(accountDetails.equity))) {
11082
- getLogger().warn("Invalid equity value.");
11431
+ // Validate equity. A drained or freshly-funded account reports equity "0"
11432
+ // (and a broken feed can yield NaN or a negative string); dividing by it
11433
+ // would fabricate "Infinity%"/"NaN%" — return the deliberate "N/A"
11434
+ // (unknown) instead, consistent with the fee-fetch failure path below.
11435
+ const equity = parseFloat(accountDetails.equity);
11436
+ if (!Number.isFinite(equity) || equity <= 0) {
11437
+ getLogger().warn("Non-positive or non-finite equity value; cannot compute expense ratio.", { equity: accountDetails.equity });
11083
11438
  return "N/A";
11084
11439
  }
11085
- const equity = parseFloat(accountDetails.equity);
11086
11440
  // Fetch the account's real trailing fee expenses from Alpaca account
11087
11441
  // activities. A genuine data-source failure yields "N/A" (unknown) rather
11088
11442
  // than a fabricated 0.00%.
@@ -11408,8 +11762,13 @@ async function calculateAlphaAndBeta(portfolioHistory, benchmarkBars) {
11408
11762
  const alignedPortfolioReturns = [];
11409
11763
  const alignedBenchmarkReturns = [];
11410
11764
  for (const timestamp of commonTimestamps) {
11765
+ // commonTimestamps is the key intersection of both maps, so both lookups
11766
+ // are guaranteed present; the guard replaces a non-null assertion.
11411
11767
  const portfolioRet = portfolioReturnsMap.get(timestamp);
11412
11768
  const benchmarkRet = benchmarkReturnsMap.get(timestamp);
11769
+ if (portfolioRet === undefined || benchmarkRet === undefined) {
11770
+ continue;
11771
+ }
11413
11772
  if (isFinite(portfolioRet) && isFinite(benchmarkRet)) {
11414
11773
  alignedPortfolioReturns.push(portfolioRet);
11415
11774
  alignedBenchmarkReturns.push(benchmarkRet);
@@ -11670,8 +12029,13 @@ function alignReturnsByDate(portfolioHistory, benchmarkBars) {
11670
12029
  const alignedPortfolioReturns = [];
11671
12030
  const alignedBenchmarkReturns = [];
11672
12031
  for (const timestamp of commonTimestamps) {
12032
+ // commonTimestamps is the key intersection of both maps, so both lookups
12033
+ // are guaranteed present; the guard replaces a non-null assertion.
11673
12034
  const portfolioRet = portfolioReturnsMap.get(timestamp);
11674
12035
  const benchmarkRet = benchmarkReturnsMap.get(timestamp);
12036
+ if (portfolioRet === undefined || benchmarkRet === undefined) {
12037
+ continue;
12038
+ }
11675
12039
  alignedPortfolioReturns.push(portfolioRet);
11676
12040
  alignedBenchmarkReturns.push(benchmarkRet);
11677
12041
  }
@@ -11788,8 +12152,13 @@ async function calculateInformationRatio(portfolioHistory, benchmarkBars) {
11788
12152
  // Extract aligned returns
11789
12153
  const activeReturns = [];
11790
12154
  for (const timestamp of commonTimestamps) {
12155
+ // commonTimestamps is the key intersection of both maps, so both lookups
12156
+ // are guaranteed present; the guard replaces a non-null assertion.
11791
12157
  const portfolioRet = portfolioReturnsMap.get(timestamp);
11792
12158
  const benchmarkRet = benchmarkReturnsMap.get(timestamp);
12159
+ if (portfolioRet === undefined || benchmarkRet === undefined) {
12160
+ continue;
12161
+ }
11793
12162
  activeReturns.push(portfolioRet - benchmarkRet);
11794
12163
  }
11795
12164
  const n = activeReturns.length;
@@ -63147,6 +63516,14 @@ class LRUCache {
63147
63516
  }
63148
63517
  }
63149
63518
 
63519
+ /**
63520
+ * Default hard ceiling (ms) on a single loader invocation. Resolved at
63521
+ * construction time into `options.loadTimeoutMs` so the effective value is
63522
+ * always a validated number (see the constructor guard) — mirrors the
63523
+ * engine-local copy's constructor-time resolution ahead of consolidating the
63524
+ * two implementations onto this one.
63525
+ */
63526
+ const DEFAULT_LOAD_TIMEOUT_MS = 30_000;
63150
63527
  /**
63151
63528
  * StampedeProtectedCache provides three-layer protection against cache stampedes
63152
63529
  *
@@ -63215,12 +63592,17 @@ class StampedeProtectedCache {
63215
63592
  loadTimeouts: 0,
63216
63593
  };
63217
63594
  constructor(options) {
63595
+ if (options.loadTimeoutMs !== undefined &&
63596
+ (!Number.isFinite(options.loadTimeoutMs) || options.loadTimeoutMs <= 0)) {
63597
+ throw new RangeError(`StampedeProtectedCache loadTimeoutMs must be a positive finite number of milliseconds; received ${String(options.loadTimeoutMs)}`);
63598
+ }
63218
63599
  this.options = {
63219
63600
  ...options,
63220
63601
  staleWhileRevalidateTtl: options.staleWhileRevalidateTtl ?? options.defaultTtl * 2,
63221
63602
  minJitter: options.minJitter ?? 0.9,
63222
63603
  maxJitter: options.maxJitter ?? 1.1,
63223
63604
  enableBackgroundRefresh: options.enableBackgroundRefresh ?? true,
63605
+ loadTimeoutMs: options.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS,
63224
63606
  logger: options.logger ?? {
63225
63607
  debug: () => { },
63226
63608
  info: () => { },
@@ -63234,6 +63616,15 @@ class StampedeProtectedCache {
63234
63616
  allowStale: true,
63235
63617
  updateAgeOnGet: false,
63236
63618
  updateAgeOnHas: false,
63619
+ // LRU discard visibility for the onEvent observability hook. lru-cache
63620
+ // invokes dispose for every removal; only capacity discards ("evict")
63621
+ // are reported as evictions so deliberate delete()/invalidate() calls
63622
+ // do not inflate the eviction signal.
63623
+ dispose: (_value, key, reason) => {
63624
+ if (reason === "evict") {
63625
+ this.emitEvent("eviction", key);
63626
+ }
63627
+ },
63237
63628
  });
63238
63629
  this.options.logger.info("StampedeProtectedCache initialized", {
63239
63630
  maxSize: this.options.maxSize,
@@ -63297,10 +63688,11 @@ class StampedeProtectedCache {
63297
63688
  cached.accessCount++;
63298
63689
  cached.lastAccessedAt = now;
63299
63690
  // Check if entry is still fresh (considering probabilistic expiration)
63300
- const jitteredExpiresAt = this.applyJitter(cached.expiresAt);
63691
+ const jitteredExpiresAt = this.applyJitter(cached.expiresAt, cached.ttl);
63301
63692
  if (now < jitteredExpiresAt) {
63302
63693
  // Fresh hit
63303
63694
  this.stats.hits++;
63695
+ this.emitEvent("hit", key);
63304
63696
  this.options.logger.debug("Cache hit (fresh)", {
63305
63697
  key,
63306
63698
  age: now - cached.createdAt,
@@ -63312,6 +63704,7 @@ class StampedeProtectedCache {
63312
63704
  if (now < staleExpiresAt && !cached.isRefreshing) {
63313
63705
  // Serve stale and trigger background refresh
63314
63706
  this.stats.staleHits++;
63707
+ this.emitEvent("stale_hit", key);
63315
63708
  this.options.logger.debug("Cache hit (stale-while-revalidate)", {
63316
63709
  key,
63317
63710
  age: now - cached.createdAt,
@@ -63325,6 +63718,7 @@ class StampedeProtectedCache {
63325
63718
  }
63326
63719
  // Cache miss or expired - need to load
63327
63720
  this.stats.misses++;
63721
+ this.emitEvent("miss", key);
63328
63722
  this.options.logger.debug("Cache miss", { key, hadCached: !!cached });
63329
63723
  return this.loadWithCoalescing(key, loader, effectiveTtl);
63330
63724
  }
@@ -63402,6 +63796,25 @@ class StampedeProtectedCache {
63402
63796
  * cache.delete(`positions:${accountId}`);
63403
63797
  * ```
63404
63798
  */
63799
+ /**
63800
+ * Fire the {@link StampedeProtectedCacheOptions.onEvent} hook, never
63801
+ * letting an observer failure propagate into the cache path.
63802
+ */
63803
+ emitEvent(event, key) {
63804
+ const hook = this.options.onEvent;
63805
+ if (!hook)
63806
+ return;
63807
+ try {
63808
+ hook(event, key);
63809
+ }
63810
+ catch (err) {
63811
+ this.options.logger.warn("cache onEvent observer threw — ignored", {
63812
+ event,
63813
+ key,
63814
+ error: err instanceof Error ? err.message : String(err),
63815
+ });
63816
+ }
63817
+ }
63405
63818
  delete(key) {
63406
63819
  const deleted = this.cache.delete(key);
63407
63820
  if (deleted) {
@@ -63534,6 +63947,7 @@ class StampedeProtectedCache {
63534
63947
  const existingPromise = this.pendingRefreshes.get(key);
63535
63948
  if (existingPromise) {
63536
63949
  this.stats.coalescedRequests++;
63950
+ this.emitEvent("coalesced", key);
63537
63951
  this.options.logger.debug("Request coalesced", { key });
63538
63952
  return existingPromise;
63539
63953
  }
@@ -63560,12 +63974,17 @@ class StampedeProtectedCache {
63560
63974
  * is logged rather than surfacing as an unhandled rejection.
63561
63975
  */
63562
63976
  async loadWithTimeout(key, loader, ttl) {
63563
- const timeoutMs = this.options.loadTimeoutMs ?? 30000;
63564
- const loadPromise = this.loadAndCache(key, loader, ttl);
63977
+ const timeoutMs = this.options.loadTimeoutMs;
63978
+ const invocation = { abandoned: false };
63979
+ const loadPromise = this.loadAndCache(key, loader, ttl, invocation);
63565
63980
  let timeoutHandle;
63566
63981
  const timeoutPromise = new Promise((_, reject) => {
63567
63982
  timeoutHandle = setTimeout(() => {
63568
63983
  this.stats.loadTimeouts++;
63984
+ this.emitEvent("load_timeout", key);
63985
+ // Mark the invocation abandoned BEFORE evicting the pin: a retry that
63986
+ // starts now must never be overwritten by this loader's late result.
63987
+ invocation.abandoned = true;
63569
63988
  // Evict the pin so the next caller retries fresh.
63570
63989
  this.pendingRefreshes.delete(key);
63571
63990
  this.options.logger.warn("Cache loader timed out — pin evicted", {
@@ -63598,11 +64017,19 @@ class StampedeProtectedCache {
63598
64017
  /**
63599
64018
  * Load data and cache it
63600
64019
  */
63601
- async loadAndCache(key, loader, ttl) {
64020
+ async loadAndCache(key, loader, ttl, invocation = { abandoned: false }) {
63602
64021
  const startTime = Date.now();
63603
64022
  try {
63604
64023
  this.options.logger.debug("Loading data", { key });
63605
64024
  const value = await loader(key);
64025
+ if (invocation.abandoned) {
64026
+ // The pin was evicted at loadTimeoutMs and a retry may have cached
64027
+ // fresher data since; writing this late value would overwrite it with
64028
+ // a snapshot fetched before/through the hang.
64029
+ const loadTime = Date.now() - startTime;
64030
+ this.options.logger.warn("Abandoned cache loader resolved late — result discarded", { key, loadTime });
64031
+ return value;
64032
+ }
63606
64033
  // Cache the loaded value
63607
64034
  this.set(key, value, ttl);
63608
64035
  const loadTime = Date.now() - startTime;
@@ -63611,17 +64038,22 @@ class StampedeProtectedCache {
63611
64038
  }
63612
64039
  catch (error) {
63613
64040
  this.stats.refreshErrors++;
64041
+ this.emitEvent("refresh_error", key);
63614
64042
  const loadTime = Date.now() - startTime;
63615
64043
  this.options.logger.error("Failed to load data", {
63616
64044
  key,
63617
64045
  error,
63618
64046
  loadTime,
63619
64047
  });
63620
- // Update cached entry with error if it exists
63621
- const cached = this.cache.get(key);
63622
- if (cached) {
63623
- cached.lastError = error;
63624
- cached.isRefreshing = false;
64048
+ // Update cached entry with error if it exists — unless this invocation
64049
+ // was abandoned, in which case the entry may already belong to a
64050
+ // fresher retry and must not be marked with a stale error.
64051
+ if (!invocation.abandoned) {
64052
+ const cached = this.cache.get(key);
64053
+ if (cached) {
64054
+ cached.lastError = error;
64055
+ cached.isRefreshing = false;
64056
+ }
63625
64057
  }
63626
64058
  throw error;
63627
64059
  }
@@ -63639,6 +64071,7 @@ class StampedeProtectedCache {
63639
64071
  this.loadWithCoalescing(key, loader, ttl)
63640
64072
  .then(() => {
63641
64073
  this.stats.backgroundRefreshes++;
64074
+ this.emitEvent("background_refresh", key);
63642
64075
  this.options.logger.debug("Background refresh completed", { key });
63643
64076
  })
63644
64077
  .catch((error) => {
@@ -63653,13 +64086,23 @@ class StampedeProtectedCache {
63653
64086
  });
63654
64087
  }
63655
64088
  /**
63656
- * Apply probabilistic jitter to expiration time
64089
+ * Apply probabilistic jitter to an entry's expiration time.
64090
+ *
64091
+ * The jitter must scale with the ENTRY's own TTL: using `defaultTtl` here
64092
+ * (as this method originally did) mis-anchored `createdAt` for any entry
64093
+ * cached with a custom TTL, swinging its effective expiry by up to
64094
+ * ±(defaultTtl - ttl) — a 50ms entry under a 5s default could randomly
64095
+ * read as already-expired at write time or fresh for 10x its TTL.
64096
+ *
64097
+ * @param originalExpiresAt - The entry's unjittered expiry timestamp (ms).
64098
+ * @param ttl - The TTL (ms) the entry was cached with.
64099
+ * @returns The jittered expiry timestamp.
63657
64100
  */
63658
- applyJitter(originalExpiresAt) {
64101
+ applyJitter(originalExpiresAt, ttl) {
63659
64102
  const range = this.options.maxJitter - this.options.minJitter;
63660
64103
  const jitter = this.options.minJitter + Math.random() * range;
63661
- const createdAt = originalExpiresAt - this.options.defaultTtl;
63662
- const jitteredTtl = this.options.defaultTtl * jitter;
64104
+ const createdAt = originalExpiresAt - ttl;
64105
+ const jitteredTtl = ttl * jitter;
63663
64106
  return createdAt + jitteredTtl;
63664
64107
  }
63665
64108
  /**
@@ -63744,7 +64187,7 @@ const DEFAULT_CACHE_OPTIONS = {
63744
64187
  minJitter: 0.9, // 90%
63745
64188
  maxJitter: 1.1, // 110%
63746
64189
  enableBackgroundRefresh: true,
63747
- loadTimeoutMs: 30000, // 30s hard loader ceiling (anti-pinning)
64190
+ loadTimeoutMs: DEFAULT_LOAD_TIMEOUT_MS, // hard loader ceiling (anti-pinning)
63748
64191
  };
63749
64192
 
63750
64193
  /**
@@ -70588,5 +71031,5 @@ const adaptic = {
70588
71031
  };
70589
71032
  const adptc = adaptic;
70590
71033
 
70591
- export { API_RETRY_CONFIGS, AVNewsArticleSchema, AVNewsResponseSchema, AdapticUtilsError, AlpacaAccountDetailsSchema, AlpacaApiError, AlpacaBarSchema, AlpacaClient, AlpacaCryptoBarsResponseSchema, AlpacaHistoricalBarsResponseSchema, AlpacaLatestBarsResponseSchema, AlpacaLatestQuotesResponseSchema, AlpacaLatestTradesResponseSchema, AlpacaMarketDataAPI, AlpacaNewsArticleSchema, AlpacaNewsResponseSchema, AlpacaOrderSchema, AlpacaOrdersArraySchema, AlpacaPortfolioHistoryResponseSchema, AlpacaPositionSchema, AlpacaPositionsArraySchema, AlpacaQuoteSchema, AlpacaTradeSchema, AlpacaTradingAPI, AlphaVantageError, AlphaVantageQuoteResponseSchema, AssetAllocationEngine, AuthenticationError, AutonomyMode, BTC_PAIRS, BarError, CircuitOpenError, CryptoDataError, CryptoOrderError, DEFAULT_CACHE_OPTIONS, DEFAULT_RISK_FREE_RATE, DEFAULT_TIMEOUTS, DEFAULT_TRADING_POLICY, DataFormatError, DecisionMemoryOutcome, DecisionOutcome, DecisionRecordStatus, HttpClientError, HttpServerError, KEEP_ALIVE_DEFAULTS, LlmProvider, MARKET_DATA_API, MassiveAggregatesResponseSchema, MassiveApiError, MassiveDailyOpenCloseSchema, MassiveErrorResponseSchema, MassiveGroupedDailyResponseSchema, MassiveLastTradeResponseSchema, MassiveTickerDetailsResponseSchema, MassiveTickerInfoSchema, MassiveTradeSchema as MassiveTradeZodSchema, MassiveTradesResponseSchema, NetworkError, NewsError, OptionStrategyError, OptionsDataError, OverlaySeverity, OverlayStatus, OverlayType, QuoteError, RISK_FREE_RATE_TTL_MS, RateLimitError, RawMassivePriceDataSchema, StampedeProtectedCache, TRADING_API, TimeoutError, TokenBucketRateLimiter, TradeError, TrailingStopValidationError, USDC_PAIRS, USDT_PAIRS, USD_PAIRS, UnsupportedBrokerError, ValidationError, ValidationResponseError, WEBSOCKET_STREAMS, WebSocketError, account, adaptic, adptc, alpaca, analyzeBars, approximateImpliedVolatility, atrNs as atr, bracketOrders, buildOCCSymbol, buildOptionSymbol, buyCryptoNotional, buyToClose, buyToOpen, buyWithStopLoss, buyWithTrailingStop, calculateMoneyness, calculateOrderValue, calculatePeriodPerformance, calculatePutCallRatio, calculateTotalFilledValue, cancelAllCryptoOrders, cancelOCOOrder, cancelOTOOrder, cancelTrailingStop, cancelTrailingStopsForSymbol, checkTradingEligibility, clearClientCache, clock, closeAllOptionPositions, closeOptionPosition, createAlpacaClient, createAlpacaMarketDataAPI, createAlpacaTradingAPI, createBracketOrder, createBrokerClient, createButterflySpread, createClientFromEnv, createCoveredCall, createCryptoLimitOrder, createCryptoMarketOrder, createCryptoOrder, createCryptoStopLimitOrder, createCryptoStopOrder, createExecutorFromTradingAPI, createIronCondor$1 as createIronCondor, createIronCondor as createIronCondorAdvanced, createMultiLegOptionOrder, createOCOOrder, createOTOOrder, createOptionOrder, createPortfolioTrailingStops, createProtectiveBracket, createStampedeProtectedCache, createStraddle$1 as createStraddle, createStraddle as createStraddleAdvanced, createStrangle$1 as createStrangle, createStrangle as createStrangleAdvanced, createStreamManager, createTimeoutSignal, createTrailingStop, createVerticalSpread$1 as createVerticalSpread, createVerticalSpread as createVerticalSpreadAdvanced, entryWithPercentStopLoss, exerciseOption, extractGreeks, filterByExpiration, filterByStrike, filterByType, filterOrdersByDateRange, findATMOptions, findATMStrikes, findNearestExpiration, findOptionsByDelta, formatOrderForLog, formatOrderSummary, generateOptimalAllocation, getAccountConfiguration, getAccountDetails, getAccountSummary, getAgentPoolStatus, getAllOrders, getAlpacaCalendar, getAlpacaClock, getAverageDailyVolume, getBars, getBuyingPower, getCachedRiskFreeRateSync, getCachedRiskFreeRateSyncWithProvenance, getCrypto24HourChange, getCryptoBars, getCryptoDailyPrices, getCryptoPairsByQuote, getCryptoPrice, getCryptoSnapshots, getCryptoSpread, getCryptoStreamUrl, getCryptoTrades, getCurrentPrice, getCurrentPrices, getDailyPrices, getDailyReturns, getDaysToExpiration, getDefaultRiskProfile, getEquityCurve, getExpirationDates, getFilledOrders, getGroupedOptionChain, getHistoricalOptionsBars, getHistoricalTrades, getIntradayPrices, getLatestBars, getLatestCryptoQuotes, getLatestCryptoTrades, getLatestNews, getLatestOptionsQuotes, getLatestOptionsTrades, getLatestQuote, getLatestQuotes, getLatestTrade, getLatestTrades, getLogger, getMarginInfo, getNews, getNewsForSymbols, getOCOOrderStatus, getOTOOrderStatus, getOpenCryptoOrders, getOpenOrders$1 as getOpenOrdersQuery, getOpenTrailingStops, getOptionChain, getOptionContract, getOptionContracts, getOptionSpread, getOptionsChain, getOptionsSnapshots, getOptionsStreamUrl, getOptionsTradingLevel, getOrderHistory, getOrdersBySymbol, getPDTStatus, getPopularCryptoPairs, getPortfolioHistory, getPreviousClose, getPriceRange, getRiskFreeRate, getRiskFreeRateWithProvenance, getSpread, getSpreads, getStockStreamUrl, getStrikePrices, getSupportedCryptoPairs, getSymbolSentiment, getTimeout, getTradeVolume, getTradingApiUrl, getTradingWebSocketUrl, getTrailingStopHWM, groupOrdersByStatus, groupOrdersBySymbol, hasActiveTrailingStop, hasGoodLiquidity as hasOptionLiquidity, hasGoodLiquidity$1 as hasStockLiquidity, hasSufficientVolume, httpAgent, httpsAgent, isAlpacaBrokerCredentials, isContractTradable, isCryptoPair, isExpiringWithin, isMarginAccount, isOptionOrderCancelable, isOptionOrderTerminal, isOrderFillable, isOrderFilled, isOrderOpen, isOrderTerminal$1 as isOrderTerminalStatus, isSupportedCryptoPair, isTransientNetworkError, index$1 as legacyApi, limitBuyWithTakeProfit, ocoOrders, orderUtils, otoOrders, paginate, paginateAll, parseOCCSymbol, protectLongPosition, protectShortPosition, rateLimiters, resetLogger, resetRiskFreeRateCache, riskNs as risk, rollOptionPosition, roundPriceForAlpaca$3 as roundPriceForAlpaca, roundPriceForAlpacaNumber, safeValidateResponse, searchNews, sellAllCrypto, sellCryptoNotional, sellToClose, sellToOpen, setLogger, setRiskFreeRate, shortWithStopLoss, sortOrdersByDate, strategyNs as strategy, index as tradingPolicy, trailingStops, updateAccountConfiguration, updateTrailingStop, validateAlpacaCredentials, validateAlphaVantageApiKey, validateMassiveApiKey$1 as validateMassiveApiKey, validateMultiLegOrder, validateResponse, verifyFetchKeepAlive, volatilityNs as volatility, waitForOrderFill, withRetry, withTimeout };
71034
+ export { API_RETRY_CONFIGS, AVNewsArticleSchema, AVNewsResponseSchema, AdapticUtilsError, AlpacaAccountDetailsSchema, AlpacaApiError, AlpacaBarSchema, AlpacaClient, AlpacaCryptoBarsResponseSchema, AlpacaHistoricalBarsResponseSchema, AlpacaLatestBarsResponseSchema, AlpacaLatestQuotesResponseSchema, AlpacaLatestTradesResponseSchema, AlpacaMarketDataAPI, AlpacaNewsArticleSchema, AlpacaNewsResponseSchema, AlpacaOrderSchema, AlpacaOrdersArraySchema, AlpacaPortfolioHistoryResponseSchema, AlpacaPositionSchema, AlpacaPositionsArraySchema, AlpacaQuoteSchema, AlpacaTradeSchema, AlpacaTradingAPI, AlphaVantageError, AlphaVantageQuoteResponseSchema, AssetAllocationEngine, AuthenticationError, AutonomyMode, BTC_PAIRS, BarError, CircuitOpenError, CryptoDataError, CryptoOrderError, DEFAULT_CACHE_OPTIONS, DEFAULT_RISK_FREE_RATE, DEFAULT_TIMEOUTS, DEFAULT_TRADING_POLICY, DataFormatError, DecisionMemoryOutcome, DecisionOutcome, DecisionRecordStatus, DuplicateClientOrderIdError, HttpClientError, HttpServerError, KEEP_ALIVE_DEFAULTS, LlmProvider, MARKET_DATA_API, MassiveAggregatesResponseSchema, MassiveApiError, MassiveDailyOpenCloseSchema, MassiveErrorResponseSchema, MassiveGroupedDailyResponseSchema, MassiveLastTradeResponseSchema, MassiveTickerDetailsResponseSchema, MassiveTickerInfoSchema, MassiveTradeSchema as MassiveTradeZodSchema, MassiveTradesResponseSchema, NetworkError, NewsError, OptionStrategyError, OptionsDataError, OverlaySeverity, OverlayStatus, OverlayType, QuoteError, RISK_FREE_RATE_TTL_MS, RateLimitError, RawMassivePriceDataSchema, StampedeProtectedCache, TRADING_API, TimeoutError, TokenBucketRateLimiter, TradeError, TrailingStopValidationError, USDC_PAIRS, USDT_PAIRS, USD_PAIRS, UnsupportedBrokerError, ValidationError, ValidationResponseError, WEBSOCKET_STREAMS, WebSocketError, account, adaptic, adptc, alpaca, analyzeBars, approximateImpliedVolatility, atrNs as atr, bracketOrders, buildOCCSymbol, buildOptionSymbol, buyCryptoNotional, buyToClose, buyToOpen, buyWithStopLoss, buyWithTrailingStop, calculateMoneyness, calculateOrderValue, calculatePeriodPerformance, calculatePutCallRatio, calculateTotalFilledValue, cancelAllCryptoOrders, cancelOCOOrder, cancelOTOOrder, cancelTrailingStop, cancelTrailingStopsForSymbol, checkTradingEligibility, clearClientCache, clock, closeAllOptionPositions, closeOptionPosition, createAlpacaClient, createAlpacaMarketDataAPI, createAlpacaTradingAPI, createBracketOrder, createBrokerClient, createButterflySpread, createClientFromEnv, createCoveredCall, createCryptoLimitOrder, createCryptoMarketOrder, createCryptoOrder, createCryptoStopLimitOrder, createCryptoStopOrder, createExecutorFromTradingAPI, createIronCondor$1 as createIronCondor, createIronCondor as createIronCondorAdvanced, createMultiLegOptionOrder, createOCOOrder, createOTOOrder, createOptionOrder, createPortfolioTrailingStops, createProtectiveBracket, createStampedeProtectedCache, createStraddle$1 as createStraddle, createStraddle as createStraddleAdvanced, createStrangle$1 as createStrangle, createStrangle as createStrangleAdvanced, createStreamManager, createTimeoutSignal, createTrailingStop, createVerticalSpread$1 as createVerticalSpread, createVerticalSpread as createVerticalSpreadAdvanced, entryWithPercentStopLoss, exerciseOption, extractGreeks, filterByExpiration, filterByStrike, filterByType, filterOrdersByDateRange, findATMOptions, findATMStrikes, findNearestExpiration, findOptionsByDelta, formatOrderForLog, formatOrderSummary, generateOptimalAllocation, getAccountConfiguration, getAccountDetails, getAccountSummary, getAgentPoolStatus, getAllOrders, getAlpacaCalendar, getAlpacaClock, getAverageDailyVolume, getBars, getBuyingPower, getCachedRiskFreeRateSync, getCachedRiskFreeRateSyncWithProvenance, getCrypto24HourChange, getCryptoBars, getCryptoDailyPrices, getCryptoPairsByQuote, getCryptoPrice, getCryptoSnapshots, getCryptoSpread, getCryptoStreamUrl, getCryptoTrades, getCurrentPrice, getCurrentPrices, getDailyPrices, getDailyReturns, getDaysToExpiration, getDefaultRiskProfile, getEquityCurve, getExpirationDates, getFilledOrders, getGroupedOptionChain, getHistoricalOptionsBars, getHistoricalTrades, getIntradayPrices, getLatestBars, getLatestCryptoQuotes, getLatestCryptoTrades, getLatestNews, getLatestOptionsQuotes, getLatestOptionsTrades, getLatestQuote, getLatestQuotes, getLatestTrade, getLatestTrades, getLogger, getMarginInfo, getNews, getNewsForSymbols, getOCOOrderStatus, getOTOOrderStatus, getOpenCryptoOrders, getOpenOrders$1 as getOpenOrdersQuery, getOpenTrailingStops, getOptionChain, getOptionContract, getOptionContracts, getOptionSpread, getOptionsChain, getOptionsSnapshots, getOptionsStreamUrl, getOptionsTradingLevel, getOrderHistory, getOrdersBySymbol, getPDTStatus, getPopularCryptoPairs, getPortfolioHistory, getPreviousClose, getPriceRange, getRiskFreeRate, getRiskFreeRateWithProvenance, getSpread, getSpreads, getStockStreamUrl, getStrikePrices, getSupportedCryptoPairs, getSymbolSentiment, getTimeout, getTradeVolume, getTradingApiUrl, getTradingWebSocketUrl, getTrailingStopHWM, groupOrdersByStatus, groupOrdersBySymbol, hasActiveTrailingStop, hasGoodLiquidity as hasOptionLiquidity, hasGoodLiquidity$1 as hasStockLiquidity, hasSufficientVolume, httpAgent, httpsAgent, isAlpacaBrokerCredentials, isContractTradable, isCryptoPair, isExpiringWithin, isMarginAccount, isOptionOrderCancelable, isOptionOrderTerminal, isOrderFillable, isOrderFilled, isOrderOpen, isOrderTerminal$1 as isOrderTerminalStatus, isSupportedCryptoPair, isTransientNetworkError, index$1 as legacyApi, limitBuyWithTakeProfit, ocoOrders, orderUtils, otoOrders, paginate, paginateAll, parseOCCSymbol, protectLongPosition, protectShortPosition, rateLimiters, resetLogger, resetRiskFreeRateCache, riskNs as risk, rollOptionPosition, roundPriceForAlpaca$3 as roundPriceForAlpaca, roundPriceForAlpacaNumber, safeValidateResponse, searchNews, sellAllCrypto, sellCryptoNotional, sellToClose, sellToOpen, setLogger, setRiskFreeRate, shortWithStopLoss, sortOrdersByDate, strategyNs as strategy, index as tradingPolicy, trailingStops, updateAccountConfiguration, updateTrailingStop, validateAlpacaCredentials, validateAlphaVantageApiKey, validateMassiveApiKey$1 as validateMassiveApiKey, validateMultiLegOrder, validateResponse, verifyFetchKeepAlive, volatilityNs as volatility, waitForOrderFill, withRetry, withTimeout };
70592
71035
  //# sourceMappingURL=index.mjs.map