@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.mjs CHANGED
@@ -2210,11 +2210,20 @@ class AdapticUtilsError extends Error {
2210
2210
  */
2211
2211
  class AlpacaApiError extends AdapticUtilsError {
2212
2212
  statusCode;
2213
- constructor(message, code, statusCode, cause) {
2213
+ brokerError;
2214
+ constructor(message, code, statusCode, cause,
2215
+ /**
2216
+ * Normalized Alpaca broker-rejection detail (numeric code + message + raw
2217
+ * body), when the underlying rejection carried one. Additive and optional:
2218
+ * synthetic errors and non-broker failures omit it, and every existing
2219
+ * consumer of `message`/`code`/`statusCode`/`cause` is unaffected.
2220
+ */
2221
+ brokerError) {
2214
2222
  // Rate limit (429) and server errors (5xx) are retryable
2215
2223
  const isRetryable = statusCode === 429 || (statusCode !== undefined && statusCode >= 500);
2216
2224
  super(message, code, "alpaca", isRetryable, cause);
2217
2225
  this.statusCode = statusCode;
2226
+ this.brokerError = brokerError;
2218
2227
  }
2219
2228
  }
2220
2229
  /**
@@ -2410,11 +2419,261 @@ class DuplicateClientOrderIdError extends AlpacaApiError {
2410
2419
  clientOrderId,
2411
2420
  /** Whether the colliding id was derived by the wrapper (vs caller-supplied). */
2412
2421
  wasDerived, cause) {
2413
- super(message, "DUPLICATE_CLIENT_ORDER_ID", 422, cause);
2422
+ // Carry the normalized broker payload forward from the original rejection
2423
+ // (the `cause`) so a consumer can read the numeric code without re-parsing.
2424
+ super(message, "DUPLICATE_CLIENT_ORDER_ID", 422, cause, extractAlpacaBrokerError(cause));
2414
2425
  this.clientOrderId = clientOrderId;
2415
2426
  this.wasDerived = wasDerived;
2416
2427
  }
2417
2428
  }
2429
+ /** Max depth walked along the `error.cause` chain when locating a broker payload. */
2430
+ const MAX_BROKER_ERROR_CAUSE_DEPTH = 6;
2431
+ /**
2432
+ * Narrows an unknown value to an index-signature record so nested properties
2433
+ * can be probed without an unsafe cast.
2434
+ * @param value - The value to test.
2435
+ * @returns true when the value is a non-null object.
2436
+ */
2437
+ function isBrokerErrorRecord(value) {
2438
+ return typeof value === "object" && value !== null;
2439
+ }
2440
+ /**
2441
+ * Reads a finite number from an unknown value, accepting Alpaca's numeric
2442
+ * `code` whether it arrives as a JSON number or a numeric string.
2443
+ * @param value - The candidate value.
2444
+ * @returns The number when finite, otherwise null.
2445
+ */
2446
+ function asBrokerCode(value) {
2447
+ if (typeof value === "number" && Number.isFinite(value)) {
2448
+ return value;
2449
+ }
2450
+ if (typeof value === "string" && value.trim() !== "") {
2451
+ const parsed = Number(value);
2452
+ return Number.isFinite(parsed) ? parsed : null;
2453
+ }
2454
+ return null;
2455
+ }
2456
+ /**
2457
+ * Reads the axios/SDK-shaped broker payload from a SINGLE node's `response`
2458
+ * field: an object `response.data` (`{ code, message }`) or a `response.data`
2459
+ * left as an unparsed JSON string (the raw-`fetch` seams attach the body as a
2460
+ * string). A known HTTP `response.status` is itself a broker-boundary signal —
2461
+ * a `422` whose body carries no numeric code is still a `422` — so a
2462
+ * status-only detail (`brokerCode: null`) is surfaced rather than discarded.
2463
+ * Returns `undefined` only when the node carries no `response` and no status.
2464
+ *
2465
+ * @param node - The candidate error-like record.
2466
+ * @returns The normalized detail, or undefined when the node has no response.
2467
+ */
2468
+ function readResponseBrokerDetail(node) {
2469
+ const response = node.response;
2470
+ if (!isBrokerErrorRecord(response)) {
2471
+ return undefined;
2472
+ }
2473
+ const statusCode = asBrokerCode(response.status);
2474
+ // A known status with no structured code/message: preserve the status rather
2475
+ // than discarding it (a code null is never fabricated into a value).
2476
+ const statusOnly = statusCode === null
2477
+ ? undefined
2478
+ : { brokerCode: null, brokerMessage: null, statusCode, raw: response.data };
2479
+ // Keep the raw body in its own const so the string narrowing survives the
2480
+ // JSON.parse (a reassigned `let` would widen back to `unknown` in the catch).
2481
+ const rawData = response.data;
2482
+ let parsed = rawData;
2483
+ if (typeof rawData === "string") {
2484
+ try {
2485
+ parsed = JSON.parse(rawData);
2486
+ }
2487
+ catch {
2488
+ // A non-JSON string body carries a human reason but no structured code.
2489
+ return { brokerCode: null, brokerMessage: rawData, statusCode, raw: rawData };
2490
+ }
2491
+ }
2492
+ if (!isBrokerErrorRecord(parsed)) {
2493
+ return statusOnly;
2494
+ }
2495
+ const brokerCode = asBrokerCode(parsed.code);
2496
+ const brokerMessage = typeof parsed.message === "string" ? parsed.message : null;
2497
+ if (brokerCode === null && brokerMessage === null) {
2498
+ return statusOnly;
2499
+ }
2500
+ return { brokerCode, brokerMessage, statusCode, raw: rawData };
2501
+ }
2502
+ /**
2503
+ * Reads the normalized broker detail from a SINGLE error-like node, without
2504
+ * walking its `cause` chain. Recognizes two carriers on the node: an
2505
+ * {@link AlpacaBrokerErrorDetail} already attached as `brokerError`, and an
2506
+ * axios/SDK-shaped `response` body (object or unparsed JSON string). A carrier
2507
+ * bearing a numeric code wins over a code-less one, so an enrichment that
2508
+ * resolved no code never shadows a numeric code sitting in the same node's raw
2509
+ * response body. Returns `undefined` when the node carries no broker payload,
2510
+ * so absence is never converted into a fabricated code.
2511
+ *
2512
+ * @param node - The candidate error-like value.
2513
+ * @returns The normalized detail, or undefined.
2514
+ */
2515
+ function readBrokerDetailFromNode(node) {
2516
+ if (!isBrokerErrorRecord(node)) {
2517
+ return undefined;
2518
+ }
2519
+ // Carrier 1: a detail already normalized and attached by this module
2520
+ // (e.g. AlpacaApiError.brokerError or a value enriched via enrichAlpacaError).
2521
+ let attachedDetail;
2522
+ const attached = node.brokerError;
2523
+ if (isBrokerErrorRecord(attached) && "brokerCode" in attached) {
2524
+ attachedDetail = {
2525
+ brokerCode: asBrokerCode(attached.brokerCode),
2526
+ brokerMessage: typeof attached.brokerMessage === "string"
2527
+ ? attached.brokerMessage
2528
+ : null,
2529
+ statusCode: asBrokerCode(attached.statusCode),
2530
+ raw: attached.raw,
2531
+ };
2532
+ // A numeric code on the attached detail is authoritative for this node.
2533
+ if (attachedDetail.brokerCode !== null) {
2534
+ return attachedDetail;
2535
+ }
2536
+ }
2537
+ // Carrier 2: an axios/SDK-shaped `response` body on the same node. Prefer a
2538
+ // numeric code found here over a code-less attached detail.
2539
+ const responseDetail = readResponseBrokerDetail(node);
2540
+ if (responseDetail?.brokerCode != null) {
2541
+ return responseDetail;
2542
+ }
2543
+ return attachedDetail ?? responseDetail;
2544
+ }
2545
+ /**
2546
+ * Extracts the normalized {@link AlpacaBrokerErrorDetail} from a thrown Alpaca
2547
+ * SDK/axios error, reading the vendor payload at `error.response.data` and,
2548
+ * failing that, walking the `error.cause` chain (the raw SDK error is preserved
2549
+ * there once a wrapper has re-thrown). Returns `undefined` when no broker
2550
+ * payload is present anywhere on the chain.
2551
+ *
2552
+ * Pure and outcome-independent: derived solely from Alpaca's documented error
2553
+ * contract, with zero reference to realized P&L, fills, or account state.
2554
+ *
2555
+ * A node bearing a numeric broker code wins immediately; a code-less detail
2556
+ * (status-only or message-only) found higher on the chain is held as a fallback
2557
+ * while the walk continues, so a numeric code sitting deeper in the `cause`
2558
+ * chain is never shadowed by a shallower code-less node — and when no code
2559
+ * exists anywhere, the code-less detail is still returned rather than discarded.
2560
+ *
2561
+ * @param error - The thrown value.
2562
+ * @returns The normalized broker detail, or undefined when none is present.
2563
+ */
2564
+ function extractAlpacaBrokerError(error) {
2565
+ let current = error;
2566
+ let fallback;
2567
+ for (let depth = 0; depth < MAX_BROKER_ERROR_CAUSE_DEPTH && current != null; depth++) {
2568
+ const detail = readBrokerDetailFromNode(current);
2569
+ if (detail !== undefined) {
2570
+ if (detail.brokerCode !== null) {
2571
+ return detail;
2572
+ }
2573
+ if (fallback === undefined) {
2574
+ fallback = detail;
2575
+ }
2576
+ }
2577
+ if (!isBrokerErrorRecord(current)) {
2578
+ break;
2579
+ }
2580
+ current = current.cause;
2581
+ }
2582
+ return fallback;
2583
+ }
2584
+ /**
2585
+ * Returns the normalized {@link AlpacaBrokerErrorDetail} for a thrown error, or
2586
+ * `null` when the error carries no Alpaca broker payload. The typed
2587
+ * vendor-boundary replacement for reaching into `err.response.data` downstream.
2588
+ *
2589
+ * @param error - The thrown value.
2590
+ * @returns The normalized detail, or null.
2591
+ */
2592
+ function getAlpacaBrokerErrorDetail(error) {
2593
+ return extractAlpacaBrokerError(error) ?? null;
2594
+ }
2595
+ /**
2596
+ * Returns Alpaca's machine-readable numeric broker error code from a thrown
2597
+ * error (walking the `cause` chain), or `null` when absent. The typed
2598
+ * replacement for `err.message.includes("42210000")`:
2599
+ *
2600
+ * ```typescript
2601
+ * if (getAlpacaBrokerErrorCode(err) === 42210000) { ... } // stale-order reject
2602
+ * ```
2603
+ *
2604
+ * The code resolves uniformly across every vendor seam: the SDK/axios path
2605
+ * (where `response.data` rides along for free) and the raw-`fetch` paths — the
2606
+ * `AlpacaTradingAPI` class `makeRequest` and the legacy order helpers, which
2607
+ * throw via {@link alpacaHttpError} so the verbatim status + body are carried as
2608
+ * a typed `.response`. A consumer branching on the stale-order `42210000` gets
2609
+ * the same answer regardless of which seam produced the reject, including the
2610
+ * dominant percent-trailing-stop tighten path where a plain `Error` previously
2611
+ * dropped the broker payload.
2612
+ *
2613
+ * @param error - The thrown value.
2614
+ * @returns The numeric broker code, or null.
2615
+ */
2616
+ function getAlpacaBrokerErrorCode(error) {
2617
+ return extractAlpacaBrokerError(error)?.brokerCode ?? null;
2618
+ }
2619
+ /**
2620
+ * Additively enriches a thrown error with the normalized Alpaca broker detail
2621
+ * extracted from `source` (the original SDK/axios rejection), WITHOUT changing
2622
+ * the target's `message`, `name`, or prototype. It:
2623
+ *
2624
+ * - sets `target.cause = source` when the target has no cause yet, so the raw
2625
+ * rejection (and its `response.data`) is never lost down the wrapper chain;
2626
+ * - attaches the normalized {@link AlpacaBrokerErrorDetail} as
2627
+ * `target.brokerError` when `source` carried a broker payload.
2628
+ *
2629
+ * Purely additive by construction: a caller writes
2630
+ * `throw enrichAlpacaError(new Error(msg), error)` and every consumer that read
2631
+ * `error.message` or `error instanceof Error` before reads the identical value
2632
+ * after, while new consumers can call {@link getAlpacaBrokerErrorCode}. This is
2633
+ * the restoration for the dropped-`response.data` defect (Alpaca `42210000` /
2634
+ * `40310000` reaching consumers only as a lossy "status code NNN" string).
2635
+ *
2636
+ * @param target - The wrapper error about to be thrown.
2637
+ * @param source - The original rejection to normalize and preserve.
2638
+ * @returns The same `target`, typed to expose the optional `brokerError`.
2639
+ */
2640
+ function enrichAlpacaError(target, source) {
2641
+ const enriched = target;
2642
+ if (enriched.cause === undefined && source !== undefined) {
2643
+ enriched.cause = source;
2644
+ }
2645
+ const detail = extractAlpacaBrokerError(source);
2646
+ if (detail !== undefined) {
2647
+ enriched.brokerError = detail;
2648
+ }
2649
+ return enriched;
2650
+ }
2651
+ /**
2652
+ * Builds a thrown-ready `Error` for a raw-`fetch` Alpaca rejection, carrying the
2653
+ * verbatim HTTP status + body as a typed `.response` so that
2654
+ * {@link getAlpacaBrokerErrorCode} / {@link extractAlpacaBrokerError} resolve
2655
+ * the numeric broker code on the `fetch` seams (the `AlpacaTradingAPI` class
2656
+ * `makeRequest` and the legacy functional order helpers) exactly as they
2657
+ * already do on the SDK seam — where the SDK/axios error carries `response.data`
2658
+ * for free but a hand-thrown `new Error(...)` does not.
2659
+ *
2660
+ * Purely additive by construction: the `.message` is caller-supplied and
2661
+ * returned byte-identical (so message string-matching consumers are
2662
+ * unaffected), the returned value `instanceof Error` still holds, and only the
2663
+ * `.response` surface is added. The `data` is the raw string body exactly as
2664
+ * `response.text()` returned it — {@link extractAlpacaBrokerError} parses a
2665
+ * JSON-string body itself, so no vendor payload is lost or reshaped here.
2666
+ *
2667
+ * @param message - The error message, thrown verbatim (never rewritten).
2668
+ * @param status - The HTTP status the rejection arrived on.
2669
+ * @param body - The raw response body (`response.text()`), preserved verbatim.
2670
+ * @returns An `Error` whose `.response` exposes `{ status, data: body }`.
2671
+ */
2672
+ function alpacaHttpError(message, status, body) {
2673
+ return Object.assign(new Error(message), {
2674
+ response: { status, data: body },
2675
+ });
2676
+ }
2418
2677
 
