@adaptic/utils 0.0.1012 → 0.0.1014

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 (43) hide show
  1. package/dist/index.cjs +698 -116
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.mjs +695 -117
  4. package/dist/index.mjs.map +1 -1
  5. package/dist/test.js +8 -0
  6. package/dist/test.js.map +1 -1
  7. package/dist/types/__tests__/alpaca-broker-error-preservation.test.d.ts +2 -0
  8. package/dist/types/__tests__/alpaca-broker-error-preservation.test.d.ts.map +1 -0
  9. package/dist/types/__tests__/metrics-calcs-direction.test.d.ts +2 -0
  10. package/dist/types/__tests__/metrics-calcs-direction.test.d.ts.map +1 -0
  11. package/dist/types/__tests__/protective-order-sides.test.d.ts +2 -0
  12. package/dist/types/__tests__/protective-order-sides.test.d.ts.map +1 -0
  13. package/dist/types/__tests__/technical-analysis-totality.test.d.ts +2 -0
  14. package/dist/types/__tests__/technical-analysis-totality.test.d.ts.map +1 -0
  15. package/dist/types/__tests__/trailing-stops-portfolio.test.d.ts +2 -0
  16. package/dist/types/__tests__/trailing-stops-portfolio.test.d.ts.map +1 -0
  17. package/dist/types/alpaca/index.d.ts +1 -1
  18. package/dist/types/alpaca/index.d.ts.map +1 -1
  19. package/dist/types/alpaca/legacy/orders.d.ts.map +1 -1
  20. package/dist/types/alpaca/trading/bracket-orders.d.ts +25 -3
  21. package/dist/types/alpaca/trading/bracket-orders.d.ts.map +1 -1
  22. package/dist/types/alpaca/trading/orders.d.ts.map +1 -1
  23. package/dist/types/alpaca/trading/oto-orders.d.ts +8 -2
  24. package/dist/types/alpaca/trading/oto-orders.d.ts.map +1 -1
  25. package/dist/types/alpaca/trading/trailing-stops.d.ts +6 -3
  26. package/dist/types/alpaca/trading/trailing-stops.d.ts.map +1 -1
  27. package/dist/types/alpaca-trading-api.d.ts.map +1 -1
  28. package/dist/types/asset-allocation-algorithm.d.ts.map +1 -1
  29. package/dist/types/errors/index.d.ts +149 -1
  30. package/dist/types/errors/index.d.ts.map +1 -1
  31. package/dist/types/index.d.ts +3 -3
  32. package/dist/types/index.d.ts.map +1 -1
  33. package/dist/types/metrics-calcs.d.ts +6 -0
  34. package/dist/types/metrics-calcs.d.ts.map +1 -1
  35. package/dist/types/schemas/massive-schemas.d.ts +6 -6
  36. package/dist/types/technical-analysis.d.ts +26 -1
  37. package/dist/types/technical-analysis.d.ts.map +1 -1
  38. package/dist/types/trading-policy/defaults/default-trading-policy.d.ts.map +1 -1
  39. package/dist/types/types/metrics-types.d.ts +8 -1
  40. package/dist/types/types/metrics-types.d.ts.map +1 -1
  41. package/dist/types/types/ta-types.d.ts +10 -1
  42. package/dist/types/types/ta-types.d.ts.map +1 -1
  43. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -2212,11 +2212,20 @@ class AdapticUtilsError extends Error {
2212
2212
  */
2213
2213
  class AlpacaApiError extends AdapticUtilsError {
2214
2214
  statusCode;
2215
- constructor(message, code, statusCode, cause) {
2215
+ brokerError;
2216
+ constructor(message, code, statusCode, cause,
2217
+ /**
2218
+ * Normalized Alpaca broker-rejection detail (numeric code + message + raw
2219
+ * body), when the underlying rejection carried one. Additive and optional:
2220
+ * synthetic errors and non-broker failures omit it, and every existing
2221
+ * consumer of `message`/`code`/`statusCode`/`cause` is unaffected.
2222
+ */
2223
+ brokerError) {
2216
2224
  // Rate limit (429) and server errors (5xx) are retryable
2217
2225
  const isRetryable = statusCode === 429 || (statusCode !== undefined && statusCode >= 500);
2218
2226
  super(message, code, "alpaca", isRetryable, cause);
2219
2227
  this.statusCode = statusCode;
2228
+ this.brokerError = brokerError;
2220
2229
  }
2221
2230
  }
2222
2231
  /**
@@ -2412,11 +2421,261 @@ class DuplicateClientOrderIdError extends AlpacaApiError {
2412
2421
  clientOrderId,
2413
2422
  /** Whether the colliding id was derived by the wrapper (vs caller-supplied). */
2414
2423
  wasDerived, cause) {
2415
- super(message, "DUPLICATE_CLIENT_ORDER_ID", 422, cause);
2424
+ // Carry the normalized broker payload forward from the original rejection
2425
+ // (the `cause`) so a consumer can read the numeric code without re-parsing.
2426
+ super(message, "DUPLICATE_CLIENT_ORDER_ID", 422, cause, extractAlpacaBrokerError(cause));
2416
2427
  this.clientOrderId = clientOrderId;
2417
2428
  this.wasDerived = wasDerived;
2418
2429
  }
2419
2430
  }
