dexbot 1.4.9 → 1.4.11

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 (120) hide show
  1. package/README.md +3 -3
  2. package/dist/dexbot.d.ts.map +1 -1
  3. package/dist/dexbot.js +43 -9
  4. package/dist/dexbot.js.map +1 -1
  5. package/dist/market_adapter/core/market_adapter_service.d.ts +2 -0
  6. package/dist/market_adapter/core/market_adapter_service.d.ts.map +1 -1
  7. package/dist/market_adapter/core/market_adapter_service.js +9 -0
  8. package/dist/market_adapter/core/market_adapter_service.js.map +1 -1
  9. package/dist/market_adapter/market_adapter.d.ts.map +1 -1
  10. package/dist/market_adapter/market_adapter.js +6 -1
  11. package/dist/market_adapter/market_adapter.js.map +1 -1
  12. package/dist/modules/broadcast_failure.d.ts +0 -1
  13. package/dist/modules/broadcast_failure.d.ts.map +1 -1
  14. package/dist/modules/broadcast_failure.js +0 -1
  15. package/dist/modules/broadcast_failure.js.map +1 -1
  16. package/dist/modules/chain_keys.d.ts.map +1 -1
  17. package/dist/modules/chain_keys.js +19 -1
  18. package/dist/modules/chain_keys.js.map +1 -1
  19. package/dist/modules/constants.d.ts +2 -5
  20. package/dist/modules/constants.d.ts.map +1 -1
  21. package/dist/modules/constants.js +8 -5
  22. package/dist/modules/constants.js.map +1 -1
  23. package/dist/modules/credential_policy.d.ts.map +1 -1
  24. package/dist/modules/credential_policy.js +135 -364
  25. package/dist/modules/credential_policy.js.map +1 -1
  26. package/dist/modules/daemon_node_health.d.ts +0 -1
  27. package/dist/modules/daemon_node_health.d.ts.map +1 -1
  28. package/dist/modules/daemon_node_health.js +0 -1
  29. package/dist/modules/daemon_node_health.js.map +1 -1
  30. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  31. package/dist/modules/dexbot_cow_runtime.js +7 -1
  32. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  33. package/dist/modules/dexbot_fill_runtime.d.ts +14 -5
  34. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  35. package/dist/modules/dexbot_fill_runtime.js +27 -27
  36. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  37. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  38. package/dist/modules/dexbot_maintenance_runtime.js +32 -14
  39. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  40. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  41. package/dist/modules/dexbot_startup_runtime.js +4 -11
  42. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  43. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  44. package/dist/modules/dexbot_state_recovery.js +7 -7
  45. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  46. package/dist/modules/key_store.d.ts.map +1 -1
  47. package/dist/modules/key_store.js +42 -60
  48. package/dist/modules/key_store.js.map +1 -1
  49. package/dist/modules/launcher/bot_supervisor.d.ts +2 -2
  50. package/dist/modules/launcher/bot_supervisor.d.ts.map +1 -1
  51. package/dist/modules/launcher/bot_supervisor.js +6 -26
  52. package/dist/modules/launcher/bot_supervisor.js.map +1 -1
  53. package/dist/modules/launcher/foreign_cred_daemon.d.ts +17 -0
  54. package/dist/modules/launcher/foreign_cred_daemon.d.ts.map +1 -1
  55. package/dist/modules/launcher/foreign_cred_daemon.js +5 -14
  56. package/dist/modules/launcher/foreign_cred_daemon.js.map +1 -1
  57. package/dist/modules/launcher/monolithic_runtime.d.ts +2 -1
  58. package/dist/modules/launcher/monolithic_runtime.d.ts.map +1 -1
  59. package/dist/modules/launcher/monolithic_runtime.js +34 -16
  60. package/dist/modules/launcher/monolithic_runtime.js.map +1 -1
  61. package/dist/modules/launcher/status_reporting.d.ts.map +1 -1
  62. package/dist/modules/launcher/status_reporting.js +1 -11
  63. package/dist/modules/launcher/status_reporting.js.map +1 -1
  64. package/dist/modules/node_failure_ledger.d.ts +0 -1
  65. package/dist/modules/node_failure_ledger.d.ts.map +1 -1
  66. package/dist/modules/node_failure_ledger.js +0 -1
  67. package/dist/modules/node_failure_ledger.js.map +1 -1
  68. package/dist/modules/order/accounting.d.ts.map +1 -1
  69. package/dist/modules/order/accounting.js +5 -1
  70. package/dist/modules/order/accounting.js.map +1 -1
  71. package/dist/modules/order/grid.d.ts +5 -5
  72. package/dist/modules/order/grid.d.ts.map +1 -1
  73. package/dist/modules/order/grid.js +236 -27
  74. package/dist/modules/order/grid.js.map +1 -1
  75. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  76. package/dist/modules/order/grid_reconcile_internal.js +147 -35
  77. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  78. package/dist/modules/order/logger.d.ts +0 -8
  79. package/dist/modules/order/logger.d.ts.map +1 -1
  80. package/dist/modules/order/logger.js +1 -73
  81. package/dist/modules/order/logger.js.map +1 -1
  82. package/dist/modules/order/manager.d.ts +1 -80
  83. package/dist/modules/order/manager.d.ts.map +1 -1
  84. package/dist/modules/order/manager.js +3 -116
  85. package/dist/modules/order/manager.js.map +1 -1
  86. package/dist/modules/order/strategy.js +2 -2
  87. package/dist/modules/order/strategy.js.map +1 -1
  88. package/dist/modules/order/sync_engine.js +4 -4
  89. package/dist/modules/order/sync_engine.js.map +1 -1
  90. package/dist/modules/order/utils/math.d.ts +38 -1
  91. package/dist/modules/order/utils/math.d.ts.map +1 -1
  92. package/dist/modules/order/utils/math.js +60 -0
  93. package/dist/modules/order/utils/math.js.map +1 -1
  94. package/dist/modules/order/utils/order.d.ts +45 -1
  95. package/dist/modules/order/utils/order.d.ts.map +1 -1
  96. package/dist/modules/order/utils/order.js +84 -0
  97. package/dist/modules/order/utils/order.js.map +1 -1
  98. package/dist/modules/order/utils/system.d.ts.map +1 -1
  99. package/dist/modules/order/utils/system.js +5 -2
  100. package/dist/modules/order/utils/system.js.map +1 -1
  101. package/dist/modules/process_discovery.d.ts +1 -1
  102. package/dist/modules/process_discovery.d.ts.map +1 -1
  103. package/dist/modules/process_discovery.js +17 -17
  104. package/dist/modules/process_discovery.js.map +1 -1
  105. package/dist/modules/socket_json_client.d.ts +0 -1
  106. package/dist/modules/socket_json_client.d.ts.map +1 -1
  107. package/dist/modules/socket_json_client.js +0 -1
  108. package/dist/modules/socket_json_client.js.map +1 -1
  109. package/dist/modules/types.d.ts +0 -600
  110. package/dist/modules/types.d.ts.map +1 -1
  111. package/dist/modules/types.js +15 -0
  112. package/dist/modules/types.js.map +1 -1
  113. package/dist/scripts/run-tests.js +185 -18
  114. package/dist/scripts/run-tests.js.map +1 -1
  115. package/dist/scripts/update.js +27 -17
  116. package/dist/scripts/update.js.map +1 -1
  117. package/dist/unlock.d.ts.map +1 -1
  118. package/dist/unlock.js +170 -86
  119. package/dist/unlock.js.map +1 -1
  120. package/package.json +1 -1