2419
2678
  const DEFAULT_RETRY_CONFIG = {
2420
2679
  maxRetries: 3,
@@ -5294,7 +5553,13 @@ class AlpacaTradingAPI {
5294
5553
  this.log(`Alpaca API error (${response.status}): ${errorText}`, {
5295
5554
  type: "error",
5296
5555
  });
5297
- throw new Error(`Alpaca API error (${response.status}): ${errorText}`);
5556
+ // Additive broker-error preservation: the message is byte-identical
5557
+ // (existing "422"/"42210000" string-matching consumers are unaffected),
5558
+ // and the verbatim status + body ride along as a typed `.response` so
5559
+ // getAlpacaBrokerErrorCode resolves the numeric code on this fetch seam —
5560
+ // the dominant percent-trailing-stop tighten path and the 08-20 defect
5561
+ // site, where a plain Error dropped the broker's response.data.
5562
+ throw alpacaHttpError(`Alpaca API error (${response.status}): ${errorText}`, response.status, errorText);
5298
5563
  }
5299
5564
  // Handle responses with no content (e.g., 204 No Content)
5300
5565
  if (response.status === 204 ||
@@ -5679,7 +5944,10 @@ class AlpacaTradingAPI {
5679
5944
  this.log(`Order ${orderId} is not cancelable`, {
5680
5945
  type: "error",
5681
5946
  });
5682
- throw new Error(`Order ${orderId} is not cancelable`);
5947
+ // Re-message stays byte-identical; the broker payload from makeRequest's
5948
+ // `.response` is carried onto the new error so the numeric code survives
5949
+ // this wrapper instead of being dropped at the re-throw.
5950
+ throw enrichAlpacaError(new Error(`Order ${orderId} is not cancelable`), error);
5683
5951
  }
5684
5952
  // Re-throw other errors
5685
5953
  throw error;
@@ -6729,7 +6997,7 @@ async function makeRequest(auth, params) {
6729
6997
  source: "AlpacaAPI",
6730
6998
  type: "error",
6731
6999
  });
6732
- throw new Error(`Alpaca API error (${response.status}): ${errorText}`);
7000
+ throw alpacaHttpError(`Alpaca API error (${response.status}): ${errorText}`, response.status, errorText);
6733
7001
  }
6734
7002
  catch (err) {
6735
7003
  const error = err;
@@ -6762,7 +7030,7 @@ async function createOrder$1(auth, params) {
6762
7030
  });
6763
7031
  if (!response.ok) {
6764
7032
  const errorText = await response.text();
6765
- throw new Error(`Failed to create order: ${response.status} ${response.statusText} ${errorText}`);
7033
+ throw alpacaHttpError(`Failed to create order: ${response.status} ${response.statusText} ${errorText}`, response.status, errorText);
6766
7034
  }
6767
7035
  return (await response.json());
6768
7036
  }
@@ -6809,7 +7077,7 @@ async function getOrders$1(auth, params = {}) {
6809
7077
  });
6810
7078
  if (!response.ok) {
6811
7079
  const errorText = await response.text();
6812
- throw new Error(`Failed to get orders: ${response.status} ${response.statusText} ${errorText}`);
7080
+ throw alpacaHttpError(`Failed to get orders: ${response.status} ${response.statusText} ${errorText}`, response.status, errorText);
6813
7081
  }
6814
7082
  const orders = (await response.json());
6815
7083
  allOrders.push(...orders);
@@ -6870,7 +7138,7 @@ async function cancelAllOrders$1(auth) {
6870
7138
  });
6871
7139
  if (!response.ok) {
6872
7140
  const errorText = await response.text();
6873
- throw new Error(`Failed to cancel orders: ${response.status} ${response.statusText} ${errorText}`);
7141
+ throw alpacaHttpError(`Failed to cancel orders: ${response.status} ${response.statusText} ${errorText}`, response.status, errorText);
6874
7142
  }
6875
7143
  return (await response.json());
6876
7144
  }
@@ -6903,7 +7171,7 @@ async function getOrder$1(auth, orderId, nested) {
6903
7171
  });
6904
7172
  if (!response.ok) {
6905
7173
  const errorText = await response.text();
6906
- throw new Error(`Failed to get order: ${response.status} ${response.statusText} ${errorText}`);
7174
+ throw alpacaHttpError(`Failed to get order: ${response.status} ${response.statusText} ${errorText}`, response.status, errorText);
6907
7175
  }
6908
7176
  return (await response.json());
6909
7177
  }
@@ -6936,7 +7204,7 @@ async function replaceOrder$1(auth, orderId, params) {
6936
7204
  });
6937
7205
  if (!response.ok) {
6938
7206
  const errorText = await response.text();
6939
- throw new Error(`Failed to replace order: ${response.status} ${response.statusText} ${errorText}`);
7207
+ throw alpacaHttpError(`Failed to replace order: ${response.status} ${response.statusText} ${errorText}`, response.status, errorText);
6940
7208
  }
6941
7209
  return (await response.json());
6942
7210
  }
@@ -6969,7 +7237,7 @@ async function cancelOrder$1(auth, orderId) {
6969
7237
  return { success: false, message: `Order not found: ${orderId}` };
6970
7238
  }
6971
7239
  else {
6972
- throw new Error(`Failed to cancel order: ${response.status} ${response.statusText} ${errorText}`);
7240
+ throw alpacaHttpError(`Failed to cancel order: ${response.status} ${response.statusText} ${errorText}`, response.status, errorText);
6973
7241
  }
6974
7242
  }
6975
7243
  return { success: true };
@@ -11413,7 +11681,13 @@ async function calculateMaxDrawdown$1(tradeBars, isShort) {
11413
11681
  peak = positionAwareEquity[i];
11414
11682
  }