2431
+ /** Max depth walked along the `error.cause` chain when locating a broker payload. */
2432
+ const MAX_BROKER_ERROR_CAUSE_DEPTH = 6;
2433
+ /**
2434
+ * Narrows an unknown value to an index-signature record so nested properties
2435
+ * can be probed without an unsafe cast.
2436
+ * @param value - The value to test.
2437
+ * @returns true when the value is a non-null object.
2438
+ */
2439
+ function isBrokerErrorRecord(value) {
2440
+ return typeof value === "object" && value !== null;
2441
+ }
2442
+ /**
2443
+ * Reads a finite number from an unknown value, accepting Alpaca's numeric
2444
+ * `code` whether it arrives as a JSON number or a numeric string.
2445
+ * @param value - The candidate value.
2446
+ * @returns The number when finite, otherwise null.
2447
+ */
2448
+ function asBrokerCode(value) {
2449
+ if (typeof value === "number" && Number.isFinite(value)) {
2450
+ return value;
2451
+ }
2452
+ if (typeof value === "string" && value.trim() !== "") {
2453
+ const parsed = Number(value);
2454
+ return Number.isFinite(parsed) ? parsed : null;
2455
+ }
2456
+ return null;
2457
+ }
2458
+ /**
2459
+ * Reads the axios/SDK-shaped broker payload from a SINGLE node's `response`
2460
+ * field: an object `response.data` (`{ code, message }`) or a `response.data`
2461
+ * left as an unparsed JSON string (the raw-`fetch` seams attach the body as a
2462
+ * string). A known HTTP `response.status` is itself a broker-boundary signal —
2463
+ * a `422` whose body carries no numeric code is still a `422` — so a
2464
+ * status-only detail (`brokerCode: null`) is surfaced rather than discarded.
2465
+ * Returns `undefined` only when the node carries no `response` and no status.
2466
+ *
2467
+ * @param node - The candidate error-like record.
2468
+ * @returns The normalized detail, or undefined when the node has no response.
2469
+ */
2470
+ function readResponseBrokerDetail(node) {
2471
+ const response = node.response;
2472
+ if (!isBrokerErrorRecord(response)) {
2473
+ return undefined;
2474
+ }
2475
+ const statusCode = asBrokerCode(response.status);
2476
+ // A known status with no structured code/message: preserve the status rather
2477
+ // than discarding it (a code null is never fabricated into a value).
2478
+ const statusOnly = statusCode === null
2479
+ ? undefined
2480
+ : { brokerCode: null, brokerMessage: null, statusCode, raw: response.data };
2481
+ // Keep the raw body in its own const so the string narrowing survives the
2482
+ // JSON.parse (a reassigned `let` would widen back to `unknown` in the catch).
2483
+ const rawData = response.data;
2484
+ let parsed = rawData;
2485
+ if (typeof rawData === "string") {
2486
+ try {
2487
+ parsed = JSON.parse(rawData);
2488
+ }
2489
+ catch {
2490
+ // A non-JSON string body carries a human reason but no structured code.
2491
+ return { brokerCode: null, brokerMessage: rawData, statusCode, raw: rawData };
2492
+ }
2493
+ }
2494
+ if (!isBrokerErrorRecord(parsed)) {
2495
+ return statusOnly;
2496
+ }
2497
+ const brokerCode = asBrokerCode(parsed.code);
2498
+ const brokerMessage = typeof parsed.message === "string" ? parsed.message : null;
2499
+ if (brokerCode === null && brokerMessage === null) {
2500
+ return statusOnly;
2501
+ }
2502
+ return { brokerCode, brokerMessage, statusCode, raw: rawData };
2503
+ }
2504
+ /**
2505
+ * Reads the normalized broker detail from a SINGLE error-like node, without
2506
+ * walking its `cause` chain. Recognizes two carriers on the node: an
2507
+ * {@link AlpacaBrokerErrorDetail} already attached as `brokerError`, and an
2508
+ * axios/SDK-shaped `response` body (object or unparsed JSON string). A carrier
2509
+ * bearing a numeric code wins over a code-less one, so an enrichment that
2510
+ * resolved no code never shadows a numeric code sitting in the same node's raw
2511
+ * response body. Returns `undefined` when the node carries no broker payload,
2512
+ * so absence is never converted into a fabricated code.
2513
+ *
2514
+ * @param node - The candidate error-like value.
2515
+ * @returns The normalized detail, or undefined.
2516
+ */
2517
+ function readBrokerDetailFromNode(node) {
2518
+ if (!isBrokerErrorRecord(node)) {
2519
+ return undefined;
2520
+ }
2521
+ // Carrier 1: a detail already normalized and attached by this module
2522
+ // (e.g. AlpacaApiError.brokerError or a value enriched via enrichAlpacaError).
2523
+ let attachedDetail;
2524
+ const attached = node.brokerError;
2525
+ if (isBrokerErrorRecord(attached) && "brokerCode" in attached) {
2526
+ attachedDetail = {
2527
+ brokerCode: asBrokerCode(attached.brokerCode),
2528
+ brokerMessage: typeof attached.brokerMessage === "string"
2529
+ ? attached.brokerMessage
2530
+ : null,
2531
+ statusCode: asBrokerCode(attached.statusCode),
2532
+ raw: attached.raw,
2533
+ };
2534
+ // A numeric code on the attached detail is authoritative for this node.
2535
+ if (attachedDetail.brokerCode !== null) {
2536
+ return attachedDetail;
2537
+ }
2538
+ }
2539
+ // Carrier 2: an axios/SDK-shaped `response` body on the same node. Prefer a
2540
+ // numeric code found here over a code-less attached detail.
2541
+ const responseDetail = readResponseBrokerDetail(node);
2542
+ if (responseDetail?.brokerCode != null) {
2543
+ return responseDetail;
2544
+ }
2545
+ return attachedDetail ?? responseDetail;
2546
+ }
2547
+ /**
2548
+ * Extracts the normalized {@link AlpacaBrokerErrorDetail} from a thrown Alpaca
2549
+ * SDK/axios error, reading the vendor payload at `error.response.data` and,
2550
+ * failing that, walking the `error.cause` chain (the raw SDK error is preserved
2551
+ * there once a wrapper has re-thrown). Returns `undefined` when no broker
2552
+ * payload is present anywhere on the chain.
2553
+ *
2554
+ * Pure and outcome-independent: derived solely from Alpaca's documented error
2555
+ * contract, with zero reference to realized P&L, fills, or account state.
2556
+ *
2557
+ * A node bearing a numeric broker code wins immediately; a code-less detail
2558
+ * (status-only or message-only) found higher on the chain is held as a fallback
2559
+ * while the walk continues, so a numeric code sitting deeper in the `cause`
2560
+ * chain is never shadowed by a shallower code-less node — and when no code
2561
+ * exists anywhere, the code-less detail is still returned rather than discarded.
2562
+ *
2563
+ * @param error - The thrown value.
2564
+ * @returns The normalized broker detail, or undefined when none is present.
2565
+ */
2566
+ function extractAlpacaBrokerError(error) {
2567
+ let current = error;
2568
+ let fallback;
2569
+ for (let depth = 0; depth < MAX_BROKER_ERROR_CAUSE_DEPTH && current != null; depth++) {
2570
+ const detail = readBrokerDetailFromNode(current);
2571
+ if (detail !== undefined) {
2572
+ if (detail.brokerCode !== null) {
2573
+ return detail;
2574
+ }
2575
+ if (fallback === undefined) {
2576
+ fallback = detail;
2577
+ }
2578
+ }
2579
+ if (!isBrokerErrorRecord(current)) {
2580
+ break;
2581
+ }
2582
+ current = current.cause;
2583
+ }
2584
+ return fallback;
2585
+ }
2586
+ /**
2587
+ * Returns the normalized {@link AlpacaBrokerErrorDetail} for a thrown error, or
2588
+ * `null` when the error carries no Alpaca broker payload. The typed
2589
+ * vendor-boundary replacement for reaching into `err.response.data` downstream.
2590
+ *
2591
+ * @param error - The thrown value.
2592
+ * @returns The normalized detail, or null.
2593
+ */
2594
+ function getAlpacaBrokerErrorDetail(error) {
2595
+ return extractAlpacaBrokerError(error) ?? null;
2596
+ }
2597
+ /**
2598
+ * Returns Alpaca's machine-readable numeric broker error code from a thrown
2599
+ * error (walking the `cause` chain), or `null` when absent. The typed
2600
+ * replacement for `err.message.includes("42210000")`:
2601
+ *
2602
+ * ```typescript
2603
+ * if (getAlpacaBrokerErrorCode(err) === 42210000) { ... } // stale-order reject
2604
+ * ```
2605
+ *
2606
+ * The code resolves uniformly across every vendor seam: the SDK/axios path
2607
+ * (where `response.data` rides along for free) and the raw-`fetch` paths — the
2608
+ * `AlpacaTradingAPI` class `makeRequest` and the legacy order helpers, which
2609
+ * throw via {@link alpacaHttpError} so the verbatim status + body are carried as
2610
+ * a typed `.response`. A consumer branching on the stale-order `42210000` gets
2611
+ * the same answer regardless of which seam produced the reject, including the
2612
+ * dominant percent-trailing-stop tighten path where a plain `Error` previously
2613
+ * dropped the broker payload.
2614
+ *
2615
+ * @param error - The thrown value.
2616
+ * @returns The numeric broker code, or null.
2617
+ */
2618
+ function getAlpacaBrokerErrorCode(error) {
2619
+ return extractAlpacaBrokerError(error)?.brokerCode ?? null;
2620
+ }
2621
+ /**
2622
+ * Additively enriches a thrown error with the normalized Alpaca broker detail
2623
+ * extracted from `source` (the original SDK/axios rejection), WITHOUT changing
2624
+ * the target's `message`, `name`, or prototype. It:
2625
+ *
2626
+ * - sets `target.cause = source` when the target has no cause yet, so the raw
2627
+ * rejection (and its `response.data`) is never lost down the wrapper chain;
2628
+ * - attaches the normalized {@link AlpacaBrokerErrorDetail} as
2629
+ * `target.brokerError` when `source` carried a broker payload.
2630
+ *
2631
+ * Purely additive by construction: a caller writes
2632
+ * `throw enrichAlpacaError(new Error(msg), error)` and every consumer that read
2633
+ * `error.message` or `error instanceof Error` before reads the identical value
2634
+ * after, while new consumers can call {@link getAlpacaBrokerErrorCode}. This is
2635
+ * the restoration for the dropped-`response.data` defect (Alpaca `42210000` /
2636
+ * `40310000` reaching consumers only as a lossy "status code NNN" string).
2637
+ *
2638
+ * @param target - The wrapper error about to be thrown.
2639
+ * @param source - The original rejection to normalize and preserve.
2640
+ * @returns The same `target`, typed to expose the optional `brokerError`.
2641
+ */
2642
+ function enrichAlpacaError(target, source) {
2643
+ const enriched = target;
2644
+ if (enriched.cause === undefined && source !== undefined) {
2645
+ enriched.cause = source;
2646
+ }
2647
+ const detail = extractAlpacaBrokerError(source);
2648
+ if (detail !== undefined) {
2649
+ enriched.brokerError = detail;
2650
+ }
2651
+ return enriched;
2652
+ }
2653
+ /**
2654
+ * Builds a thrown-ready `Error` for a raw-`fetch` Alpaca rejection, carrying the
2655
+ * verbatim HTTP status + body as a typed `.response` so that
2656
+ * {@link getAlpacaBrokerErrorCode} / {@link extractAlpacaBrokerError} resolve
2657
+ * the numeric broker code on the `fetch` seams (the `AlpacaTradingAPI` class
2658
+ * `makeRequest` and the legacy functional order helpers) exactly as they
2659
+ * already do on the SDK seam — where the SDK/axios error carries `response.data`
2660
+ * for free but a hand-thrown `new Error(...)` does not.
2661
+ *
2662
+ * Purely additive by construction: the `.message` is caller-supplied and
2663
+ * returned byte-identical (so message string-matching consumers are
2664
+ * unaffected), the returned value `instanceof Error` still holds, and only the
2665
+ * `.response` surface is added. The `data` is the raw string body exactly as
2666
+ * `response.text()` returned it — {@link extractAlpacaBrokerError} parses a
2667
+ * JSON-string body itself, so no vendor payload is lost or reshaped here.
2668
+ *
2669
+ * @param message - The error message, thrown verbatim (never rewritten).
2670
+ * @param status - The HTTP status the rejection arrived on.
2671
+ * @param body - The raw response body (`response.text()`), preserved verbatim.
2672
+ * @returns An `Error` whose `.response` exposes `{ status, data: body }`.
2673
+ */
2674
+ function alpacaHttpError(message, status, body) {
2675
+ return Object.assign(new Error(message), {
2676
+ response: { status, data: body },
2677
+ });
2678
+ }
2420
2679
 
2421
2680
  const DEFAULT_RETRY_CONFIG = {
2422
2681
  maxRetries: 3,
@@ -5296,7 +5555,13 @@ class AlpacaTradingAPI {
5296
5555
  this.log(`Alpaca API error (${response.status}): ${errorText}`, {
5297
5556
  type: "error",
5298
5557
  });
5299
- throw new Error(`Alpaca API error (${response.status}): ${errorText}`);
5558
+ // Additive broker-error preservation: the message is byte-identical
5559
+ // (existing "422"/"42210000" string-matching consumers are unaffected),
5560
+ // and the verbatim status + body ride along as a typed `.response` so
5561
+ // getAlpacaBrokerErrorCode resolves the numeric code on this fetch seam —
5562
+ // the dominant percent-trailing-stop tighten path and the 08-20 defect
5563
+ // site, where a plain Error dropped the broker's response.data.
5564
+ throw alpacaHttpError(`Alpaca API error (${response.status}): ${errorText}`, response.status, errorText);
5300
5565
  }
5301
5566
  // Handle responses with no content (e.g., 204 No Content)
5302
5567
  if (response.status === 204 ||
@@ -5681,7 +5946,10 @@ class AlpacaTradingAPI {
5681
5946
  this.log(`Order ${orderId} is not cancelable`, {
5682
5947
  type: "error",
5683
5948
  });
5684
- throw new Error(`Order ${orderId} is not cancelable`);
5949
+ // Re-message stays byte-identical; the broker payload from makeRequest's
5950
+ // `.response` is carried onto the new error so the numeric code survives
5951
+ // this wrapper instead of being dropped at the re-throw.
5952
+ throw enrichAlpacaError(new Error(`Order ${orderId} is not cancelable`), error);
5685
5953
  }
5686
5954
  // Re-throw other errors
5687
5955
  throw error;
@@ -6731,7 +6999,7 @@ async function makeRequest(auth, params) {
6731
6999
  source: "AlpacaAPI",
6732
7000
  type: "error",
6733
7001
  });
6734
- throw new Error(`Alpaca API error (${response.status}): ${errorText}`);
7002
+ throw alpacaHttpError(`Alpaca API error (${response.status}): ${errorText}`, response.status, errorText);
6735
7003
  }