@@ -385,9 +385,13 @@ function createOrderGrid(config) {
385
385
  if (!Number.isFinite(incrementPercent)) {
386
386
  throw new Error(`Invalid incrementPercent: ${incrementPercent}. Must be a finite number.`);
387
387
  }
388
- const minPercent = config.incrementBounds?.MIN_PERCENT;
389
- const maxPercent = config.incrementBounds?.MAX_PERCENT;
390
- if (incrementPercent < minPercent || incrementPercent > maxPercent) {
388
+ // Fall back to the canonical INCREMENT_BOUNDS when the config omits
389
+ // incrementBounds. Without this, a non-positive incrementPercent (e.g. 0)
390
+ // silently passes validation and the geometric loop below spins forever.
391
+ const incrementBounds = config.incrementBounds || constants_1.INCREMENT_BOUNDS;
392
+ const minPercent = incrementBounds.MIN_PERCENT;
393
+ const maxPercent = incrementBounds.MAX_PERCENT;
394
+ if (incrementPercent <= 0 || incrementPercent < minPercent || incrementPercent > maxPercent) {
391
395
  throw new Error(`Invalid incrementPercent: ${incrementPercent}. Must be between ` +
392
396
  `${minPercent} and ${maxPercent} (inclusive).`);
393
397
  }
@@ -544,6 +548,25 @@ async function loadGrid(manager, grid, boundaryIdx = null) {
544
548
  : (i >= sellStartIdx)
545
549
  ? constants_1.ORDER_TYPES.SELL
546
550
  : constants_1.ORDER_TYPES.SPREAD;
551
+ // DEFENSIVE BACKSTOP: a VIRTUAL slot with no orderId and
552
+ // zero size is side-neutral — it is a reusable placeholder
553
+ // that may be activated on either rail. Storing a stale
554
+ // BUY/SELL type here misleads candidate-selection code
555
+ // (e.g. spread-correction orphaned filters and reconcile
556
+ // activation), which pick by stored type instead of boundary
557
+ // geometry. Force SPREAD so the stored type can never
558
+ // pre-bias which side reuses the slot. The VIRTUAL +
559
+ // !orderId + size-0 combination already implies not on-chain
560
+ // (on-chain requires ACTIVE/PARTIAL and an orderId). This is
561
+ // a defensive backstop for legacy persisted grids; the
562
+ // boundary-shift and strategy re-plan paths use
563
+ // assignGridRoles (order.ts) with assignOnChain, where
564
+ // geometry-based typing wins.
565
+ if ((0, order_1.isEmptyGridSlot)(slot, slot, { allowNullType: true })) {
566
+ if (slot.type !== constants_1.ORDER_TYPES.SPREAD)
567
+ reassignCount++;
568
+ return { ...slot, type: constants_1.ORDER_TYPES.SPREAD };
569
+ }
547
570
  if (slot.type !== correctType) {
548
571
  let newType = correctType;
549
572
  // On-chain slots must never be reassigned to SPREAD:
@@ -606,8 +629,20 @@ async function loadGrid(manager, grid, boundaryIdx = null) {
606
629
  }
607
630
  await manager._applyOrderUpdate(currentOrder, 'grid-load', { skipAccounting: true });
608
631
  }
609
- const spreadCount = grid.filter((o) => o.type === constants_1.ORDER_TYPES.SPREAD).length;
610
- manager.targetSpreadCount = spreadCount;
632
+ // Gap-band occupancy for the spread metric. Empty slots are
633
+ // normalized to SPREAD (side-neutral) above, so a raw
634
+ // `type === SPREAD` count would include every empty slot on both
635
+ // rails. countGapBandSpread requires both SPREAD type and band
636
+ // geometry (target = gapSlots).
637
+ // Index resolution: prefer the parsed slot id (price-monotonic
638
+ // at creation) so the count is order-independent, matching the
639
+ // accountant (accounting.ts). Fall back to array position only
640
+ // for ids that are not grid slot ids.
641
+ const spreadCount = (0, math_1.countGapBandSpread)(manager, grid, (o, i) => {
642
+ const idx = (0, order_1.parseSlotIndex)(o?.id);
643
+ return idx === null ? i : idx;
644
+ });
645
+ manager.initialSpreadCount = spreadCount;
611
646
  manager.currentSpreadCount = spreadCount;
612
647
  }
613
648
  finally {
@@ -617,12 +652,21 @@ async function loadGrid(manager, grid, boundaryIdx = null) {
617
652
  manager.logger?.log?.(`Loaded ${manager.orders.size} orders from persisted grid.`, 'info');
618
653
  });
619
654
  }
655
+ function resolveMinScaleSlots(primary, secondary) {
656
+ const p = Number(primary);
657
+ if (Number.isFinite(p))
658
+ return Math.max(0, Math.floor(p));
659
+ const s = Number(secondary);
660
+ if (Number.isFinite(s))
661
+ return Math.max(0, Math.floor(s));
662
+ return constants_1.MARKET_ADAPTER.ASYMMETRIC_BOUNDS_MIN_SCALE_SLOTS;
663
+ }
620
664
  /**
621
- * Initialize the order grid with blockchain-aware sizing.
622
- * @param {import('./types').OrderManager} manager - The manager instance.
623
- * @returns {Promise<void>}
624
- * @throws {Error} If initialization fails or account totals are missing.
625
- */
665
+ * Initialize and orchestrate the order grid.
666
+ *
667
+ * @return {Promise<void>}
668
+ * @throws {Error} If initialization fails or account totals are missing.
669
+ */
626
670
  async function initializeGrid(manager) {
627
671
  if (!manager)
628
672
  throw new Error('initializeGrid requires a manager instance');
@@ -723,6 +767,8 @@ async function initializeGrid(manager) {
723
767
  let resolvedMinP = minP;
724
768
  let resolvedMaxP = maxP;
725
769
  let rangeScalingFactor = null;
770
+ let appliedTrend = null;
771
+ let minScaleSlots = null;
726
772
  if (gpSource === 'ama' && Number.isFinite(minP) && Number.isFinite(maxP)
727
773
  && isGridRangeScalingWhitelisted) {
728
774
  const dw = amaSnapshot?.dynamicWeights;
@@ -747,6 +793,8 @@ async function initializeGrid(manager) {
747
793
  resolvedMinP = adjustment.resolvedMinPrice;
748
794
  resolvedMaxP = adjustment.resolvedMaxPrice;
749
795
  rangeScalingFactor = Number(adjustment.appliedAsymmetryFactor);
796
+ appliedTrend = (dw?.trend === 'UP' || dw?.trend === 'DOWN') ? dw.trend : null;
797
+ minScaleSlots = resolveMinScaleSlots(manager.config.asymmetricBounds?.minScaleSlots, dw?.minScaleSlots);
750
798
  manager.logger?.log?.(`[BOUND-ASYMMETRY] trend=${dw.trend} slopeOffset=${dw.slopeOffset.toFixed(4)} `
751
799
  + `raw=${((adjustment.rawAsymmetryFactor ?? 0) * 100).toFixed(1)}% `
752
800
  + `cap=${((maxAsymmetryFactor ?? 0) * 100).toFixed(0)}% `
@@ -768,6 +816,8 @@ async function initializeGrid(manager) {
768
816
  resolvedMaxP = gp * ((maxP / gp) * (1 + asymmetry));
769
817
  }
770
818
  rangeScalingFactor = asymmetry;
819
+ appliedTrend = rootTrend;
820
+ minScaleSlots = resolveMinScaleSlots(manager.config.asymmetricBounds?.minScaleSlots, rootBounds.minScaleSlots);
771
821
  manager.logger?.log?.(`[BOUND-ASYMMETRY] trend=${rootTrend} `
772
822
  + `asymmetry=${(asymmetry * 100).toFixed(1)}% `
773
823
  + `(root-level) min ${(minP ?? 0).toFixed(8)}→${(resolvedMinP ?? 0).toFixed(8)} `
@@ -796,6 +846,35 @@ async function initializeGrid(manager) {
796
846
  gridStartPrice = clamped;
797
847
  }
798
848
  }
849
+ // Narrowing-side slot guard: range scaling tightens one bound toward the
850
+ // center. Without a floor this can collapse that side into a near-center
851
+ // sliver holding few or zero active orders. Guarantee at least
852
+ // minScaleSlots price levels remain between the grid center and the
853
+ // tightened bound (in multiples of incrementPercent). The widened side
854
+ // still extends freely.
855
+ if (appliedTrend && Number.isFinite(gridStartPrice) && gridStartPrice > 0) {
856
+ const inc = Number(manager.config.incrementPercent);
857
+ const mss = Number.isFinite(minScaleSlots) ? Math.floor(Number(minScaleSlots)) : 0;
858
+ if (Number.isFinite(inc) && inc > 0 && mss > 0) {
859
+ const stepMult = 1 + (inc / 100);
860
+ if (appliedTrend === 'DOWN' && resolvedMaxP != null) {
861
+ const keepAbove = gridStartPrice * Math.pow(stepMult, mss);
862
+ if (resolvedMaxP < keepAbove) {
863
+ manager.logger?.log?.(`[BOUND-ASYMMETRY] narrowing-side guard: max ${resolvedMaxP.toFixed(8)} collapses ` +
864
+ `${mss} levels; holding at ${keepAbove.toFixed(8)}`, 'info');
865
+ resolvedMaxP = keepAbove;
866
+ }
867
+ }
868
+ else if (appliedTrend === 'UP' && resolvedMinP != null) {
869
+ const belowMin = gridStartPrice * Math.pow(1 - (inc / 100), mss);
870
+ if (resolvedMinP > belowMin) {
871
+ manager.logger?.log?.(`[BOUND-ASYMMETRY] narrowing-side guard would pull min ${resolvedMinP.toFixed(8)} ` +
872
+ `short of ${mss} levels; holding at ${belowMin.toFixed(8)}`, 'info');
873
+ resolvedMinP = belowMin;
874
+ }
875
+ }
876
+ }
877
+ }
799
878
  manager.config.minPrice = resolvedMinP;
800
879
  manager.config.maxPrice = resolvedMaxP;
801
880
  manager._lastGridPricingContext = {
@@ -882,9 +961,14 @@ async function initializeGrid(manager) {
882
961
  await manager.resumeFundRecalc();
883
962
  manager.resumeRecalcLogging();
884
963
  }
885
- // RC-6: Spread count updates protected by grid lock
886
- manager.targetSpreadCount = initialSpreadCount.buy + initialSpreadCount.sell;
887
- manager.currentSpreadCount = manager.targetSpreadCount;
964
+ // RC-6: Spread count updates protected by grid lock.
965
+ // initializeGrid always sets initialSpreadCount = gapSlots (theoretical
966
+ // value for a fresh grid). loadGrid uses the actual SPREAD-typed slot
967
+ // count in the gap band, which may be lower after promotions absorbed
968
+ // gap slots into a rail. Both are correct for their context; callers
969
+ // should not assume the two paths agree.
970
+ manager.initialSpreadCount = initialSpreadCount.buy + initialSpreadCount.sell;
971
+ manager.currentSpreadCount = manager.initialSpreadCount;
888
972
  });
889
973
  // FIX: Use consistent optional chaining pattern for all logger calls
890
974
  manager.logger?.log?.(`Initialized grid with ${orders.length} orders.`, 'info');
@@ -1113,6 +1197,19 @@ async function _recalculateGridOrderSizesFromBlockchain(manager, orderType, opti
1113
1197
  // correct side's size calculation — using manager.orders here would
1114
1198
  // filter by stale pre-shift types and miss the crossers, producing a
1115
1199
  // budget allocation that doesn't match the post-shift grid structure.
1200
+ //
1201
+ // Empty (size-0 VIRTUAL) slots are stored SPREAD (side-neutral) after
1202
+ // normalization and are deliberately NOT included in the side's
1203
+ // ideal-size denominator here: a SPREAD slot cannot carry a size
1204
+ // (validateOrder forces SPREAD size back to 0), so including it would
1205
+ // only dilute the budget without giving the slot a usable size.
1206
+ // Activation sizing for empties is handled where re-typing happens:
1207
+ // spread-correction (prepareSpreadCorrectionOrders) and startup
1208
+ // reconcile (_deriveBudgetedSideSizes) both compute the full in-rail
1209
+ // geometric progression including empties, then re-type the picked slot
1210
+ // to BUY/SELL before placement. The COW boundary-shift path re-types
1211
+ // the working grid by geometry first, so crossers stay in the correct
1212
+ // side's denominator.
1116
1213
  const orderSource = collectActions ? workingGrid : manager.orders;
1117
1214
  const allSideSlots = Array.from(orderSource.values())
1118
1215
  .filter((o) => o.type === orderType)
@@ -1903,6 +2000,36 @@ function determineOrderSideByFunds(manager, currentMarketPrice) {
1903
2000
  }
1904
2001
  return { side, reason: side ? `Choosing ${side}` : 'Insufficient funds or committed inventory' };
1905
2002
  }
2003
+ // Collect contiguous empty gap-band slots adjacent to a rail edge for boundary
2004
+ // promotion. BUY walks upward from the boundary into the gap, SELL walks
2005
+ // downward from the sell start; both stop at the first unavailable slot.
2006
+ // Mirrors are collapsed into one directional walk (step +/- 1).
2007
+ //
2008
+ // NOTE: the promotion cap is the OPPOSITE RAIL's span, NOT the gap-band size.
2009
+ // SELL promotion moves the boundary down by the promoted count (boundary =
2010
+ // buyEndIdx - count), so capping at buyEndIdx keeps it >= 0. BUY promotion
2011
+ // moves the boundary up; capping at the SELL rail size (maxIdx+1-sellStartIdx)
2012
+ // keeps the promoted boundary at or below the last placed slot. The walk's
2013
+ // own idx < sellStartIdx / idx > buyEndIdx bounds already limit promotion to
2014
+ // the band, so this cap only binds when the opposite rail is smaller than the
2015
+ // band — i.e. an empty opposite rail (cap 0) blocks promotion entirely.
2016
+ function _collectPromotableBoundarySlots(allSlotsByPrice, railType, buyEndIdx, sellStartIdx, quota) {
2017
+ const isBuy = railType === constants_1.ORDER_TYPES.BUY;
2018
+ const maxIdx = allSlotsByPrice.length - 1;
2019
+ const maxPromotable = isBuy
2020
+ ? Math.max(0, maxIdx + 1 - sellStartIdx)
2021
+ : Math.max(0, buyEndIdx);
2022
+ const promotionQuota = Math.min(quota, maxPromotable);
2023
+ const promoted = [];
2024
+ const step = isBuy ? 1 : -1;
2025
+ for (let idx = isBuy ? buyEndIdx + 1 : sellStartIdx - 1; promoted.length < promotionQuota && (isBuy ? idx < sellStartIdx : idx > buyEndIdx); idx += step) {
2026
+ const slot = allSlotsByPrice[idx];
2027
+ if (!slot || !(0, order_1.isSlotAvailable)(slot))
2028
+ break;
2029
+ promoted.push(slot);
2030
+ }
2031
+ return promoted;
2032
+ }
1906
2033
  /**
1907
2034
  * Prepares one or more orders to correct a wide spread.
1908
2035
  * @param {import('./types').OrderManager} manager - The OrderManager instance.
@@ -1941,15 +2068,22 @@ async function prepareSpreadCorrectionOrders(manager, preferredSide, outOfSpread
1941
2068
  .filter((o) => o.price != null && Number.isFinite(o.price))
1942
2069
  .sort((a, b) => a.price - b.price);
1943
2070
  const slotIndexMap = new Map(allSlotsByPrice.map((o, i) => [o.id, i]));
1944
- const gapSlots = manager._gapSlots ?? calculateGapSlots(manager.config?.incrementPercent, manager.config?.targetSpreadPercent, manager.config?.gridLimits);
1945
2071
  // Use the committed boundary for slot classification — never a speculative
1946
2072
  // value from syncBoundaryToFunds that hasn't been persisted through the COW
1947
2073
  // pipeline. If the boundary shifts later via _commitWorkingGrid, the next
1948
2074
  // spread correction cycle will re-classify with the updated committed value.
1949
2075
  // This prevents TOCTOU-style inconsistency where slot types are chosen
1950
2076
  // against a boundary that was never atomically committed to manager.orders.
1951
- const buyEndIdx = manager.boundaryIdx ?? 0;
1952
- const sellStartIdx = (0, math_1.getSellStartIdx)(manager.boundaryIdx, gapSlots);
2077
+ const resolved = (0, math_1.resolveGapBand)(manager);
2078
+ const gapSlots = resolved.gapSlots;
2079
+ const boundaryKnown = resolved.boundaryIdx !== null && resolved.sellStartIdx !== null;
2080
+ // NOTE: `?? 0` keeps the legacy classification fallback (boundary not
2081
+ // restored yet → treat as bottom of the rail) so the correction can still
2082
+ // act on concrete BUY/SELL candidates. Boundary PROMOTION is gated on
2083
+ // `boundaryKnown` below: deriving a new boundary from a fabricated 0 would
2084
+ // silently commit a boundary that was never real.
2085
+ const buyEndIdx = resolved.boundaryIdx ?? 0;
2086
+ const sellStartIdx = resolved.sellStartIdx ?? (0, math_1.getSellStartIdx)(buyEndIdx, gapSlots);
1953
2087
  const getSlotCorrectType = (slot) => {
1954
2088
  const idx = slotIndexMap.get(slot.id);
1955
2089
  if (idx === undefined)
@@ -1979,30 +2113,68 @@ async function prepareSpreadCorrectionOrders(manager, preferredSide, outOfSpread
1979
2113
  && getSlotCorrectType(o) === railType)
1980
2114
  .sort((a, b) => railType === constants_1.ORDER_TYPES.BUY ? a.price - b.price : b.price - a.price)
1981
2115
  .slice(0, missingSlots);
1982
- // Secondary candidates: orphaned virtual slots of the correct side-type that have
1983
- // lost their order (e.g. stale-cleaned after a race condition during a crash).
1984
- // These sit inside the active window and are invisible to the SPREAD-type filter above.
2116
+ // Secondary candidates: orphaned virtual slots that have lost their
2117
+ // order (e.g. stale-cleaned after a race condition during a crash).
2118
+ // These sit inside the active window and are invisible to the
2119
+ // gap-band SPREAD filter above.
1985
2120
  //
1986
- // IMPORTANT: Filter by boundary-correct type so that filled-then-virtualized slots
1987
- // whose boundary position has moved into the spread or opposite zone are NOT
1988
- // re-activated on the stale side — doing so would compound inventory at prices
1989
- // where the bot already traded. The boundary-correct type is computed from the
1990
- // current boundary index and the slot's price position in the sorted rail.
2121
+ // Empty slots are stored SPREAD (side-neutral) after normalization, so
2122
+ // the stored type can be railType OR SPREAD — what matters is that the
2123
+ // boundary-correct type (geometry) matches the rail being corrected.
2124
+ // Filter by boundary-correct type so that filled-then-virtualized slots
2125
+ // whose boundary position has moved into the spread or opposite zone are
2126
+ // NOT re-activated on the stale side — doing so would compound inventory
2127
+ // at prices where the bot already traded.
1991
2128
  const orphanedVirtualCandidates = allOrders
1992
- .filter((o) => o.type === railType
2129
+ .filter((o) => (o.type === railType || o.type === constants_1.ORDER_TYPES.SPREAD)
1993
2130
  && o.state === constants_1.ORDER_STATES.VIRTUAL
1994
2131
  && !o.orderId
1995
2132
  && Number(o.size || 0) === 0
1996
2133
  && getSlotCorrectType(o) === railType)
1997
2134
  .sort((a, b) => railType === constants_1.ORDER_TYPES.BUY ? b.price - a.price : a.price - b.price)
1998
2135
  .slice(0, missingSlots);
2136
+ // If the funded rail is full, the spread itself may be stale: the
2137
+ // nearest empty slots are still in the gap band. Promote contiguous
2138
+ // empty gap slots from that rail edge so correction can repair the
2139
+ // boundary and place orders in one atomic COW commit.
2140
+ //
2141
+ // Requires a KNOWN committed boundary: with a null/unknown boundary the
2142
+ // rail edges are fabricated (buyEndIdx=0), so promotion would place
2143
+ // orders at arbitrary prices and return a boundary derived from that
2144
+ // fiction. Without promotion there is nothing to commit, so falling
2145
+ // back to the pre-existing candidate paths is safe.
2146
+ const promotedCandidates = [];
2147
+ if (boundaryKnown && orphanedVirtualCandidates.length + typedSpreadCandidates.length < missingSlots) {
2148
+ const rawQuota = missingSlots - orphanedVirtualCandidates.length - typedSpreadCandidates.length;
2149
+ promotedCandidates.push(..._collectPromotableBoundarySlots(allSlotsByPrice, railType, buyEndIdx, sellStartIdx, rawQuota));
2150
+ if (promotedCandidates.length > 0) {
2151
+ manager.logger?.log?.(`[SPREAD-CORRECTION] ${promotedCandidates.length} gap slot(s) available for boundary promotion on ${sideName}`, 'info');
2152
+ }
2153
+ }
1999
2154
  // Merge: prefer orphaned virtuals (they already occupy correct grid positions) then
2000
2155
  // fall back to SPREAD slots for any remaining quota.
2001
2156
  const remainingQuota = Math.max(0, missingSlots - orphanedVirtualCandidates.length);
2002
2157
  let spreadCandidates = [
2003
2158
  ...orphanedVirtualCandidates,
2004
- ...typedSpreadCandidates.slice(0, remainingQuota)
2159
+ ...typedSpreadCandidates.slice(0, remainingQuota),
2160
+ ...promotedCandidates
2005
2161
  ];
2162
+ // Dedupe by slot id: an empty in-rail slot typed SPREAD (normalized) now
2163
+ // qualifies for BOTH orphanedVirtualCandidates and typedSpreadCandidates
2164
+ // (both accept SPREAD type + rail geometry), so a single slot can be
2165
+ // planned twice. Duplicate CREATEs would inflate the sizing denominator
2166
+ // (diluting every order), mislead the plan counts, and get dropped by the
2167
+ // COW same-batch collision filter anyway. Keep the first occurrence
2168
+ // (orphaned-priority — those already occupy correct grid positions).
2169
+ {
2170
+ const seenSlotIds = new Set();
2171
+ spreadCandidates = spreadCandidates.filter((c) => {
2172
+ if (!c?.id || seenSlotIds.has(c.id))
2173
+ return false;
2174
+ seenSlotIds.add(c.id);
2175
+ return true;
2176
+ });
2177
+ }
2006
2178
  // P2: Filter out candidates whose price already has a placed order from any slot.
2007
2179
  // This prevents creating a duplicate order at the same price when a prior cycle's
2008
2180
  // order was not properly cleaned up (e.g. uncertain broadcast).
@@ -2135,6 +2307,43 @@ async function prepareSpreadCorrectionOrders(manager, preferredSide, outOfSpread
2135
2307
  });
2136
2308
  remainingBudget -= createSize;
2137
2309
  }
2310
+ let boundaryIdx;
2311
+ const placedPromotedIds = new Set(ordersToPlace
2312
+ .filter((order) => promotedCandidates.some((slot) => slot.id === order.id))
2313
+ .map((order) => order.id));
2314
+ if (placedPromotedIds.size > 0) {
2315
+ // Derive boundary from the placed promoted set's extremes (via
2316
+ // slotIndexMap), not the contiguous prefix. Any promoted-but-
2317
+ // unplaced slots sit in-rail or in-band as normal empty slots.
2318
+ let maxDist = 0;
2319
+ for (const id of placedPromotedIds) {
2320
+ const idx = slotIndexMap.get(id);
2321
+ if (idx === undefined)
2322
+ continue;
2323
+ const dist = railType === constants_1.ORDER_TYPES.BUY
2324
+ ? idx - buyEndIdx
2325
+ : sellStartIdx - idx;
2326
+ if (dist > maxDist)
2327
+ maxDist = dist;
2328
+ }
2329
+ if (maxDist > 0) {
2330
+ boundaryIdx = railType === constants_1.ORDER_TYPES.BUY
2331
+ ? buyEndIdx + maxDist
2332
+ : buyEndIdx - maxDist;
2333
+ const maxIdx = allSlotsByPrice.length - 1;
2334
+ const clamped = Math.max(0, Math.min(maxIdx, boundaryIdx));
2335
+ if (clamped !== boundaryIdx) {
2336
+ manager.logger?.log?.(`[SPREAD-CORRECTION] Boundary promotion clamped on ${sideName}: ` +
2337
+ `${boundaryIdx} -> ${clamped}`, 'warn');
2338
+ boundaryIdx = clamped;
2339
+ }
2340
+ manager.logger?.log?.(`[SPREAD-CORRECTION] Boundary promotion on ${sideName}: ` +
2341
+ `${buyEndIdx} -> ${boundaryIdx} (${placedPromotedIds.size} placed, maxDist ${maxDist})`, 'info');
2342
+ }
2343
+ else {
2344
+ boundaryIdx = undefined;
2345
+ }
2346
+ }
2138
2347
  const combinedUpdates = [...redistributionUpdates];
2139
2348
  for (const plannedUpdate of ordersToUpdate) {
2140
2349
  const id = plannedUpdate?.partialOrder?.id || plannedUpdate?.id;
@@ -2157,6 +2366,6 @@ async function prepareSpreadCorrectionOrders(manager, preferredSide, outOfSpread
2157
2366
  if (combinedUpdates.length > 0 || ordersToPlace.length > 0) {
2158
2367
  manager.logger?.log?.(`[SPREAD-CORRECTION] Prepared updates=${combinedUpdates.length}, creates=${ordersToPlace.length}, remainingBudget=${Format.formatSizeByOrderType(Math.max(0, remainingBudget), railType, manager.assets)}`, 'debug');
2159
2368
  }
2160
- return { ordersToPlace, ordersToUpdate: combinedUpdates };
2369
+ return { ordersToPlace, ordersToUpdate: combinedUpdates, ...(boundaryIdx === undefined ? {} : { boundaryIdx }) };
2161
2370
  }
2162
2371
  //# sourceMappingURL=grid.js.map