@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.
- package/dist/index.cjs +513 -104
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +514 -106
- package/dist/index.mjs.map +1 -1
- package/dist/test.js +120 -9
- package/dist/test.js.map +1 -1
- package/dist/types/__tests__/alpaca-client-order-id.test.d.ts +2 -0
- package/dist/types/__tests__/alpaca-client-order-id.test.d.ts.map +1 -0
- package/dist/types/__tests__/alpaca-market-data-retry.test.d.ts +2 -0
- package/dist/types/__tests__/alpaca-market-data-retry.test.d.ts.map +1 -0
- package/dist/types/__tests__/performance-metrics-fees.test.d.ts +2 -0
- package/dist/types/__tests__/performance-metrics-fees.test.d.ts.map +1 -0
- package/dist/types/__tests__/price-utils-fees.test.d.ts +2 -0
- package/dist/types/__tests__/price-utils-fees.test.d.ts.map +1 -0
- package/dist/types/alpaca-market-data-api.d.ts.map +1 -1
- package/dist/types/alpaca-trading-api.d.ts +93 -8
- package/dist/types/alpaca-trading-api.d.ts.map +1 -1
- package/dist/types/cache/stampede-protected-cache.d.ts +19 -1
- package/dist/types/cache/stampede-protected-cache.d.ts.map +1 -1
- package/dist/types/errors/index.d.ts +25 -0
- package/dist/types/errors/index.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/performance-metrics.d.ts +31 -1
- package/dist/types/performance-metrics.d.ts.map +1 -1
- package/dist/types/price-utils.d.ts +12 -0
- package/dist/types/price-utils.d.ts.map +1 -1
- package/dist/types/rate-limiter.d.ts.map +1 -1
- package/dist/types/utils/retry.d.ts +14 -0
- package/dist/types/utils/retry.d.ts.map +1 -1
- 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
|
-
|
|
2904
|
-
|
|
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
|
-
|
|
3001
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5063
|
-
|
|
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
|
-
|
|
5073
|
-
|
|
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
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
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
|
-
|
|
5115
|
-
|
|
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
|
|
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
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
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
|
-
|
|
5301
|
-
|
|
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
|
|
5483
|
-
clientOrderId
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
])
|
|
5495
|
-
|
|
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
|
|
5535
|
-
clientOrderId
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
])
|
|
5545
|
-
|
|
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
|
-
|
|
6023
|
-
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
|
|
6033
|
-
|
|
6034
|
-
|
|
6035
|
-
|
|
6036
|
-
|
|
6037
|
-
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
11084
|
-
|
|
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
|
|
63566
|
-
const
|
|
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
|
-
|
|
63624
|
-
|
|
63625
|
-
|
|
63626
|
-
cached
|
|
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 -
|
|
63664
|
-
const jitteredTtl =
|
|
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:
|
|
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;
|