6736
7004
  catch (err) {
6737
7005
  const error = err;
@@ -6764,7 +7032,7 @@ async function createOrder$1(auth, params) {
6764
7032
  });
6765
7033
  if (!response.ok) {
6766
7034
  const errorText = await response.text();
6767
- throw new Error(`Failed to create order: ${response.status} ${response.statusText} ${errorText}`);
7035
+ throw alpacaHttpError(`Failed to create order: ${response.status} ${response.statusText} ${errorText}`, response.status, errorText);
6768
7036
  }
6769
7037
  return (await response.json());
6770
7038
  }
@@ -6811,7 +7079,7 @@ async function getOrders$1(auth, params = {}) {
6811
7079
  });
6812
7080
  if (!response.ok) {
6813
7081
  const errorText = await response.text();
6814
- throw new Error(`Failed to get orders: ${response.status} ${response.statusText} ${errorText}`);
7082
+ throw alpacaHttpError(`Failed to get orders: ${response.status} ${response.statusText} ${errorText}`, response.status, errorText);
6815
7083
  }
6816
7084
  const orders = (await response.json());
6817
7085
  allOrders.push(...orders);
@@ -6872,7 +7140,7 @@ async function cancelAllOrders$1(auth) {
6872
7140
  });
6873
7141
  if (!response.ok) {
6874
7142
  const errorText = await response.text();
6875
- throw new Error(`Failed to cancel orders: ${response.status} ${response.statusText} ${errorText}`);
7143
+ throw alpacaHttpError(`Failed to cancel orders: ${response.status} ${response.statusText} ${errorText}`, response.status, errorText);
6876
7144
  }
6877
7145
  return (await response.json());
6878
7146
  }
@@ -6905,7 +7173,7 @@ async function getOrder$1(auth, orderId, nested) {
6905
7173
  });
6906
7174
  if (!response.ok) {
6907
7175
  const errorText = await response.text();
6908
- throw new Error(`Failed to get order: ${response.status} ${response.statusText} ${errorText}`);
7176
+ throw alpacaHttpError(`Failed to get order: ${response.status} ${response.statusText} ${errorText}`, response.status, errorText);
6909
7177
  }
6910
7178
  return (await response.json());
6911
7179
  }
@@ -6938,7 +7206,7 @@ async function replaceOrder$1(auth, orderId, params) {
6938
7206
  });
6939
7207
  if (!response.ok) {
6940
7208
  const errorText = await response.text();
6941
- throw new Error(`Failed to replace order: ${response.status} ${response.statusText} ${errorText}`);
7209
+ throw alpacaHttpError(`Failed to replace order: ${response.status} ${response.statusText} ${errorText}`, response.status, errorText);
6942
7210
  }
6943
7211
  return (await response.json());
6944
7212
  }
@@ -6971,7 +7239,7 @@ async function cancelOrder$1(auth, orderId) {
6971
7239
  return { success: false, message: `Order not found: ${orderId}` };
6972
7240
  }
6973
7241
  else {
6974
- throw new Error(`Failed to cancel order: ${response.status} ${response.statusText} ${errorText}`);
7242
+ throw alpacaHttpError(`Failed to cancel order: ${response.status} ${response.statusText} ${errorText}`, response.status, errorText);
6975
7243
  }
6976
7244
  }
6977
7245
  return { success: true };
@@ -11415,7 +11683,13 @@ async function calculateMaxDrawdown$1(tradeBars, isShort) {
11415
11683
  peak = positionAwareEquity[i];
11416
11684
  }