11415
11683
  else {
11416
- const drawdown = peak <= 0 ? 0 : (peak - positionAwareEquity[i]) / Math.abs(peak);
11684
+ // The short branch negates equity, so its peak is legitimately negative.
11685
+ // Scale the decline by the peak's magnitude — a sign test on the peak
11686
+ // would discard every drawdown on one side of the book.
11687
+ const denominator = Math.abs(peak);
11688
+ const drawdown = denominator === 0
11689
+ ? 0
11690
+ : (peak - positionAwareEquity[i]) / denominator;
11417
11691
  if (drawdown > maxDrawdown) {
11418
11692
  maxDrawdown = drawdown;
11419
11693
  }
@@ -11426,17 +11700,71 @@ async function calculateExpenseRatio$1(trade) {
11426
11700
  const totalFees = await computeTotalFees(trade);
11427
11701
  return totalFees ? `${totalFees.toFixed(2)}%` : "N/A";
11428
11702
  }
11703
+ /**
11704
+ * Resolves whether a trade is short from its primary action.
11705
+ *
11706
+ * Only an outright BUY or SELL fixes whether the position's P&L runs with or
11707
+ * against the price series. Option legs, exercises, cancels, adjustments and
11708
+ * hedges do not, and `trade.actions` itself is curated by backend-legacy
11709
+ * selection-set directives, so its absence is routine. Every one of those
11710
+ * cases leaves the direction genuinely unknown, and unknown is returned as
11711
+ * such — inferring a side would silently invert every direction-aware metric
11712
+ * computed from it.
11713
+ *
11714
+ * @param trade - Trade whose direction is being resolved
11715
+ * @returns `true` for a short, `false` for a long, `null` when unresolvable
11716
+ */
11717
+ function resolveIsShort(trade) {
11718
+ const primaryAction = trade.actions?.find((action) => action.primary);
11719
+ if (!primaryAction) {
11720
+ getLogger().warn(`Trade ${trade.id} has no primary action; position direction is unresolved.`);
11721
+ return null;
11722
+ }
11723
+ switch (primaryAction.type) {
11724
+ case "SELL":
11725
+ return true;
11726
+ case "BUY":
11727
+ return false;
11728
+ default:
11729
+ getLogger().warn(`Trade ${trade.id} primary action type "${primaryAction.type}" does not determine a long/short direction.`);
11730
+ return null;
11731
+ }
11732
+ }
11429
11733
  // Main function to fetch and calculate all trade metrics for one trade object
11430
11734
  async function fetchTradeMetrics(trade, tradeBars, benchmarkBars) {
11431
- const isShort = trade.actions?.find((a) => a.primary)?.type === "SELL" ? true : false;
11735
+ const isShort = resolveIsShort(trade);
11736
+ // The Sharpe ratio and the expense ratio do not invert on direction, so they
11737
+ // are started immediately and stay concurrent with everything below.
11738
+ const riskAdjustedReturnPromise = calculateRiskAdjustedReturn$1(tradeBars);
11739
+ const expenseRatioPromise = calculateExpenseRatio$1(trade);
11740
+ if (isShort === null) {
11741
+ // Every other metric inverts on direction. With the direction unknown
11742
+ // there is no value to report — only a sign-ambiguous one — so they are
11743
+ // reported as unavailable rather than resolved by assumption.
11744
+ const [riskAdjustedReturn, expenseRatio] = await Promise.all([
11745
+ riskAdjustedReturnPromise,
11746
+ expenseRatioPromise,
11747
+ ]);
11748
+ return {
11749
+ totalReturnYTD: "N/A",
11750
+ alpha: "N/A",
11751
+ beta: "N/A",
11752
+ alphaAnnualized: "N/A",
11753
+ informationRatio: "N/A",
11754
+ riskAdjustedReturn,
11755
+ expenseRatio,
11756
+ maxDrawdown: "N/A",
11757
+ side: "N/A",
11758
+ };
11759
+ }
11432
11760
  // Calculate metrics concurrently
11433
- const [totalReturnYTD, { alpha, beta, alphaAnnualized }, informationRatio, riskAdjustedReturn, expenseRatio, maxDrawdown,] = await Promise.all([
11761
+ const [totalReturnYTD, { alpha, beta, alphaAnnualized }, informationRatio, maxDrawdown, riskAdjustedReturn, expenseRatio,] = await Promise.all([
11434
11762
  calculateProfitLoss(tradeBars, isShort),
11435
11763
  calculateAlphaAndBeta$1(tradeBars, benchmarkBars, isShort),
11436
11764
  calculateInformationRatio$1(tradeBars, benchmarkBars, isShort),
11437
- calculateRiskAdjustedReturn$1(tradeBars),
11438
- calculateExpenseRatio$1(trade),
11439
11765
  calculateMaxDrawdown$1(tradeBars, isShort),
11766
+ riskAdjustedReturnPromise,
11767
+ expenseRatioPromise,
11440
11768
  ]);
11441
11769
  return {
11442
11770
  totalReturnYTD,
@@ -12800,6 +13128,66 @@ var strategyNs = /*#__PURE__*/Object.freeze({
12800
13128
  calculateRollingSortino: calculateRollingSortino
12801
13129
  });
12802
13130
 
13131
+ /**
13132
+ * Round a PRICE-scale indicator output to a precision derived from its own
13133
+ * magnitude, rather than a hardcoded 2 decimal places.
13134
+ *
13135
+ * A flat `toFixed(2)` silently destroys every sub-penny price — a $0.0003
13136
+ * microcap's bands collapse to `0.00`, and a MACD histogram of a low-priced
13137
+ * name rounds to nothing (F7.2). Precision must scale with the price: values at
13138
+ * or above $1 keep the conventional 2dp, while sub-dollar values keep ~4
13139
+ * significant figures so the number survives its own scale. Non-finite inputs
13140
+ * pass through untouched — totality of the underlying value is the caller's
13141
+ * responsibility, this helper only quantises.
13142
+ *
13143
+ * The `>= $1` branch delegates to `toFixed(2)` rather than re-deriving it as
13144
+ * `Math.round(value * 100) / 100`. The two disagree wherever the intermediate
13145
+ * `value * 100` rounds onto an exact `.5` that the decimal value sits just
13146
+ * below (`1.045` → `1.05` vs `1.04`), which would make this helper shift
13147
+ * ordinary dollar prices by a cent — a behaviour change well outside repairing
13148
+ * sub-penny collapse. Delegating keeps the common case byte-identical to the
13149
+ * historical output by construction, which matters because the same function
13150
+ * computes indicators for unit tests, backtests, paper and live.
13151
+ *
13152
+ * @param value - A price-scale indicator output (band, EMA, MACD component).
13153
+ * @returns The value rounded to a scale-appropriate precision.
13154
+ */
13155
+ function roundToPriceScale(value) {
13156
+ if (!Number.isFinite(value))
13157
+ return value;
13158
+ const abs = Math.abs(value);
13159
+ if (abs === 0)
13160
+ return 0;
13161
+ if (abs >= 1)
13162
+ return parseFloat(value.toFixed(2));
13163
+ // Sub-dollar: decimals = leading zeros after the point + 4 significant figures,
13164
+ // capped so the factor stays within safe-integer range.
13165
+ const decimals = Math.min(12, Math.ceil(-Math.log10(abs)) + 4);
13166
+ const factor = 10 ** decimals;
13167
+ return Math.round(value * factor) / factor;
13168
+ }
13169
+ /**
13170
+ * Relative Strength Index from average gain / average loss, total on the
13171
+ * degenerate flat window.
13172
+ *
13173
+ * When a window has no losses the Wilder ratio `avgGain / avgLoss` is
13174
+ * `+Infinity` (→ RSI 100); on a perfectly flat window it is `0 / 0 = NaN`,
13175
+ * which the naive formula propagates straight into the output. A flat window
13176
+ * carries no momentum, so its RSI is the neutral 50 — never NaN. This mirrors
13177
+ * the engine's live RSI guards (a constant series scores neutral, an all-gains
13178
+ * series scores 100).
13179
+ *
13180
+ * @param avgGain - Average gain over the period (>= 0).
13181
+ * @param avgLoss - Average loss over the period (>= 0).
13182
+ * @returns RSI in [0, 100]; 50 for a flat window, 100 for an all-gains window.
13183
+ */
13184
+ function rsiFromAverages(avgGain, avgLoss) {
13185
+ if (avgLoss === 0)
13186
+ return avgGain === 0 ? 50 : 100;
13187
+ const rs = avgGain / avgLoss;
13188
+ const rsi = 100 - 100 / (1 + rs);
13189
+ return Number.isFinite(rsi) ? rsi : 50;
13190
+ }
12803
13191
  /**
12804
13192
  * Calculates Bollinger Bands for a given set of price data.
12805
13193
  * Bollinger Bands consist of a middle band (SMA) and two outer bands
@@ -12832,9 +13220,9 @@ function calculateBollingerBands(priceData, { period = 20, standardDeviations =
12832
13220
  const lowerBand = sma - standardDeviation * standardDeviations;
12833
13221
  result.push({
12834
13222
  date: priceData[i].date,
12835
- middle: parseFloat(sma.toFixed(2)),
12836
- upper: parseFloat(upperBand.toFixed(2)),
12837
- lower: parseFloat(lowerBand.toFixed(2)),
13223
+ middle: roundToPriceScale(sma),
13224
+ upper: roundToPriceScale(upperBand),
13225
+ lower: roundToPriceScale(lowerBand),
12838
13226
  close: priceData[i].close,
12839
13227
  });
12840
13228
  }
@@ -12876,11 +13264,11 @@ function calculateEMA(priceData, { period = 20, period2 = 9 } = {}) {
12876
13264
  // Add first EMA(s)
12877
13265
  const firstEntry = {
12878
13266
  date: priceData[Math.max(period, period2 || 0) - 1].date,
12879
- ema: parseFloat(prevEMA.toFixed(2)),
13267
+ ema: roundToPriceScale(prevEMA),
12880
13268
  close: priceData[Math.max(period, period2 || 0) - 1].close,
12881
13269
  };
12882
13270
  if (period2) {
12883
- firstEntry.ema2 = parseFloat(prevEMA2.toFixed(2));
13271
+ firstEntry.ema2 = roundToPriceScale(prevEMA2);
12884
13272
  }
12885
13273
  result.push(firstEntry);
12886
13274
  // Calculate EMA for remaining periods
@@ -12890,18 +13278,55 @@ function calculateEMA(priceData, { period = 20, period2 = 9 } = {}) {
12890
13278
  prevEMA = currentEMA;
12891
13279
  const entry = {
12892
13280
  date: priceData[i].date,
12893
- ema: parseFloat(currentEMA.toFixed(2)),
13281
+ ema: roundToPriceScale(currentEMA),
12894
13282
  close: currentClose,
12895
13283
  };
12896
13284
  if (period2) {
12897
13285
  const currentEMA2 = (currentClose - prevEMA2) * multiplier2 + prevEMA2;
12898
13286
  prevEMA2 = currentEMA2;
12899
- entry.ema2 = parseFloat(currentEMA2.toFixed(2));
13287
+ entry.ema2 = roundToPriceScale(currentEMA2);
12900
13288
  }
12901
13289
  result.push(entry);
12902
13290
  }
12903
13291
  return result;
12904
13292
  }
13293
+ /**
13294
+ * Locates a window's swing extremes and derives the direction of its most
13295
+ * recent leg from the order in which those extremes print.
13296
+ *
13297
+ * A Fibonacci construction is anchored to the latest leg: an up-leg runs swing
13298
+ * low to swing high, a down-leg swing high to swing low. Whichever extreme
13299
+ * prints last therefore identifies the leg, which makes the direction a
13300
+ * measurement of the window rather than a caller's assumption. When both
13301
+ * extremes land on the same bar the window contains no leg and the direction
13302
+ * is genuinely indeterminate.
13303
+ *
13304
+ * @param window - The lookback slice to analyse.
13305
+ * @returns The window's swing extremes and derived leg direction.
13306
+ */
13307
+ function analyzeSwingWindow(window) {
13308
+ let swingHigh = -Infinity;
13309
+ let swingLow = Infinity;
13310
+ let highIndex = -1;
13311
+ let lowIndex = -1;
13312
+ // `>=` / `<=` keep the most recent occurrence of each extreme, which is the
13313
+ // one the current leg is measured from.
13314
+ for (let i = 0; i < window.length; i++) {
13315
+ if (window[i].high >= swingHigh) {
13316
+ swingHigh = window[i].high;
13317
+ highIndex = i;
13318
+ }
13319
+ if (window[i].low <= swingLow) {
13320
+ swingLow = window[i].low;
13321
+ lowIndex = i;
13322
+ }
13323
+ }
13324
+ return {
13325
+ swingHigh,
13326
+ swingLow,
13327
+ trend: highIndex === lowIndex ? null : highIndex > lowIndex ? "uptrend" : "downtrend",
13328
+ };
13329
+ }
12905
13330
  /**
12906
13331
  * Calculates Fibonacci retracement and extension levels based on price data.
12907
13332
  * Fibonacci levels are used to identify potential support and resistance levels.
@@ -12911,43 +13336,55 @@ function calculateEMA(priceData, { period = 20, period2 = 9 } = {}) {
12911
13336
  * @param params.lookbackPeriod - The number of periods to look back for swing high/low (default is 20).
12912
13337
  * @param params.retracementLevels - An array of retracement levels to calculate (default is [0.236, 0.382, 0.5, 0.618, 0.786]).
12913
13338
  * @param params.extensionLevels - An array of extension levels to calculate (default is [1.272, 1.618, 2.618]).
12914
- * @param params.reverseDirection - A boolean indicating if the trend is reversed (default is false).
13339
+ * @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.
12915
13340
  * @returns An array of FibonacciData objects containing the calculated levels.
12916
13341
  */
12917
- 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, } = {}) {
13342
+ function calculateFibonacciLevels(priceData, { lookbackPeriod = 20, retracementLevels = [0.236, 0.382, 0.5, 0.618, 0.786], extensionLevels = [1.272, 1.618, 2.618], reverseDirection, } = {}) {
12918
13343
  const result = [];
12919
13344
  for (let i = 0; i < priceData.length; i++) {
12920
13345
  const periodSlice = priceData.slice(Math.max(0, i - lookbackPeriod + 1), i + 1);
12921
- const swingHigh = Math.max(...periodSlice.map((d) => d.high));
12922
- const swingLow = Math.min(...periodSlice.map((d) => d.low));
13346
+ const { swingHigh, swingLow, trend: derivedTrend } = analyzeSwingWindow(periodSlice);
12923
13347
  const priceRange = swingHigh - swingLow;
12924
- const trend = reverseDirection ? "downtrend" : "uptrend";
13348
+ // An explicit `reverseDirection` is the caller stating the leg it is
13349
+ // measuring; absent that, the leg is read off the window itself.
13350
+ const trend = reverseDirection === undefined
13351
+ ? derivedTrend
13352
+ : reverseDirection
13353
+ ? "downtrend"
13354
+ : "uptrend";
12925
13355
  const levels = [];
12926
- if (priceRange > 0) {
13356
+ if (priceRange > 0 && trend !== null) {
13357
+ const isDowntrend = trend === "downtrend";
12927
13358
  // Calculate retracement levels
12928
13359
  retracementLevels.forEach((level) => {
12929
- const price = reverseDirection
13360
+ const price = isDowntrend
12930
13361
  ? swingLow + priceRange * level
12931
13362
  : swingHigh - priceRange * level;
12932
13363
  levels.push({
12933
13364
  level,
12934
- price: parseFloat(price.toFixed(2)),
13365
+ price: roundToPriceScale(price),
12935
13366
  type: "retracement",
12936
13367
  });
12937
13368
  });
12938
- // Calculate extension levels
13369
+ // Calculate extension levels — each is projected beyond the leg's
13370
+ // terminal extreme: past the swing low for a down-leg, past the swing
13371
+ // high for an up-leg. Anchoring both to the same extreme would place one
13372
+ // side's targets a full swing range away from where the leg is running.
12939
13373
  extensionLevels.forEach((level) => {
12940
- const price = reverseDirection
12941
- ? swingHigh - priceRange * (level - 1) // For downtrend
13374
+ const price = isDowntrend
13375
+ ? swingLow - priceRange * (level - 1) // For downtrend
12942
13376
  : swingHigh + priceRange * (level - 1); // For uptrend
12943
13377
  levels.push({
12944
13378
  level,
12945
- price: parseFloat(price.toFixed(2)),
13379
+ price: roundToPriceScale(price),
12946
13380
  type: "extension",
12947
13381
  });
12948
13382
  });
12949
13383
  // Sort levels by price
12950
- levels.sort((a, b) => reverseDirection ? b.price - a.price : a.price - b.price);
13384
+ levels.sort((a, b) => isDowntrend ? b.price - a.price : a.price - b.price);
13385
+ }
13386
+ else if (trend === null) {
13387
+ logIfDebug(`Swing high and low fall on the same bar on date ${priceData[i].date}; trend is indeterminate and no levels calculated.`);
12951
13388
  }
12952
13389
  else {
12953
13390
  logIfDebug(`Price range is zero on date ${priceData[i].date}; no levels calculated.`);
@@ -13002,9 +13439,9 @@ function calculateMACD(priceData, { shortPeriod = 12, longPeriod = 26, signalPer
13002
13439
  const hist = macdValue - signalEMA;
13003
13440
  result.push({
13004
13441
  date: emaLong[i].date, // Use emaLong's date for alignment
13005
- macd: parseFloat(macdValue.toFixed(2)),
13006
- signal: parseFloat(signalEMA.toFixed(2)),
13007
- histogram: parseFloat(hist.toFixed(2)),
13442
+ macd: roundToPriceScale(macdValue),
13443
+ signal: roundToPriceScale(signalEMA),
13444
+ histogram: roundToPriceScale(hist),
13008
13445
  close: emaLong[i].close,
13009
13446
  });
13010
13447
  }
@@ -13039,9 +13476,9 @@ function calculateRSI(priceData, { period = 14 } = {}) {
13039
13476
  }
13040
13477
  avgGain = avgGain / period;
13041
13478
  avgLoss = avgLoss / period;
13042
- // Calculate RSI for the first period
13043
- let rs = avgGain / avgLoss;
13044
- let rsi = 100 - 100 / (1 + rs);
13479
+ // Calculate RSI for the first period (total on a flat window — see
13480
+ // rsiFromAverages: a constant series scores the neutral 50, never NaN).
13481
+ let rsi = rsiFromAverages(avgGain, avgLoss);
13045
13482
  result.push({
13046
13483
  date: priceData[period].date,
13047
13484
  rsi: parseFloat(rsi.toFixed(2)),
@@ -13055,8 +13492,7 @@ function calculateRSI(priceData, { period = 14 } = {}) {
13055
13492
  // Use smoothed averages
13056
13493
  avgGain = (avgGain * (period - 1) + gain) / period;
13057
13494
  avgLoss = (avgLoss * (period - 1) + loss) / period;
13058
- rs = avgGain / avgLoss;
13059
- rsi = 100 - 100 / (1 + rs);
13495
+ rsi = rsiFromAverages(avgGain, avgLoss);
13060
13496
  result.push({
13061
13497
  date: priceData[i].date,
13062
13498
  rsi: parseFloat(rsi.toFixed(2)),
@@ -13077,6 +13513,20 @@ function calculateRSI(priceData, { period = 14 } = {}) {
13077
13513
  * @returns An array of StochData objects containing the calculated %K and %D values.
13078
13514
  */
13079
13515
  function calculateStochasticOscillator(priceData, { lookbackPeriod = 5, signalPeriod = 3, smoothingFactor = 3, } = {}) {
13516
+ // Each period is a divisor (`kSum / min(len, smoothingFactor)`) and a slice
13517
+ // width. A zero or fractional period therefore divides by zero or slices an
13518
+ // empty window, producing NaN/Infinity %K and %D — an oscillator reading that
13519
+ // is never true and never false. The periods are caller-supplied constants
13520
+ // rather than market data, so an invalid one is a programming error and is
13521
+ // reported as such, matching the ATR and volatility primitives.
13522
+ if (!Number.isInteger(lookbackPeriod) ||
13523
+ lookbackPeriod < 1 ||
13524
+ !Number.isInteger(signalPeriod) ||
13525
+ signalPeriod < 1 ||
13526
+ !Number.isInteger(smoothingFactor) ||
13527
+ smoothingFactor < 1) {
13528
+ throw new Error("calculateStochasticOscillator: lookbackPeriod, signalPeriod and smoothingFactor must be positive integers");
13529
+ }
13080
13530
  if (priceData.length < lookbackPeriod) {
13081
13531
  logIfDebug(`Insufficient data for Stochastic Oscillator calculation: required periods: ${lookbackPeriod}, but only received ${priceData.length} periods of data`);
13082
13532
  return [];
@@ -13117,6 +13567,48 @@ function calculateStochasticOscillator(priceData, { lookbackPeriod = 5, signalPe
13117
13567
  }
13118
13568
  return result;
13119
13569
  }
13570
+ /**
13571
+ * Collapses a cluster of nearby pivots into one volume-weighted level, or
13572
+ * reports that the cluster evidences no level at all.
13573
+ *
13574
+ * Both outputs are volume-weighted: the price is the volume-weighted mean of
13575
+ * the cluster's pivots, and the strength is the pivot count weighted by each
13576
+ * pivot's share of cluster volume. That weighting is undefined when the cluster
13577
+ * transacted no volume — `0 / 0` makes both NaN. A NaN level is strictly worse
13578
+ * than no level: every comparison against NaN is false, so a stop or target
13579
+ * placed off one is silently never triggered, leaving the position unprotected
13580
+ * while appearing protected.
13581
+ *
13582
+ * Zero cluster volume is a real market state rather than corrupt input — halted,
13583
+ * pre-market-thin and synthetic warm-up bars all report it. A support or
13584
+ * resistance level means price transacted enough there to turn the market, so a
13585
+ * cluster with no volume has not evidenced one. `SupportResistanceLevel` types
13586
+ * both fields as non-optional numbers, which leaves omitting the level as the
13587
+ * only honest way to say so.
13588
+ *
13589
+ * @param cluster - The nearby pivots to collapse into a single level.
13590
+ * @param currentPrice - The bar's close, which classifies the level's side.
13591
+ * @returns The aggregated level, or null when the cluster evidences none.
13592
+ */
13593
+ function aggregatePivotCluster(cluster, currentPrice) {
13594
+ const totalVolume = cluster.reduce((sum, p) => sum + p.volume, 0);
13595
+ // Negated `> 0` so NaN and negative totals are rejected alongside zero: no
13596
+ // volume weighting survives any of them.
13597
+ if (!(totalVolume > 0))
13598
+ return null;
13599
+ const avgPrice = cluster.reduce((sum, p) => sum + p.price * p.volume, 0) / totalVolume;
13600
+ const strength = cluster.reduce((sum, p) => sum + p.count * (p.volume / totalVolume), 0);
13601
+ if (!Number.isFinite(avgPrice) || !Number.isFinite(strength))
13602
+ return null;
13603
+ return {
13604
+ // The level is a price, so its precision follows the price's magnitude
13605
+ // (F7.2). Strength is a count-weighted score rather than a price and keeps
13606
+ // the conventional 2dp.
13607
+ price: roundToPriceScale(avgPrice),
13608
+ strength: parseFloat(strength.toFixed(2)),
13609
+ type: avgPrice > currentPrice ? "resistance" : "support",
13610
+ };
13611
+ }
13120
13612
  /**
13121
13613
  * Calculates support and resistance levels based on price data.
13122
13614
  * Support and resistance levels are price levels at which a stock tends to stop and reverse.
@@ -13137,9 +13629,22 @@ function calculateSupportAndResistance(priceData, { maxLevels = 5, lookbackPerio
13137
13629
  const priceChanges = analysisWindow
13138
13630
  .slice(1)
13139
13631
  .map((bar, idx) => Math.abs(bar.close - analysisWindow[idx].close));
13140
- const avgPriceChange = priceChanges.reduce((sum, change) => sum + change, 0) /
13141
- priceChanges.length;
13142
- const volatility = avgPriceChange / analysisWindow[0].close; // Relative volatility
13632
+ // A single-bar window produces no price changes to average, and a
13633
+ // non-positive reference close cannot scale one — `0 / 0` and `x / 0` make
13634
+ // the relative volatility NaN or Infinity. Volatility is the sole input to
13635
+ // both the pivot sensitivity and the level-grouping gap below, so a
13636
+ // non-finite value silently disables every comparison that depends on it
13637
+ // (each is false against NaN). Unmeasurable volatility resolves to zero,
13638
+ // under which each pivot stands as its own level instead of being merged on
13639
+ // a meaningless ratio.
13640
+ const referenceClose = analysisWindow[0].close;
13641
+ const avgPriceChange = priceChanges.length > 0
13642
+ ? priceChanges.reduce((sum, change) => sum + change, 0) /
13643
+ priceChanges.length
13644
+ : 0;
13645
+ const volatility = referenceClose > 0 && Number.isFinite(avgPriceChange)
13646
+ ? avgPriceChange / referenceClose
13647
+ : 0; // Relative volatility
13143
13648
  // **Adjust Sensitivity and minGapBetweenLevels Dynamically**
13144
13649
  const sensitivity = volatility * 2; // Adjust the multiplier as needed
13145
13650
  const minGapBetweenLevels = volatility * 100; // Convert to percentage
@@ -13148,8 +13653,16 @@ function calculateSupportAndResistance(priceData, { maxLevels = 5, lookbackPerio
13148
13653
  const curr = analysisWindow[j];
13149
13654
  const prevBar = analysisWindow[j - 1];
13150
13655
  const nextBar = analysisWindow[j + 1];
13656
+ // A pivot is matched against existing candidates by a *relative* gap
13657
+ // measured against its own price, so a non-positive reference price makes
13658
+ // that ratio meaningless: zero divides to NaN or Infinity (which never
13659
+ // compares below the sensitivity, so the pivot never merges), and a
13660
+ // negative price inverts the comparison (so everything merges). A bar
13661
+ // without a positive high or low carries no tradeable level either way.
13151
13662
  // Check for high pivot
13152
- if (curr.high > prevBar.high && curr.high > nextBar.high) {
13663
+ if (curr.high > 0 &&
13664
+ curr.high > prevBar.high &&
13665
+ curr.high > nextBar.high) {
13153
13666
  const existingPivot = pivotPoints.find((p) => Math.abs(p.price - curr.high) / curr.high < sensitivity);
13154
13667
  if (existingPivot) {
13155
13668
  existingPivot.count++;
@@ -13160,7 +13673,7 @@ function calculateSupportAndResistance(priceData, { maxLevels = 5, lookbackPerio
13160
13673
  }
13161
13674
  }
13162
13675
  // Check for low pivot
13163
- if (curr.low < prevBar.low && curr.low < nextBar.low) {
13676
+ if (curr.low > 0 && curr.low < prevBar.low && curr.low < nextBar.low) {
13164
13677
  const existingPivot = pivotPoints.find((p) => Math.abs(p.price - curr.low) / curr.low < sensitivity);
13165
13678
  if (existingPivot) {
13166
13679
  existingPivot.count++;
@@ -13190,33 +13703,17 @@ function calculateSupportAndResistance(priceData, { maxLevels = 5, lookbackPerio
13190
13703
  }
13191
13704
  else {
13192
13705
  // Process current group
13193
- if (currentGroup.length > 0) {
13194
- const totalVolume = currentGroup.reduce((sum, p) => sum + p.volume, 0);
13195
- const avgPrice = currentGroup.reduce((sum, p) => sum + p.price * p.volume, 0) /
13196
- totalVolume;
13197
- const totalStrength = currentGroup.reduce((sum, p) => sum + p.count * (p.volume / totalVolume), 0);
13198
- levels.push({
13199
- price: parseFloat(avgPrice.toFixed(2)),
13200
- strength: parseFloat(totalStrength.toFixed(2)),
13201
- type: avgPrice > currentPrice ? "resistance" : "support",
13202
- });
13203
- }
13706
+ const level = aggregatePivotCluster(currentGroup, currentPrice);
13707
+ if (level)
13708
+ levels.push(level);
13204
13709
  currentGroup = [pivotPoints[j]];
13205
13710
  }
13206
13711
  }
13207
13712
  }
13208
13713
  // Process final group
13209
- if (currentGroup.length > 0) {
13210
- const totalVolume = currentGroup.reduce((sum, p) => sum + p.volume, 0);
13211
- const avgPrice = currentGroup.reduce((sum, p) => sum + p.price * p.volume, 0) /
13212
- totalVolume;
13213
- const totalStrength = currentGroup.reduce((sum, p) => sum + p.count * (p.volume / totalVolume), 0);
13214
- levels.push({
13215
- price: parseFloat(avgPrice.toFixed(2)),
13216
- strength: parseFloat(totalStrength.toFixed(2)),
13217
- type: avgPrice > currentPrice ? "resistance" : "support",
13218
- });
13219
- }
13714
+ const finalGroupLevel = aggregatePivotCluster(currentGroup, currentPrice);
13715
+ if (finalGroupLevel)
13716
+ levels.push(finalGroupLevel);
13220
13717
  // Sort by strength and limit
13221
13718
  const finalLevels = levels
13222
13719
  .sort((a, b) => b.strength - a.strength)
@@ -51969,7 +52466,8 @@ async function createBracketOrder(executor, params) {
51969
52466
  *
51970
52467
  * @example
51971
52468
  * ```typescript
51972
- * // Add protection to an existing long position
52469
+ * // Add protection to an existing long position (sell to close):
52470
+ * // take profit above, stop below.
51973
52471
  * const result = await createProtectiveBracket(
51974
52472
  * executor,
51975
52473
  * {
@@ -51982,6 +52480,23 @@ async function createBracketOrder(executor, params) {
51982
52480
  * }
51983
52481
  * );
51984
52482
  * ```
52483
+ *
52484
+ * @example
52485
+ * ```typescript
52486
+ * // Add protection to an existing short position (buy to close):
52487
+ * // take profit below, stop above.
52488
+ * const result = await createProtectiveBracket(
52489
+ * executor,
52490
+ * {
52491
+ * symbol: 'TSLA',
52492
+ * qty: 50,
52493
+ * side: 'buy',
52494
+ * takeProfit: { limitPrice: 200.00 },
52495
+ * stopLoss: { stopPrice: 260.00 },
52496
+ * timeInForce: 'gtc',
52497
+ * }
52498
+ * );
52499
+ * ```
51985
52500
  */
51986
52501
  async function createProtectiveBracket(executor, params) {
51987
52502
  log$j(`Creating protective bracket for ${params.symbol}: ${params.qty} shares`, { type: "info" });
@@ -51994,15 +52509,28 @@ async function createProtectiveBracket(executor, params) {
51994
52509
  if (!params.qty || params.qty <= 0) {
51995
52510
  throw new Error("Quantity must be a positive number");
51996
52511
  }
52512
+ // The closing side determines which of the two exit prices is the profit
52513
+ // target, so it must be stated rather than inferred.
52514
+ if (params.side !== "buy" && params.side !== "sell") {
52515
+ throw new Error("Protective bracket requires a side of 'buy' or 'sell' matching the position being closed");
52516
+ }
51997
52517
  if (!params.takeProfit?.limitPrice || params.takeProfit.limitPrice <= 0) {
51998
52518
  throw new Error("Take profit limit price is required and must be positive");
51999
52519
  }
52000
52520
  if (!params.stopLoss?.stopPrice || params.stopLoss.stopPrice <= 0) {
52001
52521
  throw new Error("Stop loss stop price is required and must be positive");
52002
52522
  }
52003
- // For a protective sell bracket, take profit should be higher than stop loss
52004
- if (params.takeProfit.limitPrice <= params.stopLoss.stopPrice) {
52005
- log$j("Warning: Take profit price should be higher than stop loss price for protective sell bracket", { type: "warn" });
52523
+ // The take profit must sit on the profitable side of the position and the
52524
+ // stop on the losing side. Which price is the higher one therefore depends
52525
+ // on the closing side: selling to close a long takes profit above and stops
52526
+ // below; buying to close a short is the exact mirror.
52527
+ if (params.side === "sell") {
52528
+ if (params.takeProfit.limitPrice <= params.stopLoss.stopPrice) {
52529
+ log$j("Warning: Take profit price should be higher than stop loss price for protective sell bracket", { type: "warn" });
52530
+ }
52531
+ }
52532
+ else if (params.takeProfit.limitPrice >= params.stopLoss.stopPrice) {
52533
+ log$j("Warning: Take profit price should be lower than stop loss price for protective buy bracket", { type: "warn" });
52006
52534
  }
52007
52535
  try {
52008
52536
  // Build the OCO order parameters
@@ -53632,7 +54160,7 @@ async function createTrailingStop(client, params) {
53632
54160
  log$g(`Trailing stop creation failed for ${params.symbol}: ${err.message}`, {
53633
54161
  type: "error",
53634
54162
  });
53635
- throw new Error(`Failed to create trailing stop for ${params.symbol}: ${err.message}`);
54163
+ throw enrichAlpacaError(new Error(`Failed to create trailing stop for ${params.symbol}: ${err.message}`), error);
53636
54164
  }
53637
54165
  }
53638
54166
  /**
@@ -53698,7 +54226,11 @@ async function updateTrailingStop(client, orderId, updates) {
53698
54226
  log$g(`Trailing stop update failed for ${orderId}: ${err.message}`, {
53699
54227
  type: "error",
53700
54228
  });
53701
- throw new Error(`Failed to update trailing stop ${orderId}: ${err.message}`);
54229
+ // Preserve Alpaca's `response.data` (numeric code `42210000` etc.) that the
54230
+ // SDK reduces to a bare "status code NNN" message. This is THE trailing-stop
54231
+ // modify path; dropping the code here left the consumer unable to tell a
54232
+ // stale-order reject from a benign race, blind-failing the profit lock.
54233
+ throw enrichAlpacaError(new Error(`Failed to update trailing stop ${orderId}: ${err.message}`), error);
53702
54234
  }
53703
54235
  }
53704
54236
  /**
@@ -53739,7 +54271,7 @@ async function getTrailingStopHWM(client, orderId) {
53739
54271
  log$g(`Failed to get trailing stop HWM for ${orderId}: ${err.message}`, {
53740
54272
  type: "error",
53741
54273
  });
53742
- throw new Error(`Failed to get trailing stop HWM for ${orderId}: ${err.message}`);
54274
+ throw enrichAlpacaError(new Error(`Failed to get trailing stop HWM for ${orderId}: ${err.message}`), error);
53743
54275
  }
53744
54276
  }
53745
54277
  /**
@@ -53768,19 +54300,22 @@ async function cancelTrailingStop(client, orderId) {
53768
54300
  log$g(`Trailing stop ${orderId} is not cancelable (may already be filled or canceled)`, {
53769
54301
  type: "warn",
53770
54302
  });
53771
- throw new Error(`Trailing stop ${orderId} is not cancelable: order may already be filled or canceled`);
54303
+ throw enrichAlpacaError(new Error(`Trailing stop ${orderId} is not cancelable: order may already be filled or canceled`), error);
53772
54304
  }
53773
54305
  log$g(`Failed to cancel trailing stop ${orderId}: ${err.message}`, {
53774
54306
  type: "error",
53775
54307
  });
53776
- throw new Error(`Failed to cancel trailing stop ${orderId}: ${err.message}`);
54308
+ throw enrichAlpacaError(new Error(`Failed to cancel trailing stop ${orderId}: ${err.message}`), error);
53777
54309
  }
53778
54310
  }
53779
54311
  /**
53780
- * Create trailing stops for all positions in a portfolio
54312
+ * Create trailing stops for every position in a portfolio
53781
54313
  *
53782
- * This function creates trailing stop orders for all long positions in the portfolio,
53783
- * which is useful for applying blanket downside protection. Short positions are skipped.
54314
+ * Applies blanket adverse-move protection across the book. The protective side
54315
+ * is derived per position from the signed quantity reported by the broker — a
54316
+ * long is protected by a trailing sell, a short by a trailing buy — so a
54317
+ * position is never left unprotected because of the direction it happens to
54318
+ * hold.
53784
54319
  *
53785
54320
  * @param client - AlpacaClient instance
53786
54321
  * @param params - Configuration for portfolio-wide trailing stops
@@ -53805,8 +54340,11 @@ async function createPortfolioTrailingStops(client, params) {
53805
54340
  if (params.trailPercent <= 0) {
53806
54341
  throw new Error("trailPercent must be greater than 0");
53807
54342
  }
53808
- if (params.trailPercent > 100) {
53809
- throw new Error("trailPercent cannot exceed 100");
54343
+ // Reject against the broker's real ceiling up front. A looser outer bound
54344
+ // lets an out-of-range value reach the per-position loop, where every single
54345
+ // submission is rejected and the book silently ends up unprotected.
54346
+ if (params.trailPercent > ALPACA_MAX_TRAIL_PERCENT) {
54347
+ throw new Error(`trailPercent cannot exceed ${ALPACA_MAX_TRAIL_PERCENT} (Alpaca API limit)`);
53810
54348
  }
53811
54349
  const sdk = client.getSDK();
53812
54350
  const results = new Map();
@@ -53829,19 +54367,21 @@ async function createPortfolioTrailingStops(client, params) {
53829
54367
  log$g(`Skipping ${symbol} (excluded)`, { type: "debug" });
53830
54368
  continue;
53831
54369
  }
53832
- // Only create trailing stops for long positions
54370
+ // Derive the protective side from the broker's signed quantity: a long
54371
+ // (qty > 0) is closed by selling, a short (qty < 0) by buying. Direction
54372
+ // is read from the position, never assumed — a stop on the wrong side
54373
+ // doubles the exposure it was meant to cap.
53833
54374
  const qty = parseFloat(position.qty);
53834
- if (qty <= 0) {
53835
- log$g(`Skipping ${symbol} (not a long position, qty: ${qty})`, {
53836
- type: "debug",
53837
- });
54375
+ if (!Number.isFinite(qty) || qty === 0) {
54376
+ log$g(`Skipping ${symbol}: position qty "${position.qty}" is not a usable non-zero number`, { type: "warn" });
53838
54377
  continue;
53839
54378
  }
54379
+ const side = qty > 0 ? "sell" : "buy";
53840
54380
  try {
53841
54381
  const order = await createTrailingStop(client, {
53842
54382
  symbol,
53843
54383
  qty: Math.abs(qty),
53844
- side: "sell",
54384
+ side,
53845
54385
  trailPercent: params.trailPercent,
53846
54386
  timeInForce: params.timeInForce || "gtc",
53847
54387
  });
@@ -53849,9 +54389,14 @@ async function createPortfolioTrailingStops(client, params) {
53849
54389
  }
53850
54390
  catch (err) {
53851
54391
  const errorMessage = err.message;
53852
- errors.push({ symbol, error: errorMessage });
54392
+ // Preserve the broker's numeric code (e.g. 42210000) rather than
54393
+ // reducing the swallowed per-item failure to its flattened message —
54394
+ // this loop only logs failures, so the log is the preservation target.
54395
+ const brokerCode = getAlpacaBrokerErrorCode(err);
54396
+ errors.push({ symbol, error: errorMessage, brokerCode });
53853
54397
  log$g(`Failed to create trailing stop for ${symbol}: ${errorMessage}`, {
53854
54398
  type: "error",
54399
+ metadata: { brokerCode },
53855
54400
  });
53856
54401
  }
53857
54402
  }
@@ -53861,7 +54406,9 @@ async function createPortfolioTrailingStops(client, params) {
53861
54406
  const skippedCount = positions.length - successCount - failureCount;
53862
54407
  log$g(`Portfolio trailing stops complete: ${successCount} created, ${failureCount} failed, ${skippedCount} skipped`, { type: "info" });
53863
54408
  if (errors.length > 0) {
53864
- log$g(`Failed symbols: ${errors.map((e) => `${e.symbol} (${e.error})`).join(", ")}`, {
54409
+ log$g(`Failed symbols: ${errors
54410
+ .map((e) => `${e.symbol} (${e.error}${e.brokerCode !== null ? `, code ${e.brokerCode}` : ""})`)
54411
+ .join(", ")}`, {
53865
54412
  type: "warn",
53866
54413
  });
53867
54414
  }
@@ -53872,7 +54419,7 @@ async function createPortfolioTrailingStops(client, params) {
53872
54419
  log$g(`Failed to create portfolio trailing stops: ${err.message}`, {
53873
54420
  type: "error",
53874
54421
  });
53875
- throw new Error(`Failed to create portfolio trailing stops: ${err.message}`);
54422
+ throw enrichAlpacaError(new Error(`Failed to create portfolio trailing stops: ${err.message}`), error);
53876
54423
  }
53877
54424
  }
53878
54425
  /**
@@ -53911,7 +54458,7 @@ async function getOpenTrailingStops(client, symbol) {
53911
54458
  catch (error) {
53912
54459
  const err = error;
53913
54460
  log$g(`Failed to get open trailing stops: ${err.message}`, { type: "error" });
53914
- throw new Error(`Failed to get open trailing stops: ${err.message}`);
54461
+ throw enrichAlpacaError(new Error(`Failed to get open trailing stops: ${err.message}`), error);
53915
54462
  }
53916
54463
  }
53917
54464
  /**
@@ -53959,7 +54506,10 @@ async function cancelTrailingStopsForSymbol(client, symbol) {
53959
54506
  canceledCount++;
53960
54507
  }
53961
54508
  catch (err) {
53962
- errors.push(`${order.id}: ${err.message}`);
54509
+ // Keep the broker's numeric code alongside the message so the swallowed
54510
+ // per-item cancel failure stays diagnosable in the summary log.
54511
+ const brokerCode = getAlpacaBrokerErrorCode(err);
54512
+ errors.push(`${order.id}: ${err.message}${brokerCode !== null ? ` (code ${brokerCode})` : ""}`);
53963
54513
  }
53964
54514
  }
53965
54515
  if (errors.length > 0) {
@@ -54779,13 +55329,25 @@ async function shortWithStopLoss(client, symbol, qty, entryPrice, stopLossPrice)
54779
55329
  * @param qty - Number of shares
54780
55330
  * @param entryPrice - Limit price for entry (null for market)
54781
55331
  * @param stopLossPercent - Stop loss percentage (e.g., 5 for 5%)
54782
- * @param side - Order side ('buy' or 'sell')
55332
+ * @param side - Order side ('buy' or 'sell'). Required: the entry direction is
55333
+ * the caller's decision, and a default would open a position in a direction
55334
+ * nobody chose.
54783
55335
  *
54784
55336
  * @example
54785
55337
  * // Buy AAPL at $150 with 3% stop loss (stop at $145.50)
54786
55338
  * const result = await entryWithPercentStopLoss(client, 'AAPL', 100, 150.00, 3, 'buy');
55339
+ *
55340
+ * @example
55341
+ * // Short GOOGL at $140 with 3% stop loss (stop at $144.20)
55342
+ * const result = await entryWithPercentStopLoss(client, 'GOOGL', 10, 140.00, 3, 'sell');
54787
55343
  */
54788
- async function entryWithPercentStopLoss(client, symbol, qty, entryPrice, stopLossPercent, side = "buy") {
55344
+ async function entryWithPercentStopLoss(client, symbol, qty, entryPrice, stopLossPercent, side) {
55345
+ // Guard the direction at runtime as well as in the signature: every price
55346
+ // below is computed off `side`, so an unsupplied one would silently place
55347
+ // the stop on the wrong side of the entry.
55348
+ if (side !== "buy" && side !== "sell") {
55349
+ throw new Error("entryWithPercentStopLoss requires an explicit side of 'buy' or 'sell'; the entry direction cannot be inferred");
55350
+ }
54789
55351
  if (stopLossPercent <= 0 || stopLossPercent >= 100) {
54790
55352
  throw new Error("stopLossPercent must be between 0 and 100");
54791
55353
  }
@@ -58184,7 +58746,12 @@ async function resolveDuplicateSubmission(client, clientOrderId, symbol, cause)
58184
58746
  catch (lookupError) {
58185
58747
  const reason = lookupError instanceof Error ? lookupError.message : String(lookupError);
58186
58748
  log$6(`Duplicate-order lookup failed for ${clientOrderId}; failing closed (no resubmit): ${reason}`, { type: "error", symbol, metadata: { clientOrderId } });
58187
- 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);
58749
+ // The typed error represents the ORIGINAL duplicate rejection, so its broker
58750
+ // payload must come from `cause` (the 422), not from the lookup failure.
58751
+ // Chain the lookup error ahead of the original 422 (and carry the 422's
58752
+ // normalized detail onto it) so both are diagnosable and
58753
+ // getAlpacaBrokerErrorCode still resolves the duplicate code.
58754
+ 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));
58188
58755
  }
58189
58756
  if (existing && !TERMINAL_DEAD_ORDER_STATUSES.has(existing.status)) {
58190
58757
  log$6(`client_order_id ${clientOrderId} already submitted (status=${existing.status}); returning existing order ${existing.id} as idempotent success`, {
@@ -58318,7 +58885,7 @@ async function createOrder(client, params) {
58318
58885
  symbol,
58319
58886
  metadata: { params: submission },
58320
58887
  });
58321
- throw new Error(`Failed to create ${type} order for ${symbol}: ${errorMessage}`);
58888
+ throw enrichAlpacaError(new Error(`Failed to create ${type} order for ${symbol}: ${errorMessage}`), error);
58322
58889
  }
58323
58890
  }
58324
58891
  /**
@@ -58347,7 +58914,7 @@ async function getOrder(client, orderId) {
58347
58914
  catch (error) {
58348
58915
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
58349
58916
  log$6(`Failed to fetch order ${orderId}: ${errorMessage}`, { type: "error" });
58350
- throw new Error(`Failed to fetch order ${orderId}: ${errorMessage}`);
58917
+ throw enrichAlpacaError(new Error(`Failed to fetch order ${orderId}: ${errorMessage}`), error);
58351
58918
  }
58352
58919
  }
58353
58920
  /**
@@ -58410,7 +58977,7 @@ async function getOrders(client, params = {}) {
58410
58977
  catch (error) {
58411
58978
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
58412
58979
  log$6(`Failed to fetch orders: ${errorMessage}`, { type: "error" });
58413
- throw new Error(`Failed to fetch orders: ${errorMessage}`);
58980
+ throw enrichAlpacaError(new Error(`Failed to fetch orders: ${errorMessage}`), error);
58414
58981
  }
58415
58982
  }
58416
58983
  /**
@@ -58440,16 +59007,16 @@ async function cancelOrder(client, orderId) {
58440
59007
  log$6(`Order ${orderId} is not cancelable (may already be filled or canceled)`, {
58441
59008
  type: "warn",
58442
59009
  });
58443
- throw new Error(`Order ${orderId} is not cancelable`);
59010
+ throw enrichAlpacaError(new Error(`Order ${orderId} is not cancelable`), error);
58444
59011
  }
58445
59012
  if (errorMessage.includes("404") || errorMessage.includes("not found")) {
58446
59013
  log$6(`Order ${orderId} not found`, { type: "error" });
58447
- throw new Error(`Order ${orderId} not found`);
59014
+ throw enrichAlpacaError(new Error(`Order ${orderId} not found`), error);
58448
59015
  }
58449
59016
  log$6(`Failed to cancel order ${orderId}: ${errorMessage}`, {
58450
59017
  type: "error",
58451
59018
  });
58452
- throw new Error(`Failed to cancel order ${orderId}: ${errorMessage}`);
59019
+ throw enrichAlpacaError(new Error(`Failed to cancel order ${orderId}: ${errorMessage}`), error);
58453
59020
  }
58454
59021
  }
58455
59022
  /**
@@ -58492,7 +59059,7 @@ async function cancelAllOrders(client) {
58492
59059
  catch (error) {
58493
59060
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
58494
59061
  log$6(`Failed to cancel all orders: ${errorMessage}`, { type: "error" });
58495
- throw new Error(`Failed to cancel all orders: ${errorMessage}`);
59062
+ throw enrichAlpacaError(new Error(`Failed to cancel all orders: ${errorMessage}`), error);
58496
59063
  }
58497
59064
  }
58498
59065
  /**
@@ -58550,16 +59117,16 @@ async function replaceOrder(client, orderId, params) {
58550
59117
  log$6(`Order ${orderId} cannot be replaced (may already be filled)`, {
58551
59118
  type: "error",
58552
59119
  });
58553
- throw new Error(`Order ${orderId} cannot be replaced: order may already be filled or canceled`);
59120
+ throw enrichAlpacaError(new Error(`Order ${orderId} cannot be replaced: order may already be filled or canceled`), error);
58554
59121
  }
58555
59122
  if (errorMessage.includes("404")) {
58556
59123
  log$6(`Order ${orderId} not found`, { type: "error" });
58557
- throw new Error(`Order ${orderId} not found`);
59124
+ throw enrichAlpacaError(new Error(`Order ${orderId} not found`), error);
58558
59125
  }
58559
59126
  log$6(`Failed to replace order ${orderId}: ${errorMessage}`, {
58560
59127
  type: "error",
58561
59128
  });
58562
- throw new Error(`Failed to replace order ${orderId}: ${errorMessage}`);
59129
+ throw enrichAlpacaError(new Error(`Failed to replace order ${orderId}: ${errorMessage}`), error);
58563
59130
  }
58564
59131
  }
58565
59132
  /**
@@ -58648,7 +59215,7 @@ async function getOrderByClientId(client, clientOrderId) {
58648
59215
  log$6(`Failed to fetch order by client_order_id ${clientOrderId}: ${errorMessage}`, {
58649
59216
  type: "error",
58650
59217
  });
58651
- throw new Error(`Failed to fetch order by client_order_id ${clientOrderId}: ${errorMessage}`);
59218
+ throw enrichAlpacaError(new Error(`Failed to fetch order by client_order_id ${clientOrderId}: ${errorMessage}`), error);
58652
59219
  }
58653
59220
  }
58654
59221
 
@@ -64849,6 +65416,15 @@ class AssetAllocationEngine {
64849
65416
  * Assess current market condition
64850
65417
  */
64851
65418
  assessMarketCondition(metrics) {
65419
+ // Crisis detection runs first: it is the strictly more severe reading and
65420
+ // its volatility threshold sits above the high-volatility one, so testing
65421
+ // volatility first would classify every crisis-level VIX as merely high
65422
+ // and never reach this branch at all.
65423
+ if (metrics.volatilityIndex > 40 ||
65424
+ metrics.sentimentScore < 20 ||
65425
+ metrics.creditSpread > 500) {
65426
+ return "CRISIS";
65427
+ }
64852
65428
  // High volatility check
64853
65429
  if (metrics.volatilityIndex > 30) {
64854
65430
  return "HIGH_VOLATILITY";
@@ -64857,12 +65433,6 @@ class AssetAllocationEngine {
64857
65433
  if (metrics.volatilityIndex < 12) {
64858
65434
  return "LOW_VOLATILITY";
64859
65435
  }
64860
- // Crisis detection
64861
- if (metrics.volatilityIndex > 40 ||
64862
- metrics.sentimentScore < 20 ||
64863
- metrics.creditSpread > 500) {
64864
- return "CRISIS";
64865
- }
64866
65436
  // Bull market
64867
65437
  if (metrics.trendDirection === "UP" &&
64868
65438
  metrics.marketStrength > 60 &&
@@ -71158,6 +71728,14 @@ const DEFAULT_TRADING_POLICY = EffectiveTradingPolicySchema.parse({
71158
71728
  optionsEnabled: true,
71159
71729
  futuresEnabled: true,
71160
71730
  forexEnabled: true,
71731
+ // Shorting and margin are capability opt-ins, not a directional stance.
71732
+ // Both require a margin agreement and locate/borrow availability the
71733
+ // package cannot verify, so an account that has not asserted the
71734
+ // capability defaults to the one it is known to have. This is a statement
71735
+ // about account permissions, never a preference for long over short — the
71736
+ // side a strategy takes is derived from live data once the capability is
71737
+ // enabled. Resolve these from the broker account's actual margin and
71738
+ // shorting entitlements wherever those are available.
71161
71739
  shortingEnabled: false,
71162
71740
  marginEnabled: false,
71163
71741
  fractionalSharesEnabled: true,
@@ -71460,5 +72038,5 @@ const adaptic = {
71460
72038
  };
71461
72039
  const adptc = adaptic;
71462
72040
 
71463
- export { API_RETRY_CONFIGS, AVNewsArticleSchema, AVNewsResponseSchema, AdapticUtilsError, AlpacaAccountDetailsSchema, AlpacaApiError, AlpacaBarSchema, AlpacaClient, AlpacaCryptoBarsResponseSchema, AlpacaHistoricalBarsResponseSchema, AlpacaLatestBarsResponseSchema, AlpacaLatestQuotesResponseSchema, AlpacaLatestTradesResponseSchema, AlpacaMarketDataAPI, AlpacaNewsArticleSchema, AlpacaNewsResponseSchema, AlpacaOrderSchema, AlpacaOrdersArraySchema, AlpacaPortfolioHistoryResponseSchema, AlpacaPositionSchema, AlpacaPositionsArraySchema, AlpacaQuoteSchema, AlpacaTradeSchema, AlpacaTradingAPI, AlphaVantageError, AlphaVantageQuoteResponseSchema, AssetAllocationEngine, AuthenticationError, AutonomyMode, BTC_PAIRS, BarError, CircuitOpenError, CryptoDataError, CryptoOrderError, DEFAULT_CACHE_OPTIONS, DEFAULT_RISK_FREE_RATE, DEFAULT_TIMEOUTS, DEFAULT_TRADING_POLICY, DataFormatError, DecisionMemoryOutcome, DecisionOutcome, DecisionRecordStatus, DuplicateClientOrderIdError, HttpClientError, HttpServerError, KEEP_ALIVE_DEFAULTS, LlmProvider, MARKET_DATA_API, MassiveAggregatesResponseSchema, MassiveApiError, MassiveDailyOpenCloseSchema, MassiveErrorResponseSchema, MassiveGroupedDailyResponseSchema, MassiveLastTradeResponseSchema, MassiveTickerDetailsResponseSchema, MassiveTickerInfoSchema, MassiveTradeSchema as MassiveTradeZodSchema, MassiveTradesResponseSchema, NetworkError, NewsError, OptionStrategyError, OptionsDataError, OverlaySeverity, OverlayStatus, OverlayType, QuoteError, RISK_FREE_RATE_TTL_MS, RateLimitError, RawMassivePriceDataSchema, StampedeProtectedCache, TRADING_API, TimeoutError, TokenBucketRateLimiter, TradeError, TrailingStopValidationError, USDC_PAIRS, USDT_PAIRS, USD_PAIRS, UnsupportedBrokerError, ValidationError, ValidationResponseError, WEBSOCKET_STREAMS, WebSocketError, account, adaptic, adptc, alpaca, analyzeBars, approximateImpliedVolatility, atrNs as atr, bracketOrders, buildOCCSymbol, buildOptionSymbol, buyCryptoNotional, buyToClose, buyToOpen, buyWithStopLoss, buyWithTrailingStop, calculateMoneyness, calculateOrderValue, calculatePeriodPerformance, calculatePutCallRatio, calculateTotalFilledValue, cancelAllCryptoOrders, cancelOCOOrder, cancelOTOOrder, cancelTrailingStop, cancelTrailingStopsForSymbol, checkTradingEligibility, clearClientCache, clock, closeAllOptionPositions, closeOptionPosition, createAlpacaClient, createAlpacaMarketDataAPI, createAlpacaTradingAPI, createBracketOrder, createBrokerClient, createButterflySpread, createClientFromEnv, createCoveredCall, createCryptoLimitOrder, createCryptoMarketOrder, createCryptoOrder, createCryptoStopLimitOrder, createCryptoStopOrder, createExecutorFromTradingAPI, createIronCondor$1 as createIronCondor, createIronCondor as createIronCondorAdvanced, createMultiLegOptionOrder, createOCOOrder, createOTOOrder, createOptionOrder, createPortfolioTrailingStops, createProtectiveBracket, createStampedeProtectedCache, createStraddle$1 as createStraddle, createStraddle as createStraddleAdvanced, createStrangle$1 as createStrangle, createStrangle as createStrangleAdvanced, createStreamManager, createTimeoutSignal, createTrailingStop, createVerticalSpread$1 as createVerticalSpread, createVerticalSpread as createVerticalSpreadAdvanced, entryWithPercentStopLoss, exerciseOption, extractGreeks, filterByExpiration, filterByStrike, filterByType, filterOrdersByDateRange, findATMOptions, findATMStrikes, findNearestExpiration, findOptionsByDelta, formatOrderForLog, formatOrderSummary, generateOptimalAllocation, getAccountConfiguration, getAccountDetails, getAccountSummary, getAgentPoolStatus, getAllOrders, getAlpacaCalendar, getAlpacaClock, getAverageDailyVolume, getBars, getBuyingPower, getCachedRiskFreeRateSync, getCachedRiskFreeRateSyncWithProvenance, getCrypto24HourChange, getCryptoBars, getCryptoDailyPrices, getCryptoPairsByQuote, getCryptoPrice, getCryptoSnapshots, getCryptoSpread, getCryptoStreamUrl, getCryptoTrades, getCurrentPrice, getCurrentPrices, getDailyPrices, getDailyReturns, getDaysToExpiration, getDefaultRiskProfile, getEquityCurve, getExpirationDates, getFilledOrders, getGroupedOptionChain, getHistoricalOptionsBars, getHistoricalTrades, getIntradayPrices, getLatestBars, getLatestCryptoQuotes, getLatestCryptoTrades, getLatestNews, getLatestOptionsQuotes, getLatestOptionsTrades, getLatestQuote, getLatestQuotes, getLatestTrade, getLatestTrades, getLogger, getMarginInfo, getNews, getNewsForSymbols, getOCOOrderStatus, getOTOOrderStatus, getOpenCryptoOrders, getOpenOrders$1 as getOpenOrdersQuery, getOpenTrailingStops, getOptionChain, getOptionContract, getOptionContracts, getOptionSpread, getOptionsChain, getOptionsSnapshots, getOptionsStreamUrl, getOptionsTradingLevel, getOrderHistory, getOrdersBySymbol, getPDTStatus, getPopularCryptoPairs, getPortfolioHistory, getPreviousClose, getPriceRange, getRiskFreeRate, getRiskFreeRateWithProvenance, getSpread, getSpreads, getStockStreamUrl, getStrikePrices, getSupportedCryptoPairs, getSymbolSentiment, getTimeout, getTradeVolume, getTradingApiUrl, getTradingWebSocketUrl, getTrailingStopHWM, groupOrdersByStatus, groupOrdersBySymbol, hasActiveTrailingStop, hasGoodLiquidity as hasOptionLiquidity, hasGoodLiquidity$1 as hasStockLiquidity, hasSufficientVolume, httpAgent, httpsAgent, isAlpacaBrokerCredentials, isContractTradable, isCryptoPair, isExpiringWithin, isMarginAccount, isOptionOrderCancelable, isOptionOrderTerminal, isOrderFillable, isOrderFilled, isOrderOpen, isOrderTerminal$1 as isOrderTerminalStatus, isSupportedCryptoPair, isTransientNetworkError, index$1 as legacyApi, limitBuyWithTakeProfit, ocoOrders, orderUtils, otoOrders, paginate, paginateAll, parseOCCSymbol, protectLongPosition, protectShortPosition, rateLimiters, resetLogger, resetRiskFreeRateCache, riskNs as risk, rollOptionPosition, roundPriceForAlpaca$3 as roundPriceForAlpaca, roundPriceForAlpacaNumber, safeValidateResponse, searchNews, sellAllCrypto, sellCryptoNotional, sellToClose, sellToOpen, setLogger, setRiskFreeRate, shortWithStopLoss, sortOrdersByDate, strategyNs as strategy, index as tradingPolicy, trailingStops, updateAccountConfiguration, updateTrailingStop, validateAlpacaCredentials, validateAlphaVantageApiKey, validateMassiveApiKey$1 as validateMassiveApiKey, validateMultiLegOrder, validateResponse, verifyFetchKeepAlive, volatilityNs as volatility, waitForOrderFill, withRetry, withTimeout };
72041
+ export { API_RETRY_CONFIGS, AVNewsArticleSchema, AVNewsResponseSchema, AdapticUtilsError, AlpacaAccountDetailsSchema, AlpacaApiError, AlpacaBarSchema, AlpacaClient, AlpacaCryptoBarsResponseSchema, AlpacaHistoricalBarsResponseSchema, AlpacaLatestBarsResponseSchema, AlpacaLatestQuotesResponseSchema, AlpacaLatestTradesResponseSchema, AlpacaMarketDataAPI, AlpacaNewsArticleSchema, AlpacaNewsResponseSchema, AlpacaOrderSchema, AlpacaOrdersArraySchema, AlpacaPortfolioHistoryResponseSchema, AlpacaPositionSchema, AlpacaPositionsArraySchema, AlpacaQuoteSchema, AlpacaTradeSchema, AlpacaTradingAPI, AlphaVantageError, AlphaVantageQuoteResponseSchema, AssetAllocationEngine, AuthenticationError, AutonomyMode, BTC_PAIRS, BarError, CircuitOpenError, CryptoDataError, CryptoOrderError, DEFAULT_CACHE_OPTIONS, DEFAULT_RISK_FREE_RATE, DEFAULT_TIMEOUTS, DEFAULT_TRADING_POLICY, DataFormatError, DecisionMemoryOutcome, DecisionOutcome, DecisionRecordStatus, DuplicateClientOrderIdError, HttpClientError, HttpServerError, KEEP_ALIVE_DEFAULTS, LlmProvider, MARKET_DATA_API, MassiveAggregatesResponseSchema, MassiveApiError, MassiveDailyOpenCloseSchema, MassiveErrorResponseSchema, MassiveGroupedDailyResponseSchema, MassiveLastTradeResponseSchema, MassiveTickerDetailsResponseSchema, MassiveTickerInfoSchema, MassiveTradeSchema as MassiveTradeZodSchema, MassiveTradesResponseSchema, NetworkError, NewsError, OptionStrategyError, OptionsDataError, OverlaySeverity, OverlayStatus, OverlayType, QuoteError, RISK_FREE_RATE_TTL_MS, RateLimitError, RawMassivePriceDataSchema, StampedeProtectedCache, TRADING_API, TimeoutError, TokenBucketRateLimiter, TradeError, TrailingStopValidationError, USDC_PAIRS, USDT_PAIRS, USD_PAIRS, UnsupportedBrokerError, ValidationError, ValidationResponseError, WEBSOCKET_STREAMS, WebSocketError, account, adaptic, adptc, alpaca, analyzeBars, approximateImpliedVolatility, atrNs as atr, bracketOrders, buildOCCSymbol, buildOptionSymbol, buyCryptoNotional, buyToClose, buyToOpen, buyWithStopLoss, buyWithTrailingStop, calculateMoneyness, calculateOrderValue, calculatePeriodPerformance, calculatePutCallRatio, calculateTotalFilledValue, cancelAllCryptoOrders, cancelOCOOrder, cancelOTOOrder, cancelTrailingStop, cancelTrailingStopsForSymbol, checkTradingEligibility, clearClientCache, clock, closeAllOptionPositions, closeOptionPosition, createAlpacaClient, createAlpacaMarketDataAPI, createAlpacaTradingAPI, createBracketOrder, createBrokerClient, createButterflySpread, createClientFromEnv, createCoveredCall, createCryptoLimitOrder, createCryptoMarketOrder, createCryptoOrder, createCryptoStopLimitOrder, createCryptoStopOrder, createExecutorFromTradingAPI, createIronCondor$1 as createIronCondor, createIronCondor as createIronCondorAdvanced, createMultiLegOptionOrder, createOCOOrder, createOTOOrder, createOptionOrder, createPortfolioTrailingStops, createProtectiveBracket, createStampedeProtectedCache, createStraddle$1 as createStraddle, createStraddle as createStraddleAdvanced, createStrangle$1 as createStrangle, createStrangle as createStrangleAdvanced, createStreamManager, createTimeoutSignal, createTrailingStop, createVerticalSpread$1 as createVerticalSpread, createVerticalSpread as createVerticalSpreadAdvanced, enrichAlpacaError, entryWithPercentStopLoss, exerciseOption, extractAlpacaBrokerError, extractGreeks, filterByExpiration, filterByStrike, filterByType, filterOrdersByDateRange, findATMOptions, findATMStrikes, findNearestExpiration, findOptionsByDelta, formatOrderForLog, formatOrderSummary, generateOptimalAllocation, getAccountConfiguration, getAccountDetails, getAccountSummary, getAgentPoolStatus, getAllOrders, getAlpacaBrokerErrorCode, getAlpacaBrokerErrorDetail, getAlpacaCalendar, getAlpacaClock, getAverageDailyVolume, getBars, getBuyingPower, getCachedRiskFreeRateSync, getCachedRiskFreeRateSyncWithProvenance, getCrypto24HourChange, getCryptoBars, getCryptoDailyPrices, getCryptoPairsByQuote, getCryptoPrice, getCryptoSnapshots, getCryptoSpread, getCryptoStreamUrl, getCryptoTrades, getCurrentPrice, getCurrentPrices, getDailyPrices, getDailyReturns, getDaysToExpiration, getDefaultRiskProfile, getEquityCurve, getExpirationDates, getFilledOrders, getGroupedOptionChain, getHistoricalOptionsBars, getHistoricalTrades, getIntradayPrices, getLatestBars, getLatestCryptoQuotes, getLatestCryptoTrades, getLatestNews, getLatestOptionsQuotes, getLatestOptionsTrades, getLatestQuote, getLatestQuotes, getLatestTrade, getLatestTrades, getLogger, getMarginInfo, getNews, getNewsForSymbols, getOCOOrderStatus, getOTOOrderStatus, getOpenCryptoOrders, getOpenOrders$1 as getOpenOrdersQuery, getOpenTrailingStops, getOptionChain, getOptionContract, getOptionContracts, getOptionSpread, getOptionsChain, getOptionsSnapshots, getOptionsStreamUrl, getOptionsTradingLevel, getOrderHistory, getOrdersBySymbol, getPDTStatus, getPopularCryptoPairs, getPortfolioHistory, getPreviousClose, getPriceRange, getRiskFreeRate, getRiskFreeRateWithProvenance, getSpread, getSpreads, getStockStreamUrl, getStrikePrices, getSupportedCryptoPairs, getSymbolSentiment, getTimeout, getTradeVolume, getTradingApiUrl, getTradingWebSocketUrl, getTrailingStopHWM, groupOrdersByStatus, groupOrdersBySymbol, hasActiveTrailingStop, hasGoodLiquidity as hasOptionLiquidity, hasGoodLiquidity$1 as hasStockLiquidity, hasSufficientVolume, httpAgent, httpsAgent, isAlpacaBrokerCredentials, isContractTradable, isCryptoPair, isExpiringWithin, isMarginAccount, isOptionOrderCancelable, isOptionOrderTerminal, isOrderFillable, isOrderFilled, isOrderOpen, isOrderTerminal$1 as isOrderTerminalStatus, isSupportedCryptoPair, isTransientNetworkError, index$1 as legacyApi, limitBuyWithTakeProfit, ocoOrders, orderUtils, otoOrders, paginate, paginateAll, parseOCCSymbol, protectLongPosition, protectShortPosition, rateLimiters, resetLogger, resetRiskFreeRateCache, riskNs as risk, rollOptionPosition, roundPriceForAlpaca$3 as roundPriceForAlpaca, roundPriceForAlpacaNumber, safeValidateResponse, searchNews, sellAllCrypto, sellCryptoNotional, sellToClose, sellToOpen, setLogger, setRiskFreeRate, shortWithStopLoss, sortOrdersByDate, strategyNs as strategy, index as tradingPolicy, trailingStops, updateAccountConfiguration, updateTrailingStop, validateAlpacaCredentials, validateAlphaVantageApiKey, validateMassiveApiKey$1 as validateMassiveApiKey, validateMultiLegOrder, validateResponse, verifyFetchKeepAlive, volatilityNs as volatility, waitForOrderFill, withRetry, withTimeout };
71464
72042
  //# sourceMappingURL=index.mjs.map