11417
11685
  else {
11418
- const drawdown = peak <= 0 ? 0 : (peak - positionAwareEquity[i]) / Math.abs(peak);
11686
+ // The short branch negates equity, so its peak is legitimately negative.
11687
+ // Scale the decline by the peak's magnitude — a sign test on the peak
11688
+ // would discard every drawdown on one side of the book.
11689
+ const denominator = Math.abs(peak);
11690
+ const drawdown = denominator === 0
11691
+ ? 0
11692
+ : (peak - positionAwareEquity[i]) / denominator;
11419
11693
  if (drawdown > maxDrawdown) {
11420
11694
  maxDrawdown = drawdown;
11421
11695
  }
@@ -11428,17 +11702,71 @@ async function calculateExpenseRatio$1(trade) {
11428
11702
  const totalFees = await computeTotalFees(trade);
11429
11703
  return totalFees ? `${totalFees.toFixed(2)}%` : "N/A";
11430
11704
  }
11705
+ /**
11706
+ * Resolves whether a trade is short from its primary action.
11707
+ *
11708
+ * Only an outright BUY or SELL fixes whether the position's P&L runs with or
11709
+ * against the price series. Option legs, exercises, cancels, adjustments and
11710
+ * hedges do not, and `trade.actions` itself is curated by backend-legacy
11711
+ * selection-set directives, so its absence is routine. Every one of those
11712
+ * cases leaves the direction genuinely unknown, and unknown is returned as
11713
+ * such — inferring a side would silently invert every direction-aware metric
11714
+ * computed from it.
11715
+ *
11716
+ * @param trade - Trade whose direction is being resolved
11717
+ * @returns `true` for a short, `false` for a long, `null` when unresolvable
11718
+ */
11719
+ function resolveIsShort(trade) {
11720
+ const primaryAction = trade.actions?.find((action) => action.primary);
11721
+ if (!primaryAction) {
11722
+ getLogger().warn(`Trade ${trade.id} has no primary action; position direction is unresolved.`);
11723
+ return null;
11724
+ }
11725
+ switch (primaryAction.type) {
11726
+ case "SELL":
11727
+ return true;
11728
+ case "BUY":
11729
+ return false;
11730
+ default:
11731
+ getLogger().warn(`Trade ${trade.id} primary action type "${primaryAction.type}" does not determine a long/short direction.`);
11732
+ return null;
11733
+ }
11734
+ }
11431
11735
  // Main function to fetch and calculate all trade metrics for one trade object
11432
11736
  async function fetchTradeMetrics(trade, tradeBars, benchmarkBars) {
11433
- const isShort = trade.actions?.find((a) => a.primary)?.type === "SELL" ? true : false;
11737
+ const isShort = resolveIsShort(trade);
11738
+ // The Sharpe ratio and the expense ratio do not invert on direction, so they
11739
+ // are started immediately and stay concurrent with everything below.
11740
+ const riskAdjustedReturnPromise = calculateRiskAdjustedReturn$1(tradeBars);
11741
+ const expenseRatioPromise = calculateExpenseRatio$1(trade);
11742
+ if (isShort === null) {
11743
+ // Every other metric inverts on direction. With the direction unknown
11744
+ // there is no value to report — only a sign-ambiguous one — so they are
11745
+ // reported as unavailable rather than resolved by assumption.
11746
+ const [riskAdjustedReturn, expenseRatio] = await Promise.all([
11747
+ riskAdjustedReturnPromise,
11748
+ expenseRatioPromise,
11749
+ ]);
11750
+ return {
11751
+ totalReturnYTD: "N/A",
11752
+ alpha: "N/A",
11753
+ beta: "N/A",
11754
+ alphaAnnualized: "N/A",
11755
+ informationRatio: "N/A",
11756
+ riskAdjustedReturn,
11757
+ expenseRatio,
11758
+ maxDrawdown: "N/A",
11759
+ side: "N/A",
11760
+ };
11761
+ }
11434
11762
  // Calculate metrics concurrently
11435
- const [totalReturnYTD, { alpha, beta, alphaAnnualized }, informationRatio, riskAdjustedReturn, expenseRatio, maxDrawdown,] = await Promise.all([
11763
+ const [totalReturnYTD, { alpha, beta, alphaAnnualized }, informationRatio, maxDrawdown, riskAdjustedReturn, expenseRatio,] = await Promise.all([
11436
11764
  calculateProfitLoss(tradeBars, isShort),
11437
11765
  calculateAlphaAndBeta$1(tradeBars, benchmarkBars, isShort),
11438
11766
  calculateInformationRatio$1(tradeBars, benchmarkBars, isShort),
11439
- calculateRiskAdjustedReturn$1(tradeBars),
11440
- calculateExpenseRatio$1(trade),
11441
11767
  calculateMaxDrawdown$1(tradeBars, isShort),
11768
+ riskAdjustedReturnPromise,
11769
+ expenseRatioPromise,
11442
11770
  ]);
11443
11771
  return {
11444
11772
  totalReturnYTD,
@@ -12802,6 +13130,66 @@ var strategyNs = /*#__PURE__*/Object.freeze({
12802
13130
  calculateRollingSortino: calculateRollingSortino
12803
13131
  });
12804
13132
 
13133
+ /**
13134
+ * Round a PRICE-scale indicator output to a precision derived from its own
13135
+ * magnitude, rather than a hardcoded 2 decimal places.
13136
+ *
13137
+ * A flat `toFixed(2)` silently destroys every sub-penny price — a $0.0003
13138
+ * microcap's bands collapse to `0.00`, and a MACD histogram of a low-priced
13139
+ * name rounds to nothing (F7.2). Precision must scale with the price: values at
13140
+ * or above $1 keep the conventional 2dp, while sub-dollar values keep ~4
13141
+ * significant figures so the number survives its own scale. Non-finite inputs
13142
+ * pass through untouched — totality of the underlying value is the caller's
13143
+ * responsibility, this helper only quantises.
13144
+ *
13145
+ * The `>= $1` branch delegates to `toFixed(2)` rather than re-deriving it as
13146
+ * `Math.round(value * 100) / 100`. The two disagree wherever the intermediate
13147
+ * `value * 100` rounds onto an exact `.5` that the decimal value sits just
13148
+ * below (`1.045` → `1.05` vs `1.04`), which would make this helper shift
13149
+ * ordinary dollar prices by a cent — a behaviour change well outside repairing
13150
+ * sub-penny collapse. Delegating keeps the common case byte-identical to the
13151
+ * historical output by construction, which matters because the same function
13152
+ * computes indicators for unit tests, backtests, paper and live.
13153
+ *
13154
+ * @param value - A price-scale indicator output (band, EMA, MACD component).
13155
+ * @returns The value rounded to a scale-appropriate precision.
13156
+ */
13157
+ function roundToPriceScale(value) {
13158
+ if (!Number.isFinite(value))
13159
+ return value;
13160
+ const abs = Math.abs(value);
13161
+ if (abs === 0)
13162
+ return 0;
13163
+ if (abs >= 1)
13164
+ return parseFloat(value.toFixed(2));
13165
+ // Sub-dollar: decimals = leading zeros after the point + 4 significant figures,
13166
+ // capped so the factor stays within safe-integer range.
13167
+ const decimals = Math.min(12, Math.ceil(-Math.log10(abs)) + 4);
13168
+ const factor = 10 ** decimals;
13169
+ return Math.round(value * factor) / factor;
13170
+ }
13171
+ /**
13172
+ * Relative Strength Index from average gain / average loss, total on the
13173
+ * degenerate flat window.
13174
+ *
13175
+ * When a window has no losses the Wilder ratio `avgGain / avgLoss` is
13176
+ * `+Infinity` (→ RSI 100); on a perfectly flat window it is `0 / 0 = NaN`,
13177
+ * which the naive formula propagates straight into the output. A flat window
13178
+ * carries no momentum, so its RSI is the neutral 50 — never NaN. This mirrors
13179
+ * the engine's live RSI guards (a constant series scores neutral, an all-gains
13180
+ * series scores 100).
13181
+ *
13182
+ * @param avgGain - Average gain over the period (>= 0).
13183
+ * @param avgLoss - Average loss over the period (>= 0).
13184
+ * @returns RSI in [0, 100]; 50 for a flat window, 100 for an all-gains window.
13185
+ */
13186
+ function rsiFromAverages(avgGain, avgLoss) {
13187
+ if (avgLoss === 0)
13188
+ return avgGain === 0 ? 50 : 100;
13189
+ const rs = avgGain / avgLoss;
13190
+ const rsi = 100 - 100 / (1 + rs);
13191
+ return Number.isFinite(rsi) ? rsi : 50;
13192
+ }
12805
13193
  /**
12806
13194
  * Calculates Bollinger Bands for a given set of price data.
12807
13195
  * Bollinger Bands consist of a middle band (SMA) and two outer bands
@@ -12834,9 +13222,9 @@ function calculateBollingerBands(priceData, { period = 20, standardDeviations =
12834
13222
  const lowerBand = sma - standardDeviation * standardDeviations;
12835
13223
  result.push({
12836
13224
  date: priceData[i].date,
12837
- middle: parseFloat(sma.toFixed(2)),
12838
- upper: parseFloat(upperBand.toFixed(2)),
12839
- lower: parseFloat(lowerBand.toFixed(2)),
13225
+ middle: roundToPriceScale(sma),
13226
+ upper: roundToPriceScale(upperBand),
13227
+ lower: roundToPriceScale(lowerBand),
12840
13228
  close: priceData[i].close,
12841
13229
  });
12842
13230
  }
@@ -12878,11 +13266,11 @@ function calculateEMA(priceData, { period = 20, period2 = 9 } = {}) {
12878
13266
  // Add first EMA(s)
12879
13267
  const firstEntry = {
12880
13268
  date: priceData[Math.max(period, period2 || 0) - 1].date,
12881
- ema: parseFloat(prevEMA.toFixed(2)),
13269
+ ema: roundToPriceScale(prevEMA),
12882
13270
  close: priceData[Math.max(period, period2 || 0) - 1].close,
12883
13271
  };
12884
13272
  if (period2) {
12885
- firstEntry.ema2 = parseFloat(prevEMA2.toFixed(2));
13273
+ firstEntry.ema2 = roundToPriceScale(prevEMA2);
12886
13274
  }
12887
13275
  result.push(firstEntry);
12888
13276
  // Calculate EMA for remaining periods
@@ -12892,18 +13280,55 @@ function calculateEMA(priceData, { period = 20, period2 = 9 } = {}) {
12892
13280
  prevEMA = currentEMA;
12893
13281
  const entry = {
12894
13282
  date: priceData[i].date,
12895
- ema: parseFloat(currentEMA.toFixed(2)),
13283
+ ema: roundToPriceScale(currentEMA),
12896
13284
  close: currentClose,
12897
13285
  };
12898
13286
  if (period2) {
12899
13287
  const currentEMA2 = (currentClose - prevEMA2) * multiplier2 + prevEMA2;
12900
13288
  prevEMA2 = currentEMA2;
12901
- entry.ema2 = parseFloat(currentEMA2.toFixed(2));
13289
+ entry.ema2 = roundToPriceScale(currentEMA2);
12902
13290
  }
12903
13291
  result.push(entry);
12904
13292
  }
12905
13293
  return result;
12906
13294
  }
13295
+ /**
13296
+ * Locates a window's swing extremes and derives the direction of its most
13297
+ * recent leg from the order in which those extremes print.
13298
+ *
13299
+ * A Fibonacci construction is anchored to the latest leg: an up-leg runs swing
13300
+ * low to swing high, a down-leg swing high to swing low. Whichever extreme
13301
+ * prints last therefore identifies the leg, which makes the direction a
13302
+ * measurement of the window rather than a caller's assumption. When both
13303
+ * extremes land on the same bar the window contains no leg and the direction
13304
+ * is genuinely indeterminate.
13305
+ *
13306
+ * @param window - The lookback slice to analyse.
13307
+ * @returns The window's swing extremes and derived leg direction.
13308
+ */
13309
+ function analyzeSwingWindow(window) {
13310
+ let swingHigh = -Infinity;
13311
+ let swingLow = Infinity;
13312
+ let highIndex = -1;
13313
+ let lowIndex = -1;
13314
+ // `>=` / `<=` keep the most recent occurrence of each extreme, which is the
13315
+ // one the current leg is measured from.
13316
+ for (let i = 0; i < window.length; i++) {
13317
+ if (window[i].high >= swingHigh) {
13318
+ swingHigh = window[i].high;
13319
+ highIndex = i;
13320
+ }
13321
+ if (window[i].low <= swingLow) {
13322
+ swingLow = window[i].low;
13323
+ lowIndex = i;
13324
+ }
13325
+ }
13326
+ return {
13327
+ swingHigh,
13328
+ swingLow,
13329
+ trend: highIndex === lowIndex ? null : highIndex > lowIndex ? "uptrend" : "downtrend",
13330
+ };
13331
+ }
12907
13332
  /**
12908
13333
  * Calculates Fibonacci retracement and extension levels based on price data.
12909
13334
  * Fibonacci levels are used to identify potential support and resistance levels.
@@ -12913,43 +13338,55 @@ function calculateEMA(priceData, { period = 20, period2 = 9 } = {}) {
12913
13338
  * @param params.lookbackPeriod - The number of periods to look back for swing high/low (default is 20).
12914
13339
  * @param params.retracementLevels - An array of retracement levels to calculate (default is [0.236, 0.382, 0.5, 0.618, 0.786]).
12915
13340
  * @param params.extensionLevels - An array of extension levels to calculate (default is [1.272, 1.618, 2.618]).
12916
- * @param params.reverseDirection - A boolean indicating if the trend is reversed (default is false).
13341
+ * @param params.reverseDirection - Forces the leg direction: `true` for a downtrend, `false` for an uptrend. Omit it to derive the direction per bar from the swing window.
12917
13342
  * @returns An array of FibonacciData objects containing the calculated levels.
12918
13343
  */
12919
- function calculateFibonacciLevels(priceData, { lookbackPeriod = 20, retracementLevels = [0.236, 0.382, 0.5, 0.618, 0.786], extensionLevels = [1.272, 1.618, 2.618], reverseDirection = false, } = {}) {
13344
+ function calculateFibonacciLevels(priceData, { lookbackPeriod = 20, retracementLevels = [0.236, 0.382, 0.5, 0.618, 0.786], extensionLevels = [1.272, 1.618, 2.618], reverseDirection, } = {}) {
12920
13345
  const result = [];
12921
13346
  for (let i = 0; i < priceData.length; i++) {
12922
13347
  const periodSlice = priceData.slice(Math.max(0, i - lookbackPeriod + 1), i + 1);
12923
- const swingHigh = Math.max(...periodSlice.map((d) => d.high));
12924
- const swingLow = Math.min(...periodSlice.map((d) => d.low));
13348
+ const { swingHigh, swingLow, trend: derivedTrend } = analyzeSwingWindow(periodSlice);
12925
13349
  const priceRange = swingHigh - swingLow;
12926
- const trend = reverseDirection ? "downtrend" : "uptrend";
13350
+ // An explicit `reverseDirection` is the caller stating the leg it is
13351
+ // measuring; absent that, the leg is read off the window itself.
13352
+ const trend = reverseDirection === undefined
13353
+ ? derivedTrend
13354
+ : reverseDirection
13355
+ ? "downtrend"
13356
+ : "uptrend";
12927
13357
  const levels = [];
12928
- if (priceRange > 0) {
13358
+ if (priceRange > 0 && trend !== null) {
13359
+ const isDowntrend = trend === "downtrend";
12929
13360
  // Calculate retracement levels
12930
13361
  retracementLevels.forEach((level) => {
12931
- const price = reverseDirection
13362
+ const price = isDowntrend
12932
13363
  ? swingLow + priceRange * level
12933
13364
  : swingHigh - priceRange * level;
12934
13365
  levels.push({
12935
13366
  level,
12936
- price: parseFloat(price.toFixed(2)),
13367
+ price: roundToPriceScale(price),
12937
13368
  type: "retracement",
12938
13369
  });
12939
13370
  });
12940
- // Calculate extension levels
13371
+ // Calculate extension levels — each is projected beyond the leg's
13372
+ // terminal extreme: past the swing low for a down-leg, past the swing
13373
+ // high for an up-leg. Anchoring both to the same extreme would place one
13374
+ // side's targets a full swing range away from where the leg is running.
12941
13375
  extensionLevels.forEach((level) => {
12942
- const price = reverseDirection
12943
- ? swingHigh - priceRange * (level - 1) // For downtrend
13376
+ const price = isDowntrend
13377
+ ? swingLow - priceRange * (level - 1) // For downtrend
12944
13378
  : swingHigh + priceRange * (level - 1); // For uptrend
12945
13379
  levels.push({
12946
13380
  level,
12947
- price: parseFloat(price.toFixed(2)),
13381
+ price: roundToPriceScale(price),
12948
13382
  type: "extension",
12949
13383
  });
12950
13384
  });
12951
13385
  // Sort levels by price
12952
- levels.sort((a, b) => reverseDirection ? b.price - a.price : a.price - b.price);
13386
+ levels.sort((a, b) => isDowntrend ? b.price - a.price : a.price - b.price);
13387
+ }
13388
+ else if (trend === null) {
13389
+ logIfDebug(`Swing high and low fall on the same bar on date ${priceData[i].date}; trend is indeterminate and no levels calculated.`);
12953
13390
  }
12954
13391
  else {
12955
13392
  logIfDebug(`Price range is zero on date ${priceData[i].date}; no levels calculated.`);
@@ -13004,9 +13441,9 @@ function calculateMACD(priceData, { shortPeriod = 12, longPeriod = 26, signalPer
13004
13441
  const hist = macdValue - signalEMA;
13005
13442
  result.push({
13006
13443
  date: emaLong[i].date, // Use emaLong's date for alignment
13007
- macd: parseFloat(macdValue.toFixed(2)),
13008
- signal: parseFloat(signalEMA.toFixed(2)),
13009
- histogram: parseFloat(hist.toFixed(2)),
13444
+ macd: roundToPriceScale(macdValue),
13445
+ signal: roundToPriceScale(signalEMA),
13446
+ histogram: roundToPriceScale(hist),
13010
13447
  close: emaLong[i].close,
13011
13448
  });
13012
13449
  }
@@ -13041,9 +13478,9 @@ function calculateRSI(priceData, { period = 14 } = {}) {
13041
13478
  }
13042
13479
  avgGain = avgGain / period;
13043
13480
  avgLoss = avgLoss / period;
13044
- // Calculate RSI for the first period
13045
- let rs = avgGain / avgLoss;
13046
- let rsi = 100 - 100 / (1 + rs);
13481
+ // Calculate RSI for the first period (total on a flat window — see
13482
+ // rsiFromAverages: a constant series scores the neutral 50, never NaN).
13483
+ let rsi = rsiFromAverages(avgGain, avgLoss);
13047
13484
  result.push({
13048
13485
  date: priceData[period].date,
13049
13486
  rsi: parseFloat(rsi.toFixed(2)),
@@ -13057,8 +13494,7 @@ function calculateRSI(priceData, { period = 14 } = {}) {
13057
13494
  // Use smoothed averages
13058
13495
  avgGain = (avgGain * (period - 1) + gain) / period;
13059
13496
  avgLoss = (avgLoss * (period - 1) + loss) / period;
13060
- rs = avgGain / avgLoss;
13061
- rsi = 100 - 100 / (1 + rs);
13497
+ rsi = rsiFromAverages(avgGain, avgLoss);
13062
13498
  result.push({
13063
13499
  date: priceData[i].date,
13064
13500
  rsi: parseFloat(rsi.toFixed(2)),
@@ -13079,6 +13515,20 @@ function calculateRSI(priceData, { period = 14 } = {}) {
13079
13515
  * @returns An array of StochData objects containing the calculated %K and %D values.
13080
13516
  */
13081
13517
  function calculateStochasticOscillator(priceData, { lookbackPeriod = 5, signalPeriod = 3, smoothingFactor = 3, } = {}) {
13518
+ // Each period is a divisor (`kSum / min(len, smoothingFactor)`) and a slice
13519
+ // width. A zero or fractional period therefore divides by zero or slices an
13520
+ // empty window, producing NaN/Infinity %K and %D — an oscillator reading that
13521
+ // is never true and never false. The periods are caller-supplied constants
13522
+ // rather than market data, so an invalid one is a programming error and is
13523
+ // reported as such, matching the ATR and volatility primitives.
13524
+ if (!Number.isInteger(lookbackPeriod) ||
13525
+ lookbackPeriod < 1 ||
13526
+ !Number.isInteger(signalPeriod) ||
13527
+ signalPeriod < 1 ||
13528
+ !Number.isInteger(smoothingFactor) ||
13529
+ smoothingFactor < 1) {
13530
+ throw new Error("calculateStochasticOscillator: lookbackPeriod, signalPeriod and smoothingFactor must be positive integers");
13531
+ }
13082
13532
  if (priceData.length < lookbackPeriod) {
13083
13533
  logIfDebug(`Insufficient data for Stochastic Oscillator calculation: required periods: ${lookbackPeriod}, but only received ${priceData.length} periods of data`);
13084
13534
  return [];
@@ -13119,6 +13569,48 @@ function calculateStochasticOscillator(priceData, { lookbackPeriod = 5, signalPe
13119
13569
  }
13120
13570
  return result;
13121
13571
  }
13572
+ /**
13573
+ * Collapses a cluster of nearby pivots into one volume-weighted level, or
13574
+ * reports that the cluster evidences no level at all.
13575
+ *
13576
+ * Both outputs are volume-weighted: the price is the volume-weighted mean of
13577
+ * the cluster's pivots, and the strength is the pivot count weighted by each
13578
+ * pivot's share of cluster volume. That weighting is undefined when the cluster
13579
+ * transacted no volume — `0 / 0` makes both NaN. A NaN level is strictly worse
13580
+ * than no level: every comparison against NaN is false, so a stop or target
13581
+ * placed off one is silently never triggered, leaving the position unprotected
13582
+ * while appearing protected.
13583
+ *
13584
+ * Zero cluster volume is a real market state rather than corrupt input — halted,
13585
+ * pre-market-thin and synthetic warm-up bars all report it. A support or
13586
+ * resistance level means price transacted enough there to turn the market, so a
13587
+ * cluster with no volume has not evidenced one. `SupportResistanceLevel` types
13588
+ * both fields as non-optional numbers, which leaves omitting the level as the
13589
+ * only honest way to say so.
13590
+ *
13591
+ * @param cluster - The nearby pivots to collapse into a single level.
13592
+ * @param currentPrice - The bar's close, which classifies the level's side.
13593
+ * @returns The aggregated level, or null when the cluster evidences none.
13594
+ */
13595
+ function aggregatePivotCluster(cluster, currentPrice) {
13596
+ const totalVolume = cluster.reduce((sum, p) => sum + p.volume, 0);
13597
+ // Negated `> 0` so NaN and negative totals are rejected alongside zero: no
13598
+ // volume weighting survives any of them.
13599
+ if (!(totalVolume > 0))
13600
+ return null;
13601
+ const avgPrice = cluster.reduce((sum, p) => sum + p.price * p.volume, 0) / totalVolume;
13602
+ const strength = cluster.reduce((sum, p) => sum + p.count * (p.volume / totalVolume), 0);
13603
+ if (!Number.isFinite(avgPrice) || !Number.isFinite(strength))
13604
+ return null;
13605
+ return {
13606
+ // The level is a price, so its precision follows the price's magnitude
13607
+ // (F7.2). Strength is a count-weighted score rather than a price and keeps
13608
+ // the conventional 2dp.
13609
+ price: roundToPriceScale(avgPrice),
13610
+ strength: parseFloat(strength.toFixed(2)),
13611
+ type: avgPrice > currentPrice ? "resistance" : "support",
13612
+ };
13613
+ }
13122
13614
  /**
13123
13615
  * Calculates support and resistance levels based on price data.
13124
13616
  * Support and resistance levels are price levels at which a stock tends to stop and reverse.
@@ -13139,9 +13631,22 @@ function calculateSupportAndResistance(priceData, { maxLevels = 5, lookbackPerio
13139
13631
  const priceChanges = analysisWindow
13140
13632
  .slice(1)
13141
13633
  .map((bar, idx) => Math.abs(bar.close - analysisWindow[idx].close));
13142
- const avgPriceChange = priceChanges.reduce((sum, change) => sum + change, 0) /
13143
- priceChanges.length;
13144
- const volatility = avgPriceChange / analysisWindow[0].close; // Relative volatility
13634
+ // A single-bar window produces no price changes to average, and a
13635
+ // non-positive reference close cannot scale one — `0 / 0` and `x / 0` make
13636
+ // the relative volatility NaN or Infinity. Volatility is the sole input to
13637
+ // both the pivot sensitivity and the level-grouping gap below, so a
13638
+ // non-finite value silently disables every comparison that depends on it
13639
+ // (each is false against NaN). Unmeasurable volatility resolves to zero,
13640
+ // under which each pivot stands as its own level instead of being merged on
13641
+ // a meaningless ratio.
13642
+ const referenceClose = analysisWindow[0].close;
13643
+ const avgPriceChange = priceChanges.length > 0
13644
+ ? priceChanges.reduce((sum, change) => sum + change, 0) /
13645
+ priceChanges.length
13646
+ : 0;
13647
+ const volatility = referenceClose > 0 && Number.isFinite(avgPriceChange)
13648
+ ? avgPriceChange / referenceClose
13649
+ : 0; // Relative volatility
13145
13650
  // **Adjust Sensitivity and minGapBetweenLevels Dynamically**
13146
13651
  const sensitivity = volatility * 2; // Adjust the multiplier as needed
13147
13652
  const minGapBetweenLevels = volatility * 100; // Convert to percentage
@@ -13150,8 +13655,16 @@ function calculateSupportAndResistance(priceData, { maxLevels = 5, lookbackPerio
13150
13655
  const curr = analysisWindow[j];
13151
13656
  const prevBar = analysisWindow[j - 1];
13152
13657
  const nextBar = analysisWindow[j + 1];
13658
+ // A pivot is matched against existing candidates by a *relative* gap
13659
+ // measured against its own price, so a non-positive reference price makes
13660
+ // that ratio meaningless: zero divides to NaN or Infinity (which never
13661
+ // compares below the sensitivity, so the pivot never merges), and a
13662
+ // negative price inverts the comparison (so everything merges). A bar
13663
+ // without a positive high or low carries no tradeable level either way.
13153
13664
  // Check for high pivot
13154
- if (curr.high > prevBar.high && curr.high > nextBar.high) {
13665
+ if (curr.high > 0 &&
13666
+ curr.high > prevBar.high &&
13667
+ curr.high > nextBar.high) {
13155
13668
  const existingPivot = pivotPoints.find((p) => Math.abs(p.price - curr.high) / curr.high < sensitivity);
13156
13669
  if (existingPivot) {
13157
13670
  existingPivot.count++;
@@ -13162,7 +13675,7 @@ function calculateSupportAndResistance(priceData, { maxLevels = 5, lookbackPerio
13162
13675
  }
13163
13676
  }
13164
13677
  // Check for low pivot
13165
- if (curr.low < prevBar.low && curr.low < nextBar.low) {
13678
+ if (curr.low > 0 && curr.low < prevBar.low && curr.low < nextBar.low) {
13166
13679
  const existingPivot = pivotPoints.find((p) => Math.abs(p.price - curr.low) / curr.low < sensitivity);
13167
13680
  if (existingPivot) {
13168
13681
  existingPivot.count++;
@@ -13192,33 +13705,17 @@ function calculateSupportAndResistance(priceData, { maxLevels = 5, lookbackPerio
13192
13705
  }
13193
13706
  else {
13194
13707
  // Process current group
13195
- if (currentGroup.length > 0) {
13196
- const totalVolume = currentGroup.reduce((sum, p) => sum + p.volume, 0);
13197
- const avgPrice = currentGroup.reduce((sum, p) => sum + p.price * p.volume, 0) /
13198
- totalVolume;
13199
- const totalStrength = currentGroup.reduce((sum, p) => sum + p.count * (p.volume / totalVolume), 0);
13200
- levels.push({
13201
- price: parseFloat(avgPrice.toFixed(2)),
13202
- strength: parseFloat(totalStrength.toFixed(2)),
13203
- type: avgPrice > currentPrice ? "resistance" : "support",
13204
- });
13205
- }
13708
+ const level = aggregatePivotCluster(currentGroup, currentPrice);
13709
+ if (level)
13710
+ levels.push(level);
13206
13711
  currentGroup = [pivotPoints[j]];
13207
13712
  }
13208
13713
  }
13209
13714
  }
13210
13715
  // Process final group
13211
- if (currentGroup.length > 0) {
13212
- const totalVolume = currentGroup.reduce((sum, p) => sum + p.volume, 0);
13213
- const avgPrice = currentGroup.reduce((sum, p) => sum + p.price * p.volume, 0) /
13214
- totalVolume;
13215
- const totalStrength = currentGroup.reduce((sum, p) => sum + p.count * (p.volume / totalVolume), 0);
13216
- levels.push({
13217
- price: parseFloat(avgPrice.toFixed(2)),
13218
- strength: parseFloat(totalStrength.toFixed(2)),
13219
- type: avgPrice > currentPrice ? "resistance" : "support",
13220
- });
13221
- }
13716
+ const finalGroupLevel = aggregatePivotCluster(currentGroup, currentPrice);
13717
+ if (finalGroupLevel)
13718
+ levels.push(finalGroupLevel);
13222
13719
  // Sort by strength and limit
13223
13720
  const finalLevels = levels
13224
13721
  .sort((a, b) => b.strength - a.strength)
@@ -51971,7 +52468,8 @@ async function createBracketOrder(executor, params) {
51971
52468
  *
51972
52469
  * @example
51973
52470
  * ```typescript
51974
- * // Add protection to an existing long position
52471
+ * // Add protection to an existing long position (sell to close):
52472
+ * // take profit above, stop below.
51975
52473
  * const result = await createProtectiveBracket(
51976
52474
  * executor,
51977
52475
  * {
@@ -51984,6 +52482,23 @@ async function createBracketOrder(executor, params) {
51984
52482
  * }
51985
52483
  * );
51986
52484
  * ```
52485
+ *
52486
+ * @example
52487
+ * ```typescript
52488
+ * // Add protection to an existing short position (buy to close):
52489
+ * // take profit below, stop above.
52490
+ * const result = await createProtectiveBracket(
52491
+ * executor,
52492
+ * {
52493
+ * symbol: 'TSLA',
52494
+ * qty: 50,
52495
+ * side: 'buy',
52496
+ * takeProfit: { limitPrice: 200.00 },
52497
+ * stopLoss: { stopPrice: 260.00 },
52498
+ * timeInForce: 'gtc',
52499
+ * }
52500
+ * );
52501
+ * ```
51987
52502
  */
51988
52503
  async function createProtectiveBracket(executor, params) {
51989
52504
  log$j(`Creating protective bracket for ${params.symbol}: ${params.qty} shares`, { type: "info" });
@@ -51996,15 +52511,28 @@ async function createProtectiveBracket(executor, params) {
51996
52511
  if (!params.qty || params.qty <= 0) {
51997
52512
  throw new Error("Quantity must be a positive number");
51998
52513
  }
52514
+ // The closing side determines which of the two exit prices is the profit
52515
+ // target, so it must be stated rather than inferred.
52516
+ if (params.side !== "buy" && params.side !== "sell") {
52517
+ throw new Error("Protective bracket requires a side of 'buy' or 'sell' matching the position being closed");
52518
+ }
51999
52519
  if (!params.takeProfit?.limitPrice || params.takeProfit.limitPrice <= 0) {
52000
52520
  throw new Error("Take profit limit price is required and must be positive");
52001
52521
  }
52002
52522
  if (!params.stopLoss?.stopPrice || params.stopLoss.stopPrice <= 0) {
52003
52523
  throw new Error("Stop loss stop price is required and must be positive");
52004
52524
  }
52005
- // For a protective sell bracket, take profit should be higher than stop loss
52006
- if (params.takeProfit.limitPrice <= params.stopLoss.stopPrice) {
52007
- log$j("Warning: Take profit price should be higher than stop loss price for protective sell bracket", { type: "warn" });
52525
+ // The take profit must sit on the profitable side of the position and the
52526
+ // stop on the losing side. Which price is the higher one therefore depends
52527
+ // on the closing side: selling to close a long takes profit above and stops
52528
+ // below; buying to close a short is the exact mirror.
52529
+ if (params.side === "sell") {
52530
+ if (params.takeProfit.limitPrice <= params.stopLoss.stopPrice) {
52531
+ log$j("Warning: Take profit price should be higher than stop loss price for protective sell bracket", { type: "warn" });
52532
+ }
52533
+ }
52534
+ else if (params.takeProfit.limitPrice >= params.stopLoss.stopPrice) {
52535
+ log$j("Warning: Take profit price should be lower than stop loss price for protective buy bracket", { type: "warn" });
52008
52536
  }
52009
52537
  try {
52010
52538
  // Build the OCO order parameters
@@ -53634,7 +54162,7 @@ async function createTrailingStop(client, params) {
53634
54162
  log$g(`Trailing stop creation failed for ${params.symbol}: ${err.message}`, {
53635
54163
  type: "error",
53636
54164
  });
53637
- throw new Error(`Failed to create trailing stop for ${params.symbol}: ${err.message}`);
54165
+ throw enrichAlpacaError(new Error(`Failed to create trailing stop for ${params.symbol}: ${err.message}`), error);
53638
54166
  }
53639
54167
  }
53640
54168
  /**
@@ -53700,7 +54228,11 @@ async function updateTrailingStop(client, orderId, updates) {
53700
54228
  log$g(`Trailing stop update failed for ${orderId}: ${err.message}`, {
53701
54229
  type: "error",
53702
54230
  });
53703
- throw new Error(`Failed to update trailing stop ${orderId}: ${err.message}`);
54231
+ // Preserve Alpaca's `response.data` (numeric code `42210000` etc.) that the
54232
+ // SDK reduces to a bare "status code NNN" message. This is THE trailing-stop
54233
+ // modify path; dropping the code here left the consumer unable to tell a
54234
+ // stale-order reject from a benign race, blind-failing the profit lock.
54235
+ throw enrichAlpacaError(new Error(`Failed to update trailing stop ${orderId}: ${err.message}`), error);
53704
54236
  }
53705
54237
  }
53706
54238
  /**
@@ -53741,7 +54273,7 @@ async function getTrailingStopHWM(client, orderId) {
53741
54273
  log$g(`Failed to get trailing stop HWM for ${orderId}: ${err.message}`, {
53742
54274
  type: "error",
53743
54275
  });
53744
- throw new Error(`Failed to get trailing stop HWM for ${orderId}: ${err.message}`);
54276
+ throw enrichAlpacaError(new Error(`Failed to get trailing stop HWM for ${orderId}: ${err.message}`), error);
53745
54277
  }
53746
54278
  }
53747
54279
  /**
@@ -53770,19 +54302,22 @@ async function cancelTrailingStop(client, orderId) {
53770
54302
  log$g(`Trailing stop ${orderId} is not cancelable (may already be filled or canceled)`, {
53771
54303
  type: "warn",
53772
54304
  });
53773
- throw new Error(`Trailing stop ${orderId} is not cancelable: order may already be filled or canceled`);
54305
+ throw enrichAlpacaError(new Error(`Trailing stop ${orderId} is not cancelable: order may already be filled or canceled`), error);
53774
54306
  }
53775
54307
  log$g(`Failed to cancel trailing stop ${orderId}: ${err.message}`, {
53776
54308
  type: "error",
53777
54309
  });
53778
- throw new Error(`Failed to cancel trailing stop ${orderId}: ${err.message}`);
54310
+ throw enrichAlpacaError(new Error(`Failed to cancel trailing stop ${orderId}: ${err.message}`), error);
53779
54311
  }
53780
54312
  }
53781
54313
  /**
53782
- * Create trailing stops for all positions in a portfolio
54314
+ * Create trailing stops for every position in a portfolio
53783
54315
  *
53784
- * This function creates trailing stop orders for all long positions in the portfolio,
53785
- * which is useful for applying blanket downside protection. Short positions are skipped.
54316
+ * Applies blanket adverse-move protection across the book. The protective side
54317
+ * is derived per position from the signed quantity reported by the broker — a
54318
+ * long is protected by a trailing sell, a short by a trailing buy — so a
54319
+ * position is never left unprotected because of the direction it happens to
54320
+ * hold.
53786
54321
  *
53787
54322
  * @param client - AlpacaClient instance
53788
54323
  * @param params - Configuration for portfolio-wide trailing stops
@@ -53807,8 +54342,11 @@ async function createPortfolioTrailingStops(client, params) {
53807
54342
  if (params.trailPercent <= 0) {
53808
54343
  throw new Error("trailPercent must be greater than 0");
53809
54344
  }
53810
- if (params.trailPercent > 100) {
53811
- throw new Error("trailPercent cannot exceed 100");
54345
+ // Reject against the broker's real ceiling up front. A looser outer bound
54346
+ // lets an out-of-range value reach the per-position loop, where every single
54347
+ // submission is rejected and the book silently ends up unprotected.
54348
+ if (params.trailPercent > ALPACA_MAX_TRAIL_PERCENT) {
54349
+ throw new Error(`trailPercent cannot exceed ${ALPACA_MAX_TRAIL_PERCENT} (Alpaca API limit)`);
53812
54350
  }
53813
54351
  const sdk = client.getSDK();
53814
54352
  const results = new Map();
@@ -53831,19 +54369,21 @@ async function createPortfolioTrailingStops(client, params) {
53831
54369
  log$g(`Skipping ${symbol} (excluded)`, { type: "debug" });
53832
54370
  continue;
53833
54371
  }
53834
- // Only create trailing stops for long positions
54372
+ // Derive the protective side from the broker's signed quantity: a long
54373
+ // (qty > 0) is closed by selling, a short (qty < 0) by buying. Direction
54374
+ // is read from the position, never assumed — a stop on the wrong side
54375
+ // doubles the exposure it was meant to cap.
53835
54376
  const qty = parseFloat(position.qty);
53836
- if (qty <= 0) {
53837
- log$g(`Skipping ${symbol} (not a long position, qty: ${qty})`, {
53838
- type: "debug",
53839
- });
54377
+ if (!Number.isFinite(qty) || qty === 0) {
54378
+ log$g(`Skipping ${symbol}: position qty "${position.qty}" is not a usable non-zero number`, { type: "warn" });
53840
54379
  continue;
53841
54380
  }
54381
+ const side = qty > 0 ? "sell" : "buy";
53842
54382
  try {
53843
54383
  const order = await createTrailingStop(client, {
53844
54384
  symbol,
53845
54385
  qty: Math.abs(qty),
53846
- side: "sell",
54386
+ side,
53847
54387
  trailPercent: params.trailPercent,
53848
54388
  timeInForce: params.timeInForce || "gtc",
53849
54389
  });
@@ -53851,9 +54391,14 @@ async function createPortfolioTrailingStops(client, params) {
53851
54391
  }
53852
54392
  catch (err) {
53853
54393
  const errorMessage = err.message;
53854
- errors.push({ symbol, error: errorMessage });
54394
+ // Preserve the broker's numeric code (e.g. 42210000) rather than
54395
+ // reducing the swallowed per-item failure to its flattened message —
54396
+ // this loop only logs failures, so the log is the preservation target.
54397
+ const brokerCode = getAlpacaBrokerErrorCode(err);
54398
+ errors.push({ symbol, error: errorMessage, brokerCode });
53855
54399
  log$g(`Failed to create trailing stop for ${symbol}: ${errorMessage}`, {
53856
54400
  type: "error",
54401
+ metadata: { brokerCode },
53857
54402
  });
53858
54403
  }
53859
54404
  }
@@ -53863,7 +54408,9 @@ async function createPortfolioTrailingStops(client, params) {
53863
54408
  const skippedCount = positions.length - successCount - failureCount;
53864
54409
  log$g(`Portfolio trailing stops complete: ${successCount} created, ${failureCount} failed, ${skippedCount} skipped`, { type: "info" });
53865
54410
  if (errors.length > 0) {
53866
- log$g(`Failed symbols: ${errors.map((e) => `${e.symbol} (${e.error})`).join(", ")}`, {
54411
+ log$g(`Failed symbols: ${errors
54412
+ .map((e) => `${e.symbol} (${e.error}${e.brokerCode !== null ? `, code ${e.brokerCode}` : ""})`)
54413
+ .join(", ")}`, {
53867
54414
  type: "warn",
53868
54415
  });
53869
54416
  }
@@ -53874,7 +54421,7 @@ async function createPortfolioTrailingStops(client, params) {
53874
54421
  log$g(`Failed to create portfolio trailing stops: ${err.message}`, {
53875
54422
  type: "error",
53876
54423
  });
53877
- throw new Error(`Failed to create portfolio trailing stops: ${err.message}`);
54424
+ throw enrichAlpacaError(new Error(`Failed to create portfolio trailing stops: ${err.message}`), error);
53878
54425
  }
53879
54426
  }
53880
54427
  /**
@@ -53913,7 +54460,7 @@ async function getOpenTrailingStops(client, symbol) {
53913
54460
  catch (error) {
53914
54461
  const err = error;
53915
54462
  log$g(`Failed to get open trailing stops: ${err.message}`, { type: "error" });
53916
- throw new Error(`Failed to get open trailing stops: ${err.message}`);
54463
+ throw enrichAlpacaError(new Error(`Failed to get open trailing stops: ${err.message}`), error);
53917
54464
  }
53918
54465
  }
53919
54466
  /**
@@ -53961,7 +54508,10 @@ async function cancelTrailingStopsForSymbol(client, symbol) {
53961
54508
  canceledCount++;
53962
54509
  }
53963
54510
  catch (err) {
53964
- errors.push(`${order.id}: ${err.message}`);
54511
+ // Keep the broker's numeric code alongside the message so the swallowed
54512
+ // per-item cancel failure stays diagnosable in the summary log.
54513
+ const brokerCode = getAlpacaBrokerErrorCode(err);
54514
+ errors.push(`${order.id}: ${err.message}${brokerCode !== null ? ` (code ${brokerCode})` : ""}`);
53965
54515
  }
53966
54516
  }
53967
54517
  if (errors.length > 0) {
@@ -54781,13 +55331,25 @@ async function shortWithStopLoss(client, symbol, qty, entryPrice, stopLossPrice)
54781
55331
  * @param qty - Number of shares
54782
55332
  * @param entryPrice - Limit price for entry (null for market)
54783
55333
  * @param stopLossPercent - Stop loss percentage (e.g., 5 for 5%)
54784
- * @param side - Order side ('buy' or 'sell')
55334
+ * @param side - Order side ('buy' or 'sell'). Required: the entry direction is
55335
+ * the caller's decision, and a default would open a position in a direction
55336
+ * nobody chose.
54785
55337
  *
54786
55338
  * @example
54787
55339
  * // Buy AAPL at $150 with 3% stop loss (stop at $145.50)
54788
55340
  * const result = await entryWithPercentStopLoss(client, 'AAPL', 100, 150.00, 3, 'buy');
55341
+ *
55342
+ * @example
55343
+ * // Short GOOGL at $140 with 3% stop loss (stop at $144.20)
55344
+ * const result = await entryWithPercentStopLoss(client, 'GOOGL', 10, 140.00, 3, 'sell');
54789
55345
  */
54790
- async function entryWithPercentStopLoss(client, symbol, qty, entryPrice, stopLossPercent, side = "buy") {
55346
+ async function entryWithPercentStopLoss(client, symbol, qty, entryPrice, stopLossPercent, side) {
55347
+ // Guard the direction at runtime as well as in the signature: every price
55348
+ // below is computed off `side`, so an unsupplied one would silently place
55349
+ // the stop on the wrong side of the entry.
55350
+ if (side !== "buy" && side !== "sell") {
55351
+ throw new Error("entryWithPercentStopLoss requires an explicit side of 'buy' or 'sell'; the entry direction cannot be inferred");
55352
+ }
54791
55353
  if (stopLossPercent <= 0 || stopLossPercent >= 100) {
54792
55354
  throw new Error("stopLossPercent must be between 0 and 100");
54793
55355
  }
@@ -58186,7 +58748,12 @@ async function resolveDuplicateSubmission(client, clientOrderId, symbol, cause)
58186
58748
  catch (lookupError) {
58187
58749
  const reason = lookupError instanceof Error ? lookupError.message : String(lookupError);
58188
58750
  log$6(`Duplicate-order lookup failed for ${clientOrderId}; failing closed (no resubmit): ${reason}`, { type: "error", symbol, metadata: { clientOrderId } });
58189
- throw new DuplicateClientOrderIdError(`Duplicate client_order_id "${clientOrderId}" rejected by Alpaca and the existing-order lookup failed; refusing to resubmit (possible live duplicate)`, clientOrderId, false, lookupError);
58751
+ // The typed error represents the ORIGINAL duplicate rejection, so its broker
58752
+ // payload must come from `cause` (the 422), not from the lookup failure.
58753
+ // Chain the lookup error ahead of the original 422 (and carry the 422's
58754
+ // normalized detail onto it) so both are diagnosable and
58755
+ // getAlpacaBrokerErrorCode still resolves the duplicate code.
58756
+ throw new DuplicateClientOrderIdError(`Duplicate client_order_id "${clientOrderId}" rejected by Alpaca and the existing-order lookup failed; refusing to resubmit (possible live duplicate)`, clientOrderId, false, enrichAlpacaError(lookupError instanceof Error ? lookupError : new Error(reason), cause));
58190
58757
  }
58191
58758
  if (existing && !TERMINAL_DEAD_ORDER_STATUSES.has(existing.status)) {
58192
58759
  log$6(`client_order_id ${clientOrderId} already submitted (status=${existing.status}); returning existing order ${existing.id} as idempotent success`, {
@@ -58320,7 +58887,7 @@ async function createOrder(client, params) {
58320
58887
  symbol,
58321
58888
  metadata: { params: submission },
58322
58889
  });
58323
- throw new Error(`Failed to create ${type} order for ${symbol}: ${errorMessage}`);
58890
+ throw enrichAlpacaError(new Error(`Failed to create ${type} order for ${symbol}: ${errorMessage}`), error);
58324
58891
  }
58325
58892
  }
58326
58893
  /**
@@ -58349,7 +58916,7 @@ async function getOrder(client, orderId) {
58349
58916
  catch (error) {
58350
58917
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
58351
58918
  log$6(`Failed to fetch order ${orderId}: ${errorMessage}`, { type: "error" });
58352
- throw new Error(`Failed to fetch order ${orderId}: ${errorMessage}`);
58919
+ throw enrichAlpacaError(new Error(`Failed to fetch order ${orderId}: ${errorMessage}`), error);
58353
58920
  }
58354
58921
  }
58355
58922
  /**
@@ -58412,7 +58979,7 @@ async function getOrders(client, params = {}) {
58412
58979
  catch (error) {
58413
58980
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
58414
58981
  log$6(`Failed to fetch orders: ${errorMessage}`, { type: "error" });
58415
- throw new Error(`Failed to fetch orders: ${errorMessage}`);
58982
+ throw enrichAlpacaError(new Error(`Failed to fetch orders: ${errorMessage}`), error);
58416
58983
  }
58417
58984
  }
58418
58985
  /**
@@ -58442,16 +59009,16 @@ async function cancelOrder(client, orderId) {
58442
59009
  log$6(`Order ${orderId} is not cancelable (may already be filled or canceled)`, {
58443
59010
  type: "warn",
58444
59011
  });
58445
- throw new Error(`Order ${orderId} is not cancelable`);
59012
+ throw enrichAlpacaError(new Error(`Order ${orderId} is not cancelable`), error);
58446
59013
  }
58447
59014
  if (errorMessage.includes("404") || errorMessage.includes("not found")) {
58448
59015
  log$6(`Order ${orderId} not found`, { type: "error" });
58449
- throw new Error(`Order ${orderId} not found`);
59016
+ throw enrichAlpacaError(new Error(`Order ${orderId} not found`), error);
58450
59017
  }
58451
59018
  log$6(`Failed to cancel order ${orderId}: ${errorMessage}`, {
58452
59019
  type: "error",
58453
59020
  });
58454
- throw new Error(`Failed to cancel order ${orderId}: ${errorMessage}`);
59021
+ throw enrichAlpacaError(new Error(`Failed to cancel order ${orderId}: ${errorMessage}`), error);
58455
59022
  }
58456
59023
  }
58457
59024
  /**
@@ -58494,7 +59061,7 @@ async function cancelAllOrders(client) {
58494
59061
  catch (error) {
58495
59062
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
58496
59063
  log$6(`Failed to cancel all orders: ${errorMessage}`, { type: "error" });
58497
- throw new Error(`Failed to cancel all orders: ${errorMessage}`);
59064
+ throw enrichAlpacaError(new Error(`Failed to cancel all orders: ${errorMessage}`), error);
58498
59065
  }
58499
59066
  }
58500
59067
  /**
@@ -58552,16 +59119,16 @@ async function replaceOrder(client, orderId, params) {
58552
59119
  log$6(`Order ${orderId} cannot be replaced (may already be filled)`, {
58553
59120
  type: "error",
58554
59121
  });
58555
- throw new Error(`Order ${orderId} cannot be replaced: order may already be filled or canceled`);
59122
+ throw enrichAlpacaError(new Error(`Order ${orderId} cannot be replaced: order may already be filled or canceled`), error);
58556
59123
  }
58557
59124
  if (errorMessage.includes("404")) {
58558
59125
  log$6(`Order ${orderId} not found`, { type: "error" });
58559
- throw new Error(`Order ${orderId} not found`);
59126
+ throw enrichAlpacaError(new Error(`Order ${orderId} not found`), error);
58560
59127
  }
58561
59128
  log$6(`Failed to replace order ${orderId}: ${errorMessage}`, {
58562
59129
  type: "error",
58563
59130
  });
58564
- throw new Error(`Failed to replace order ${orderId}: ${errorMessage}`);
59131
+ throw enrichAlpacaError(new Error(`Failed to replace order ${orderId}: ${errorMessage}`), error);
58565
59132
  }
58566
59133
  }
58567
59134
  /**
@@ -58650,7 +59217,7 @@ async function getOrderByClientId(client, clientOrderId) {
58650
59217
  log$6(`Failed to fetch order by client_order_id ${clientOrderId}: ${errorMessage}`, {
58651
59218
  type: "error",
58652
59219
  });
58653
- throw new Error(`Failed to fetch order by client_order_id ${clientOrderId}: ${errorMessage}`);
59220
+ throw enrichAlpacaError(new Error(`Failed to fetch order by client_order_id ${clientOrderId}: ${errorMessage}`), error);
58654
59221
  }
58655
59222
  }
58656
59223
 
@@ -64851,6 +65418,15 @@ class AssetAllocationEngine {
64851
65418
  * Assess current market condition
64852
65419
  */
64853
65420
  assessMarketCondition(metrics) {
65421
+ // Crisis detection runs first: it is the strictly more severe reading and
65422
+ // its volatility threshold sits above the high-volatility one, so testing
65423
+ // volatility first would classify every crisis-level VIX as merely high
65424
+ // and never reach this branch at all.
65425
+ if (metrics.volatilityIndex > 40 ||
65426
+ metrics.sentimentScore < 20 ||
65427
+ metrics.creditSpread > 500) {
65428
+ return "CRISIS";
65429
+ }
64854
65430
  // High volatility check
64855
65431
  if (metrics.volatilityIndex > 30) {
64856
65432
  return "HIGH_VOLATILITY";
@@ -64859,12 +65435,6 @@ class AssetAllocationEngine {
64859
65435
  if (metrics.volatilityIndex < 12) {
64860
65436
  return "LOW_VOLATILITY";
64861
65437
  }
64862
- // Crisis detection
64863
- if (metrics.volatilityIndex > 40 ||
64864
- metrics.sentimentScore < 20 ||
64865
- metrics.creditSpread > 500) {
64866
- return "CRISIS";
64867
- }
64868
65438
  // Bull market
64869
65439
  if (metrics.trendDirection === "UP" &&
64870
65440
  metrics.marketStrength > 60 &&
@@ -71160,6 +71730,14 @@ const DEFAULT_TRADING_POLICY = EffectiveTradingPolicySchema.parse({
71160
71730
  optionsEnabled: true,
71161
71731
  futuresEnabled: true,
71162
71732
  forexEnabled: true,
71733
+ // Shorting and margin are capability opt-ins, not a directional stance.
71734
+ // Both require a margin agreement and locate/borrow availability the
71735
+ // package cannot verify, so an account that has not asserted the
71736
+ // capability defaults to the one it is known to have. This is a statement
71737
+ // about account permissions, never a preference for long over short — the
71738
+ // side a strategy takes is derived from live data once the capability is
71739
+ // enabled. Resolve these from the broker account's actual margin and
71740
+ // shorting entitlements wherever those are available.
71163
71741
  shortingEnabled: false,
71164
71742
  marginEnabled: false,
71165
71743
  fractionalSharesEnabled: true,
@@ -71599,8 +72177,10 @@ exports.createTimeoutSignal = createTimeoutSignal;
71599
72177
  exports.createTrailingStop = createTrailingStop;
71600
72178
  exports.createVerticalSpread = createVerticalSpread$1;
71601
72179
  exports.createVerticalSpreadAdvanced = createVerticalSpread;
72180
+ exports.enrichAlpacaError = enrichAlpacaError;
71602
72181
  exports.entryWithPercentStopLoss = entryWithPercentStopLoss;
71603
72182
  exports.exerciseOption = exerciseOption;
72183
+ exports.extractAlpacaBrokerError = extractAlpacaBrokerError;
71604
72184
  exports.extractGreeks = extractGreeks;
71605
72185
  exports.filterByExpiration = filterByExpiration;
71606
72186
  exports.filterByStrike = filterByStrike;
@@ -71618,6 +72198,8 @@ exports.getAccountDetails = getAccountDetails;
71618
72198
  exports.getAccountSummary = getAccountSummary;
71619
72199
  exports.getAgentPoolStatus = getAgentPoolStatus;
71620
72200
  exports.getAllOrders = getAllOrders;
72201
+ exports.getAlpacaBrokerErrorCode = getAlpacaBrokerErrorCode;
72202
+ exports.getAlpacaBrokerErrorDetail = getAlpacaBrokerErrorDetail;
71621
72203
  exports.getAlpacaCalendar = getAlpacaCalendar;
71622
72204
  exports.getAlpacaClock = getAlpacaClock;
71623
72205
  exports.getAverageDailyVolume = getAverageDailyVolume;