dexbot 1.1.14 → 1.2.1

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 (125) hide show
  1. package/README.md +1 -0
  2. package/dist/credential-daemon.d.ts.map +1 -1
  3. package/dist/credential-daemon.js +6 -3
  4. package/dist/credential-daemon.js.map +1 -1
  5. package/dist/market_adapter/market_adapter.js +1 -1
  6. package/dist/market_adapter/market_adapter.js.map +1 -1
  7. package/dist/modules/bitshares-native/lru_cache.d.ts +20 -0
  8. package/dist/modules/bitshares-native/lru_cache.d.ts.map +1 -0
  9. package/dist/modules/bitshares-native/lru_cache.js +45 -0
  10. package/dist/modules/bitshares-native/lru_cache.js.map +1 -0
  11. package/dist/modules/bitshares-native/resolvers.d.ts +2 -18
  12. package/dist/modules/bitshares-native/resolvers.d.ts.map +1 -1
  13. package/dist/modules/bitshares-native/resolvers.js +2 -41
  14. package/dist/modules/bitshares-native/resolvers.js.map +1 -1
  15. package/dist/modules/bitshares-native/serial/chain_constants.d.ts +1 -0
  16. package/dist/modules/bitshares-native/serial/chain_constants.d.ts.map +1 -1
  17. package/dist/modules/bitshares-native/serial/chain_constants.js +3 -0
  18. package/dist/modules/bitshares-native/serial/chain_constants.js.map +1 -1
  19. package/dist/modules/bitshares-native/signing_client.d.ts.map +1 -1
  20. package/dist/modules/bitshares-native/signing_client.js +12 -1
  21. package/dist/modules/bitshares-native/signing_client.js.map +1 -1
  22. package/dist/modules/bitshares-native/subscriptions.d.ts.map +1 -1
  23. package/dist/modules/bitshares-native/subscriptions.js +42 -3
  24. package/dist/modules/bitshares-native/subscriptions.js.map +1 -1
  25. package/dist/modules/bitshares-native/transport.d.ts.map +1 -1
  26. package/dist/modules/bitshares-native/transport.js +77 -27
  27. package/dist/modules/bitshares-native/transport.js.map +1 -1
  28. package/dist/modules/bitshares-native/tx/builder.d.ts.map +1 -1
  29. package/dist/modules/bitshares-native/tx/builder.js +26 -16
  30. package/dist/modules/bitshares-native/tx/builder.js.map +1 -1
  31. package/dist/modules/bitshares-native/tx/tx_cache.d.ts +17 -0
  32. package/dist/modules/bitshares-native/tx/tx_cache.d.ts.map +1 -0
  33. package/dist/modules/bitshares-native/tx/tx_cache.js +62 -0
  34. package/dist/modules/bitshares-native/tx/tx_cache.js.map +1 -0
  35. package/dist/modules/bot_settings.d.ts.map +1 -1
  36. package/dist/modules/bot_settings.js +2 -1
  37. package/dist/modules/bot_settings.js.map +1 -1
  38. package/dist/modules/chain_orders.d.ts +4 -1
  39. package/dist/modules/chain_orders.d.ts.map +1 -1
  40. package/dist/modules/chain_orders.js +30 -1
  41. package/dist/modules/chain_orders.js.map +1 -1
  42. package/dist/modules/config.d.ts +6 -0
  43. package/dist/modules/config.d.ts.map +1 -1
  44. package/dist/modules/config.js +11 -0
  45. package/dist/modules/config.js.map +1 -1
  46. package/dist/modules/constants.d.ts +14 -1
  47. package/dist/modules/constants.d.ts.map +1 -1
  48. package/dist/modules/constants.js +64 -6
  49. package/dist/modules/constants.js.map +1 -1
  50. package/dist/modules/credential_policy.d.ts.map +1 -1
  51. package/dist/modules/credential_policy.js +7 -5
  52. package/dist/modules/credential_policy.js.map +1 -1
  53. package/dist/modules/credit_runtime.d.ts.map +1 -1
  54. package/dist/modules/credit_runtime.js +40 -29
  55. package/dist/modules/credit_runtime.js.map +1 -1
  56. package/dist/modules/dexbot_class.d.ts +6 -12
  57. package/dist/modules/dexbot_class.d.ts.map +1 -1
  58. package/dist/modules/dexbot_class.js +155 -81
  59. package/dist/modules/dexbot_class.js.map +1 -1
  60. package/dist/modules/dexbot_maintenance_runtime.d.ts +1 -5
  61. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  62. package/dist/modules/dexbot_maintenance_runtime.js +23 -48
  63. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  64. package/dist/modules/launcher/child_env.d.ts.map +1 -1
  65. package/dist/modules/launcher/child_env.js +1 -0
  66. package/dist/modules/launcher/child_env.js.map +1 -1
  67. package/dist/modules/launcher/launch_modes.d.ts +2 -0
  68. package/dist/modules/launcher/launch_modes.d.ts.map +1 -1
  69. package/dist/modules/launcher/launch_modes.js +7 -6
  70. package/dist/modules/launcher/launch_modes.js.map +1 -1
  71. package/dist/modules/order/accounting.d.ts +8 -2
  72. package/dist/modules/order/accounting.d.ts.map +1 -1
  73. package/dist/modules/order/accounting.js +64 -26
  74. package/dist/modules/order/accounting.js.map +1 -1
  75. package/dist/modules/order/async_lock.d.ts +12 -4
  76. package/dist/modules/order/async_lock.d.ts.map +1 -1
  77. package/dist/modules/order/async_lock.js +27 -5
  78. package/dist/modules/order/async_lock.js.map +1 -1
  79. package/dist/modules/order/grid.d.ts +434 -475
  80. package/dist/modules/order/grid.d.ts.map +1 -1
  81. package/dist/modules/order/grid.js +1612 -1592
  82. package/dist/modules/order/grid.js.map +1 -1
  83. package/dist/modules/order/grid_reconcile.d.ts +3 -73
  84. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  85. package/dist/modules/order/grid_reconcile.js +11 -843
  86. package/dist/modules/order/grid_reconcile.js.map +1 -1
  87. package/dist/modules/order/grid_reconcile_internal.d.ts +8 -0
  88. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -0
  89. package/dist/modules/order/grid_reconcile_internal.js +782 -0
  90. package/dist/modules/order/grid_reconcile_internal.js.map +1 -0
  91. package/dist/modules/order/logger.d.ts +1 -1
  92. package/dist/modules/order/logger.d.ts.map +1 -1
  93. package/dist/modules/order/logger.js +26 -2
  94. package/dist/modules/order/logger.js.map +1 -1
  95. package/dist/modules/order/manager.d.ts +118 -15
  96. package/dist/modules/order/manager.d.ts.map +1 -1
  97. package/dist/modules/order/manager.js +206 -236
  98. package/dist/modules/order/manager.js.map +1 -1
  99. package/dist/modules/order/runner.js +2 -2
  100. package/dist/modules/order/runner.js.map +1 -1
  101. package/dist/modules/order/strategy.js +3 -3
  102. package/dist/modules/order/strategy.js.map +1 -1
  103. package/dist/modules/order/sync_engine.d.ts +3 -4
  104. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  105. package/dist/modules/order/sync_engine.js +85 -32
  106. package/dist/modules/order/sync_engine.js.map +1 -1
  107. package/dist/modules/order/utils/order.d.ts +17 -0
  108. package/dist/modules/order/utils/order.d.ts.map +1 -1
  109. package/dist/modules/order/utils/order.js +81 -24
  110. package/dist/modules/order/utils/order.js.map +1 -1
  111. package/dist/modules/order/utils/system.js +4 -4
  112. package/dist/modules/order/utils/system.js.map +1 -1
  113. package/dist/modules/order/utils/validate.d.ts.map +1 -1
  114. package/dist/modules/order/utils/validate.js +6 -8
  115. package/dist/modules/order/utils/validate.js.map +1 -1
  116. package/dist/modules/types.d.ts +7 -12
  117. package/dist/modules/types.d.ts.map +1 -1
  118. package/dist/scripts/print_grid.js +3 -3
  119. package/dist/scripts/print_grid.js.map +1 -1
  120. package/dist/scripts/test-credit-renewal.js +1 -1
  121. package/dist/scripts/test-credit-renewal.js.map +1 -1
  122. package/dist/unlock.d.ts.map +1 -1
  123. package/dist/unlock.js +26 -12
  124. package/dist/unlock.js.map +1 -1
  125. package/package.json +1 -1
@@ -3,7 +3,7 @@
3
3
  * modules/order/grid.ts - Grid Engine
4
4
  *
5
5
  * Order grid creation, synchronization, and health management.
6
- * Exports a single Grid class with static methods for grid operations.
6
+ * Exports plain functions for grid operations.
7
7
  *
8
8
  * Manages the complete lifecycle of the order grid:
9
9
  * - Creates geometric price grids with configurable spacing (increments)
@@ -13,7 +13,7 @@
13
13
  * - Detects and flags out-of-spread conditions
14
14
  *
15
15
  * ===============================================================================
16
- * TABLE OF CONTENTS - Grid Class (25 static methods)
16
+ * TABLE OF CONTENTS - Grid Functions (28 exported functions)
17
17
  * ===============================================================================
18
18
  *
19
19
  * CONFIGURATION & CALCULATION (2 methods)
@@ -95,6 +95,31 @@
95
95
  *
96
96
  * ===============================================================================
97
97
  */
98
+ Object.defineProperty(exports, "__esModule", { value: true });
99
+ exports.calculateGapSlots = calculateGapSlots;
100
+ exports.isGridBloated = isGridBloated;
101
+ exports.isGridBloatGraceActive = isGridBloatGraceActive;
102
+ exports.clearGridBloatFlag = clearGridBloatFlag;
103
+ exports.getSizingContext = getSizingContext;
104
+ exports._getSizingContext = _getSizingContext;
105
+ exports.createOrderGrid = createOrderGrid;
106
+ exports.loadGrid = loadGrid;
107
+ exports.initializeGrid = initializeGrid;
108
+ exports.recalculateGrid = recalculateGrid;
109
+ exports.checkAndUpdateGridIfNeeded = checkAndUpdateGridIfNeeded;
110
+ exports._recalculateGridOrderSizesFromBlockchain = _recalculateGridOrderSizesFromBlockchain;
111
+ exports.updateGridFromBlockchainSnapshot = updateGridFromBlockchainSnapshot;
112
+ exports.compareGrids = compareGrids;
113
+ exports.monitorDivergence = monitorDivergence;
114
+ exports.calculateCurrentSpread = calculateCurrentSpread;
115
+ exports.checkSpreadCondition = checkSpreadCondition;
116
+ exports.checkGridHealth = checkGridHealth;
117
+ exports.checkWindowDust = checkWindowDust;
118
+ exports.hasAnyDust = hasAnyDust;
119
+ exports.getDustOrders = getDustOrders;
120
+ exports.determineOrderSideByFunds = determineOrderSideByFunds;
121
+ exports.calculateGeometricSizeForSpreadCorrection = calculateGeometricSizeForSpreadCorrection;
122
+ exports.prepareSpreadCorrectionOrders = prepareSpreadCorrectionOrders;
98
123
  const { ORDER_TYPES, ORDER_STATES, COW_ACTIONS, DEFAULT_CONFIG, GRID_LIMITS, TIMING, MARKET_ADAPTER } = require('../constants');
99
124
  const { GRID_COMPARISON } = GRID_LIMITS;
100
125
  const Format = require('./format');
@@ -111,340 +136,336 @@ function _snapshotFundState(manager) {
111
136
  sellLocked: Number(manager.accountTotals?.sellLocked || 0),
112
137
  };
113
138
  }
114
- const { floatToBlockchainInt, blockchainToFloat, getPrecisionByOrderType, getPrecisionsForManager, calculateOrderCreationFees, calculateOrderSizes, calculateRotationOrderSizes, calculateAvailableFundsValue, calculateGridSideDivergenceMetric, getPrecisionSlack, getMinAbsoluteOrderSize, getSingleDustThreshold, getGridBestPrices, calculateSpreadFromOrders, allocateFundsByWeights, calculateGapSlots, calculatePriceTolerance } = require('./utils/math');
115
- const { filterOrdersByType, checkSizesBeforeMinimum, checkSizeThreshold, resolveConfiguredPriceBound, shouldFlagOutOfSpread, isOrderHealthy, isPhantomOrder, isSlotAvailable, isOrderOnChain, hasOnChainId, calculateIdealBoundary, assignGridRoles } = require('./utils/order');
139
+ const { floatToBlockchainInt, blockchainToFloat, getPrecisionByOrderType, getPrecisionsForManager, calculateOrderCreationFees, calculateOrderSizes, calculateRotationOrderSizes, calculateAvailableFundsValue, calculateGridSideDivergenceMetric, getPrecisionSlack, getMinAbsoluteOrderSize, getSingleDustThreshold, getGridBestPrices, calculateSpreadFromOrders, allocateFundsByWeights, calculateGapSlots: _mathGapSlots, calculatePriceTolerance } = require('./utils/math');
140
+ const { adjustBudgetForBtsFees, filterOrdersByType, checkSizesBeforeMinimum, checkSizeThreshold, resolveConfiguredPriceBound, shouldFlagOutOfSpread, isOrderHealthy, isPhantomOrder, isSlotAvailable, isOrderOnChain, hasOnChainId, calculateIdealBoundary, assignGridRoles } = require('./utils/order');
116
141
  const { derivePrice, loadAmaCenterPrice, loadAmaCenterSnapshot } = require('./utils/system');
117
142
  const { getWhitelistFlags } = require('../market_adapter_whitelist');
118
- class Grid {
119
- /**
120
- * Calculate the spread gap size (number of empty slots between BUY and SELL rails).
121
- * Delegates to utils/math for pure calculation logic.
122
- *
123
- * @param {number} incrementPercent
124
- * @param {number} targetSpreadPercent
125
- * @returns {number}
126
- */
127
- static calculateGapSlots(incrementPercent, targetSpreadPercent, gridLimitsOverride) {
128
- return calculateGapSlots(incrementPercent, targetSpreadPercent, gridLimitsOverride ?? GRID_LIMITS);
129
- }
130
- /**
131
- * Detect grid bloat: compares total grid size to expected maximum based on
132
- * actual placed orders (ACTIVE/PARTIAL with orderId) plus gap slots plus 1
133
- * tolerance slot. Accepts either an array (from persisted grid load) or a
134
- * Map (from manager.orders at runtime).
135
- *
136
- * Formula: maxAllowed = placedCount + gapSlots + 1
137
- *
138
- * @param {Object} manager - OrderManager instance (provides config).
139
- * @param {Array|Map} orders - Grid orders as array or Map.
140
- * @returns {{bloated: boolean, details?: {gridSize: number, placedCount: number, numBuyActive: number, numSellActive: number, gapSlots: number, maxAllowed: number}}}
141
- */
142
- static isGridBloated(manager, orders) {
143
- const gridSize = Array.isArray(orders) ? orders.length : orders.size;
144
- if (!gridSize || !manager?.config)
145
- return { bloated: false };
146
- const config = manager.config;
147
- const incPct = config.incrementPercent || 0.3;
148
- if (incPct <= 0)
149
- return { bloated: false };
150
- const targetSpreadPct = config.targetSpreadPercent || incPct * 2;
151
- const orderList = Array.isArray(orders) ? orders : Array.from(orders.values());
152
- const numBuyActive = orderList.filter((o) => o.type === ORDER_TYPES.BUY &&
153
- (o.state === ORDER_STATES.ACTIVE || o.state === ORDER_STATES.PARTIAL) &&
154
- o.orderId).length;
155
- const numSellActive = orderList.filter((o) => o.type === ORDER_TYPES.SELL &&
156
- (o.state === ORDER_STATES.ACTIVE || o.state === ORDER_STATES.PARTIAL) &&
157
- o.orderId).length;
158
- const placedCount = numBuyActive + numSellActive;
159
- if (!placedCount)
160
- return { bloated: false };
161
- const gapSlots = Grid.calculateGapSlots(incPct, targetSpreadPct);
162
- const maxAllowed = placedCount + gapSlots + 1;
163
- return {
164
- bloated: gridSize > maxAllowed,
165
- details: { gridSize, placedCount, numBuyActive, numSellActive, gapSlots, maxAllowed }
166
- };
143
+ function calculateGapSlots(incrementPercent, targetSpreadPercent, gridLimitsOverride) {
144
+ return _mathGapSlots(incrementPercent, targetSpreadPercent, gridLimitsOverride ?? GRID_LIMITS);
145
+ }
146
+ function isGridBloated(manager, orders) {
147
+ const gridSize = Array.isArray(orders) ? orders.length : orders.size;
148
+ if (!gridSize || !manager?.config)
149
+ return { bloated: false };
150
+ const config = manager.config;
151
+ const incPct = config.incrementPercent || 0.3;
152
+ if (incPct <= 0)
153
+ return { bloated: false };
154
+ const targetSpreadPct = config.targetSpreadPercent || incPct * 2;
155
+ const orderList = Array.isArray(orders) ? orders : Array.from(orders.values());
156
+ const numBuyActive = orderList.filter((o) => o.type === ORDER_TYPES.BUY &&
157
+ (o.state === ORDER_STATES.ACTIVE || o.state === ORDER_STATES.PARTIAL) &&
158
+ o.orderId).length;
159
+ const numSellActive = orderList.filter((o) => o.type === ORDER_TYPES.SELL &&
160
+ (o.state === ORDER_STATES.ACTIVE || o.state === ORDER_STATES.PARTIAL) &&
161
+ o.orderId).length;
162
+ const placedCount = numBuyActive + numSellActive;
163
+ if (!placedCount)
164
+ return { bloated: false };
165
+ const gapSlots = calculateGapSlots(incPct, targetSpreadPct);
166
+ const maxAllowed = placedCount + gapSlots + 1;
167
+ return {
168
+ bloated: gridSize > maxAllowed,
169
+ details: { gridSize, placedCount, numBuyActive, numSellActive, gapSlots, maxAllowed }
170
+ };
171
+ }
172
+ /**
173
+ * Check whether the grid-bloat grace period is still active, i.e. a
174
+ * bloat detection happened recently enough that a structural resync
175
+ * has not had time to resolve it.
176
+ *
177
+ * Used by both Grid.loadGrid (to suppress redundant resync requests)
178
+ * and the maintenance runtime (to decide when to re-check after the
179
+ * grace window expires) so the two policies share one definition of
180
+ * the grace window.
181
+ *
182
+ * @param {Object} manager - OrderManager instance.
183
+ * @returns {{active: boolean, elapsed: number, graceMs: number}}
184
+ */
185
+ function isGridBloatGraceActive(manager) {
186
+ const graceMs = Number(TIMING?.GRID_BLOAT_RESYNC_GRACE_MS) || TIMING.GRID_BLOAT_RESYNC_GRACE_MS;
187
+ if (!manager._gridBloatDetectedAt) {
188
+ return { active: false, elapsed: 0, graceMs };
167
189
  }
168
- /**
169
- * Public wrapper for side sizing context.
170
- * Keeps StrategyEngine decoupled from Grid private internals.
171
- *
172
- * @param {import('./types').OrderManager} manager
173
- * @param {'buy'|'sell'} side
174
- * @returns {Promise<import('./types').SizingContext|null>}
175
- */
176
- static async getSizingContext(manager, side) {
177
- return await Grid._getSizingContext(manager, side);
178
- }
179
- /**
180
- * Unifies budget calculation and fee deduction for all grid sizing scenarios.
181
- * Ensures consistent fund context (Allocated vs Total) across the bot.
182
- *
183
- * @param {import('./types').OrderManager} manager - OrderManager instance
184
- * @param {string} side - 'buy' or 'sell'
185
- * @returns {Promise<import('./types').SizingContext|null>}
186
- * @private
187
- */
188
- static async _getSizingContext(manager, side, { skipRecalc = false } = {}) {
189
- if (!manager || !manager.assets)
190
- return null;
191
- // 1. Ensure fund state is fresh before sizing
192
- if (!skipRecalc) {
193
- await manager.recalculateFunds();
194
- }
195
- const snap = manager.getChainFundsSnapshot ? manager.getChainFundsSnapshot() : {};
196
- const isBuy = side === 'buy';
197
- const type = isBuy ? ORDER_TYPES.BUY : ORDER_TYPES.SELL;
198
- // 2. Determine base budget: Always use ALLOCATED funds (respects botFunds %)
199
- // This ensures the bot only "thinks" about the capital it is allowed to use.
200
- let budget = isBuy ? (snap.allocatedBuy || 0) : (snap.allocatedSell || 0);
201
- // 3. Standardize BTS Fee Deduction (Issue #15 consistency)
202
- // BTS fees are paid for ALL order operations regardless of side, so the
203
- // BTS-holding side reserves fees for both buy and sell target counts.
190
+ const elapsed = Date.now() - manager._gridBloatDetectedAt;
191
+ return { active: elapsed < graceMs, elapsed, graceMs };
192
+ }
193
+ /**
194
+ * Clear the grid-bloat detection timestamp once the grid size has
195
+ * returned to normal. Shared so both call sites use the same key.
196
+ * @param {Object} manager - OrderManager instance.
197
+ */
198
+ function clearGridBloatFlag(manager) {
199
+ delete manager._gridBloatDetectedAt;
200
+ }
201
+ /**
202
+ * Public wrapper for side sizing context.
203
+ * Keeps StrategyEngine decoupled from Grid private internals.
204
+ *
205
+ * @param {import('./types').OrderManager} manager
206
+ * @param {'buy'|'sell'} side
207
+ * @returns {Promise<import('./types').SizingContext|null>}
208
+ */
209
+ async function getSizingContext(manager, side) {
210
+ return await _getSizingContext(manager, side);
211
+ }
212
+ /**
213
+ * Unifies budget calculation and fee deduction for all grid sizing scenarios.
214
+ * Ensures consistent fund context (Allocated vs Total) across the bot.
215
+ *
216
+ * @param {import('./types').OrderManager} manager - OrderManager instance
217
+ * @param {string} side - 'buy' or 'sell'
218
+ * @returns {Promise<import('./types').SizingContext|null>}
219
+ * @private
220
+ */
221
+ async function _getSizingContext(manager, side, { skipRecalc = false } = {}) {
222
+ if (!manager || !manager.assets)
223
+ return null;
224
+ // 1. Ensure fund state is fresh before sizing
225
+ if (!skipRecalc) {
226
+ await manager.recalculateFunds();
227
+ }
228
+ const snap = manager.getChainFundsSnapshot ? manager.getChainFundsSnapshot() : {};
229
+ const isBuy = side === 'buy';
230
+ const type = isBuy ? ORDER_TYPES.BUY : ORDER_TYPES.SELL;
231
+ // 2. Determine base budget: Always use ALLOCATED funds (respects botFunds %)
232
+ // This ensures the bot only "thinks" about the capital it is allowed to use.
233
+ let budget = isBuy ? (snap.allocatedBuy || 0) : (snap.allocatedSell || 0);
234
+ // 3. Standardize BTS Fee Deduction (Issue #15 consistency)
235
+ // BTS fees are paid for ALL order operations regardless of side, so the
236
+ // BTS-holding side reserves fees for both buy and sell target counts.
237
+ if (budget > 0) {
238
+ const targetBuy = Math.max(0, manager.config.activeOrders?.buy ?? 1);
239
+ const targetSell = Math.max(0, manager.config.activeOrders?.sell ?? 1);
240
+ const totalTarget = targetBuy + targetSell;
204
241
  const isBtsSide = (isBuy && manager.config.assetB === 'BTS') || (!isBuy && manager.config.assetA === 'BTS');
205
- const btsReservationMultiplier = manager.config?.feeParams?.BTS_RESERVATION_MULTIPLIER;
206
- if (isBtsSide && budget > 0) {
207
- const targetBuy = Math.max(0, manager.config.activeOrders?.buy ?? 1);
208
- const targetSell = Math.max(0, manager.config.activeOrders?.sell ?? 1);
209
- const totalTarget = targetBuy + targetSell;
210
- const btsFees = calculateOrderCreationFees(manager.config.assetA, manager.config.assetB, totalTarget, btsReservationMultiplier);
211
- budget = Math.max(0, budget - btsFees);
212
- }
213
- // Non-BTS pair: reserve proportional share for BTS fee budget
214
- if (!isBtsSide && budget > 0) {
215
- const targetBuy = Math.max(0, manager.config.activeOrders?.buy ?? 1);
216
- const targetSell = Math.max(0, manager.config.activeOrders?.sell ?? 1);
217
- const totalTarget = targetBuy + targetSell;
218
- const formulaBudget = calculateOrderCreationFees(manager.config.assetA, manager.config.assetB, totalTarget, btsReservationMultiplier);
219
- const configMin = manager.config.min_BTS_value;
220
- const effectiveMin = (configMin > 0) ? configMin : formulaBudget;
221
- const btsFree = Format.toFiniteNumber(manager.funds?.btsBalance?.free, 0);
222
- const btsDeficit = Math.max(0, effectiveMin - btsFree);
223
- if (btsDeficit > 0) {
224
- const sideFree = Format.toFiniteNumber(isBuy ? manager.accountTotals?.buyFree : manager.accountTotals?.sellFree, 0);
225
- const totalFree = Format.toFiniteNumber(manager.accountTotals?.buyFree, 0)
226
- + Format.toFiniteNumber(manager.accountTotals?.sellFree, 0);
227
- const share = totalFree > 0 ? sideFree / totalFree : 0.5;
228
- budget = Math.max(0, budget - btsDeficit * share);
229
- }
230
- }
231
- return {
232
- budget,
233
- precision: getPrecisionByOrderType(manager.assets, type),
234
- config: manager.config
235
- };
242
+ const formulaBudget = calculateOrderCreationFees(manager.config.assetA, manager.config.assetB, totalTarget, manager.config?.feeParams?.BTS_RESERVATION_MULTIPLIER);
243
+ budget = adjustBudgetForBtsFees(budget, isBtsSide, formulaBudget, manager.config.min_BTS_value || 0, Format.toFiniteNumber(manager.funds?.btsBalance?.free, 0), Format.toFiniteNumber(isBuy ? manager.accountTotals?.buyFree : manager.accountTotals?.sellFree, 0), Format.toFiniteNumber(manager.accountTotals?.buyFree, 0)
244
+ + Format.toFiniteNumber(manager.accountTotals?.sellFree, 0));
236
245
  }
237
- /**
238
- * Create the initial order grid structure based on configuration.
239
- *
240
- * ALGORITHM: Geometric Grid Creation with Fixed Spread Gap
241
- * =========================================================
242
- * This method generates a unified "Master Rail" of price levels with geometric spacing.
243
- * The grid is centered around startPrice with a fixed-size spread gap.
244
- *
245
- * KEY CONCEPTS:
246
- * - Geometric Spacing: Each price level is incrementPercent% away from neighbors
247
- * - Master Rail: Single unified array (not separate buy/sell rails)
248
- * - Spread Gap: Fixed-size buffer between best buy and best sell
249
- * - Role Assignment: BUY / SPREAD / SELL based on position relative to startPrice
250
- *
251
- * SPREAD GAP FORMULA:
252
- * ===================
253
- * The spread gap size is calculated to match the target spread percentage:
254
- *
255
- * 1. Step Factor (s): s = 1 + (incrementPercent / 100)
256
- * Example: If incrementPercent = 0.5%, then s = 1.005
257
- *
258
- * 2. Minimum Spread: minSpread = incrementPercent × MIN_SPREAD_FACTOR
259
- * This ensures spread is at least 2× the increment (prevents too-narrow spread)
260
- *
261
- * 3. Target Steps (n): Number of price levels needed to achieve target spread
262
- * Formula: n = ceil(ln(1 + targetSpread/100) / ln(s))
263
- *
264
- * Derivation: If we want price to grow by targetSpread% over n steps:
265
- * - Final price = startPrice × s^n
266
- * - Growth factor = (1 + targetSpread/100)
267
- * - Therefore: s^n = (1 + targetSpread/100)
268
- * - Taking ln: n × ln(s) = ln(1 + targetSpread/100)
269
- * - Solving: n = ln(1 + targetSpread/100) / ln(s)
270
- *
271
- * 4. Gap Slots (G): G = max(MIN_SPREAD_ORDERS, n)
272
- * Ensures at least MIN_SPREAD_ORDERS slots even if target spread is small
273
- *
274
- * EXAMPLE:
275
- * --------
276
- * incrementPercent = 0.5%, targetSpread = 2%
277
- * - s = 1.005
278
- * - minSpread = 0.5% × 2 = 1%
279
- * - targetSpread = max(2%, 1%) = 2%
280
- * - n = ceil(ln(1.02) / ln(1.005)) = ceil(3.98) = 4 steps
281
- * - G = max(2, 4) = 4 slots
282
- *
283
- * @param {import('./types').GridConfig} config - Grid configuration
284
- * @returns {import('./types').GridCreationResult}
285
- */
286
- static createOrderGrid(config) {
287
- const { startPrice, minPrice, maxPrice, incrementPercent } = config;
288
- // FIX: Add comprehensive input validation to prevent silent grid creation failures
289
- if (!Number.isFinite(startPrice)) {
290
- throw new Error(`Invalid startPrice: ${startPrice}. Must be a finite number.`);
291
- }
292
- if (!Number.isFinite(minPrice)) {
293
- throw new Error(`Invalid minPrice: ${minPrice}. Must be a finite number.`);
294
- }
295
- if (minPrice <= 0) {
296
- throw new Error(`Invalid minPrice: ${minPrice}. Must be positive.`);
297
- }
298
- if (!Number.isFinite(maxPrice)) {
299
- throw new Error(`Invalid maxPrice: ${maxPrice}. Must be a finite number.`);
300
- }
301
- if (minPrice >= maxPrice) {
302
- throw new Error(`Invalid price bounds: minPrice (${minPrice}) must be < maxPrice (${maxPrice}).`);
303
- }
304
- if (!(minPrice <= startPrice && startPrice <= maxPrice)) {
305
- throw new Error(`startPrice (${startPrice}) must be within bounds [${minPrice}, ${maxPrice}].`);
306
- }
307
- if (maxPrice <= 0) {
308
- throw new Error(`maxPrice (${maxPrice}) must be positive.`);
309
- }
310
- if (!Number.isFinite(incrementPercent)) {
311
- throw new Error(`Invalid incrementPercent: ${incrementPercent}. Must be a finite number.`);
312
- }
313
- const minPercent = config.incrementBounds?.MIN_PERCENT;
314
- const maxPercent = config.incrementBounds?.MAX_PERCENT;
315
- if (incrementPercent < minPercent || incrementPercent > maxPercent) {
316
- throw new Error(`Invalid incrementPercent: ${incrementPercent}. Must be between ` +
317
- `${minPercent} and ${maxPercent} (inclusive).`);
318
- }
319
- const stepUp = 1 + (incrementPercent / 100);
320
- const stepDown = 1 - (incrementPercent / 100);
321
- // ================================================================================
322
- // STEP 1: GENERATE PRICE LEVELS (Geometric progression)
323
- // ================================================================================
324
- // Create a geometric series of prices from minPrice to maxPrice.
325
- // Each level is incrementPercent% away from its neighbors.
326
- //
327
- // We start from startPrice and expand outward in both directions to ensure
328
- // the grid is centered around the market price.
329
- const priceLevels = [];
330
- // Generate levels upwards from startPrice (higher prices for SELL orders)
331
- // Start from sqrt(stepUp) × startPrice to center the grid
332
- let upPrice = startPrice * Math.sqrt(stepUp);
333
- while (upPrice <= maxPrice) {
334
- priceLevels.push(upPrice);
335
- upPrice *= stepUp;
336
- }
337
- // Generate levels downwards from startPrice (lower prices for BUY orders)
338
- // Start from sqrt(stepDown) × startPrice to center the grid
339
- let downPrice = startPrice * Math.sqrt(stepDown);
340
- while (downPrice >= minPrice) {
341
- priceLevels.push(downPrice);
342
- downPrice *= stepDown;
343
- }
344
- // Sort all levels from lowest to highest (Master Rail order)
345
- priceLevels.sort((a, b) => a - b);
346
- if (priceLevels.length === 0) {
347
- throw new Error(`Grid generation produced no price levels for startPrice=${startPrice}, ` +
348
- `bounds=[${minPrice}, ${maxPrice}], incrementPercent=${incrementPercent}. ` +
349
- `Widen bounds or reduce incrementPercent.`);
350
- }
351
- // ================================================================================
352
- // STEP 2: CALCULATE SPREAD GAP SIZE
353
- // ================================================================================
354
- // Determine how many slots should be in the spread zone.
355
- // See formula documentation in JSDoc above.
356
- const gapSlots = Grid.calculateGapSlots(incrementPercent, config.targetSpreadPercent, config.gridLimits);
357
- // ================================================================================
358
- // STEP 3: FIND SPLIT INDEX & ROLE ASSIGNMENT
359
- // ================================================================================
360
- // Determine the boundary and assign roles (BUY/SPREAD/SELL) to each slot.
361
- //
362
- // STRATEGY: Center the spread gap around startPrice
363
- const boundaryIdx = calculateIdealBoundary(priceLevels.map(p => ({ price: p })), startPrice, gapSlots);
364
- // ================================================================================
365
- // STEP 4: CREATE ORDER OBJECTS
366
- // ================================================================================
367
- // Convert price levels to order objects with assigned roles.
368
- const orders = priceLevels.map((price, i) => ({
369
- id: `slot-${i}`,
370
- price,
371
- type: null, // assigned below
372
- state: ORDER_STATES.VIRTUAL,
373
- size: 0
374
- }));
375
- const updatedOrders = assignGridRoles(orders, boundaryIdx, gapSlots, ORDER_TYPES, ORDER_STATES);
376
- const buyCount = updatedOrders.filter(o => o.type === ORDER_TYPES.BUY).length;
377
- const sellCount = updatedOrders.filter(o => o.type === ORDER_TYPES.SELL).length;
378
- if (buyCount === 0 || sellCount === 0) {
379
- throw new Error(`Grid generation produced an imbalanced rail (buy=${buyCount}, sell=${sellCount}) for ` +
380
- `startPrice=${startPrice}, bounds=[${minPrice}, ${maxPrice}], incrementPercent=${incrementPercent}, ` +
381
- `targetSpreadPercent=${config.targetSpreadPercent}. Widen bounds or reduce target spread.`);
382
- }
383
- const initialSpreadCount = {
384
- buy: Math.floor(gapSlots / 2),
385
- sell: gapSlots - Math.floor(gapSlots / 2)
386
- };
387
- return { orders: updatedOrders, boundaryIdx, initialSpreadCount };
388
- }
389
- /**
390
- * Internal utility to clear all order-related manager caches.
391
- * Prevents stale references during grid reinitialization.
392
- * RC-2: Synchronized to prevent concurrent modifications during clear
393
- *
394
- * Note: Uses explicit assignment instead of .clear() to enforce COW semantics:
395
- * - Replace the master grid atomically with a fresh Map instance
396
- * - Avoid mutating any previously referenced Map object
397
- * @param {import('./types').OrderManager} manager - OrderManager instance
398
- * @private
399
- */
400
- static _clearOrderCachesLogic(manager) {
401
- // Replace frozen master grid with fresh empty frozen Map (COW pattern)
402
- manager.orders = Object.freeze(new Map());
403
- // Clear index Sets with fresh empty Sets (mutable for _applyOrderUpdate)
404
- if (manager._ordersByState) {
405
- for (const key of Object.keys(manager._ordersByState)) {
406
- manager._ordersByState[key] = new Set();
407
- }
246
+ return {
247
+ budget,
248
+ precision: getPrecisionByOrderType(manager.assets, type),
249
+ config: manager.config
250
+ };
251
+ }
252
+ /**
253
+ * Create the initial order grid structure based on configuration.
254
+ *
255
+ * ALGORITHM: Geometric Grid Creation with Fixed Spread Gap
256
+ * =========================================================
257
+ * This method generates a unified "Master Rail" of price levels with geometric spacing.
258
+ * The grid is centered around startPrice with a fixed-size spread gap.
259
+ *
260
+ * KEY CONCEPTS:
261
+ * - Geometric Spacing: Each price level is incrementPercent% away from neighbors
262
+ * - Master Rail: Single unified array (not separate buy/sell rails)
263
+ * - Spread Gap: Fixed-size buffer between best buy and best sell
264
+ * - Role Assignment: BUY / SPREAD / SELL based on position relative to startPrice
265
+ *
266
+ * SPREAD GAP FORMULA:
267
+ * ===================
268
+ * The spread gap size is calculated to match the target spread percentage:
269
+ *
270
+ * 1. Step Factor (s): s = 1 + (incrementPercent / 100)
271
+ * Example: If incrementPercent = 0.5%, then s = 1.005
272
+ *
273
+ * 2. Minimum Spread: minSpread = incrementPercent × MIN_SPREAD_FACTOR
274
+ * This ensures spread is at least 2× the increment (prevents too-narrow spread)
275
+ *
276
+ * 3. Target Steps (n): Number of price levels needed to achieve target spread
277
+ * Formula: n = ceil(ln(1 + targetSpread/100) / ln(s))
278
+ *
279
+ * Derivation: If we want price to grow by targetSpread% over n steps:
280
+ * - Final price = startPrice × s^n
281
+ * - Growth factor = (1 + targetSpread/100)
282
+ * - Therefore: s^n = (1 + targetSpread/100)
283
+ * - Taking ln: n × ln(s) = ln(1 + targetSpread/100)
284
+ * - Solving: n = ln(1 + targetSpread/100) / ln(s)
285
+ *
286
+ * 4. Gap Slots (G): G = max(MIN_SPREAD_ORDERS, n)
287
+ * Ensures at least MIN_SPREAD_ORDERS slots even if target spread is small
288
+ *
289
+ * EXAMPLE:
290
+ * --------
291
+ * incrementPercent = 0.5%, targetSpread = 2%
292
+ * - s = 1.005
293
+ * - minSpread = 0.5% × 2 = 1%
294
+ * - targetSpread = max(2%, 1%) = 2%
295
+ * - n = ceil(ln(1.02) / ln(1.005)) = ceil(3.98) = 4 steps
296
+ * - G = max(2, 4) = 4 slots
297
+ *
298
+ * @param {import('./types').GridConfig} config - Grid configuration
299
+ * @returns {import('./types').GridCreationResult}
300
+ */
301
+ function createOrderGrid(config) {
302
+ const { startPrice, minPrice, maxPrice, incrementPercent } = config;
303
+ // FIX: Add comprehensive input validation to prevent silent grid creation failures
304
+ if (!Number.isFinite(startPrice)) {
305
+ throw new Error(`Invalid startPrice: ${startPrice}. Must be a finite number.`);
306
+ }
307
+ if (!Number.isFinite(minPrice)) {
308
+ throw new Error(`Invalid minPrice: ${minPrice}. Must be a finite number.`);
309
+ }
310
+ if (minPrice <= 0) {
311
+ throw new Error(`Invalid minPrice: ${minPrice}. Must be positive.`);
312
+ }
313
+ if (!Number.isFinite(maxPrice)) {
314
+ throw new Error(`Invalid maxPrice: ${maxPrice}. Must be a finite number.`);
315
+ }
316
+ if (minPrice >= maxPrice) {
317
+ throw new Error(`Invalid price bounds: minPrice (${minPrice}) must be < maxPrice (${maxPrice}).`);
318
+ }
319
+ if (!(minPrice <= startPrice && startPrice <= maxPrice)) {
320
+ throw new Error(`startPrice (${startPrice}) must be within bounds [${minPrice}, ${maxPrice}].`);
321
+ }
322
+ if (maxPrice <= 0) {
323
+ throw new Error(`maxPrice (${maxPrice}) must be positive.`);
324
+ }
325
+ if (!Number.isFinite(incrementPercent)) {
326
+ throw new Error(`Invalid incrementPercent: ${incrementPercent}. Must be a finite number.`);
327
+ }
328
+ const minPercent = config.incrementBounds?.MIN_PERCENT;
329
+ const maxPercent = config.incrementBounds?.MAX_PERCENT;
330
+ if (incrementPercent < minPercent || incrementPercent > maxPercent) {
331
+ throw new Error(`Invalid incrementPercent: ${incrementPercent}. Must be between ` +
332
+ `${minPercent} and ${maxPercent} (inclusive).`);
333
+ }
334
+ const stepUp = 1 + (incrementPercent / 100);
335
+ const stepDown = 1 - (incrementPercent / 100);
336
+ // ================================================================================
337
+ // STEP 1: GENERATE PRICE LEVELS (Geometric progression)
338
+ // ================================================================================
339
+ // Create a geometric series of prices from minPrice to maxPrice.
340
+ // Each level is incrementPercent% away from its neighbors.
341
+ //
342
+ // We start from startPrice and expand outward in both directions to ensure
343
+ // the grid is centered around the market price.
344
+ const priceLevels = [];
345
+ // Generate levels upwards from startPrice (higher prices for SELL orders)
346
+ // Start from sqrt(stepUp) × startPrice to center the grid
347
+ let upPrice = startPrice * Math.sqrt(stepUp);
348
+ while (upPrice <= maxPrice) {
349
+ priceLevels.push(upPrice);
350
+ upPrice *= stepUp;
351
+ }
352
+ // Generate levels downwards from startPrice (lower prices for BUY orders)
353
+ // Start from sqrt(stepDown) × startPrice to center the grid
354
+ let downPrice = startPrice * Math.sqrt(stepDown);
355
+ while (downPrice >= minPrice) {
356
+ priceLevels.push(downPrice);
357
+ downPrice *= stepDown;
358
+ }
359
+ // Sort all levels from lowest to highest (Master Rail order)
360
+ priceLevels.sort((a, b) => a - b);
361
+ if (priceLevels.length === 0) {
362
+ throw new Error(`Grid generation produced no price levels for startPrice=${startPrice}, ` +
363
+ `bounds=[${minPrice}, ${maxPrice}], incrementPercent=${incrementPercent}. ` +
364
+ `Widen bounds or reduce incrementPercent.`);
365
+ }
366
+ // ================================================================================
367
+ // STEP 2: CALCULATE SPREAD GAP SIZE
368
+ // ================================================================================
369
+ // Determine how many slots should be in the spread zone.
370
+ // See formula documentation in JSDoc above.
371
+ const gapSlots = calculateGapSlots(incrementPercent, config.targetSpreadPercent, config.gridLimits);
372
+ // ================================================================================
373
+ // STEP 3: FIND SPLIT INDEX & ROLE ASSIGNMENT
374
+ // ================================================================================
375
+ // Determine the boundary and assign roles (BUY/SPREAD/SELL) to each slot.
376
+ //
377
+ // STRATEGY: Center the spread gap around startPrice
378
+ const boundaryIdx = calculateIdealBoundary(priceLevels.map(p => ({ price: p })), startPrice, gapSlots);
379
+ // ================================================================================
380
+ // STEP 4: CREATE ORDER OBJECTS
381
+ // ================================================================================
382
+ // Convert price levels to order objects with assigned roles.
383
+ const orders = priceLevels.map((price, i) => ({
384
+ id: `slot-${i}`,
385
+ price,
386
+ type: null, // assigned below
387
+ state: ORDER_STATES.VIRTUAL,
388
+ size: 0
389
+ }));
390
+ const updatedOrders = assignGridRoles(orders, boundaryIdx, gapSlots, ORDER_TYPES, ORDER_STATES);
391
+ const buyCount = updatedOrders.filter(o => o.type === ORDER_TYPES.BUY).length;
392
+ const sellCount = updatedOrders.filter(o => o.type === ORDER_TYPES.SELL).length;
393
+ if (buyCount === 0 || sellCount === 0) {
394
+ throw new Error(`Grid generation produced an imbalanced rail (buy=${buyCount}, sell=${sellCount}) for ` +
395
+ `startPrice=${startPrice}, bounds=[${minPrice}, ${maxPrice}], incrementPercent=${incrementPercent}, ` +
396
+ `targetSpreadPercent=${config.targetSpreadPercent}. Widen bounds or reduce target spread.`);
397
+ }
398
+ const initialSpreadCount = {
399
+ buy: Math.floor(gapSlots / 2),
400
+ sell: gapSlots - Math.floor(gapSlots / 2)
401
+ };
402
+ return { orders: updatedOrders, boundaryIdx, initialSpreadCount };
403
+ }
404
+ /**
405
+ * Internal utility to clear all order-related manager caches.
406
+ * Prevents stale references during grid reinitialization.
407
+ * RC-2: Synchronized to prevent concurrent modifications during clear
408
+ *
409
+ * Note: Uses explicit assignment instead of .clear() to enforce COW semantics:
410
+ * - Replace the master grid atomically with a fresh Map instance
411
+ * - Avoid mutating any previously referenced Map object
412
+ * @param {import('./types').OrderManager} manager - OrderManager instance
413
+ * @private
414
+ */
415
+ function _clearOrderCachesLogic(manager) {
416
+ // Replace frozen master grid with fresh empty frozen Map (COW pattern)
417
+ manager.orders = Object.freeze(new Map());
418
+ // Clear index Sets with fresh empty Sets (mutable for _applyOrderUpdate)
419
+ if (manager._ordersByState) {
420
+ for (const key of Object.keys(manager._ordersByState)) {
421
+ manager._ordersByState[key] = new Set();
408
422
  }
409
- if (manager._ordersByType) {
410
- for (const key of Object.keys(manager._ordersByType)) {
411
- manager._ordersByType[key] = new Set();
412
- }
423
+ }
424
+ if (manager._ordersByType) {
425
+ for (const key of Object.keys(manager._ordersByType)) {
426
+ manager._ordersByType[key] = new Set();
413
427
  }
414
428
  }
415
- /**
416
- * Restore a persisted grid snapshot onto a manager instance.
417
- * @param {import('./types').OrderManager} manager - The manager instance.
418
- * @param {Array<import('./types').GridOrderSlot>} grid - The persisted grid array.
419
- * @param {number|null} [boundaryIdx=null] - The master boundary index.
420
- * @returns {Promise<void>}
421
- */
422
- static async loadGrid(manager, grid, boundaryIdx = null) {
423
- if (!Array.isArray(grid))
424
- return;
425
- return await manager._gridLock.acquire(async () => {
426
- try {
427
- await manager._initializeAssets();
428
- }
429
- catch (e) {
430
- manager.logger?.log?.(`Asset initialization failed during grid load: ${e.message}`, 'warn');
431
- }
432
- // RC-2: Use logic helper
433
- Grid._clearOrderCachesLogic(manager);
434
- const savedBtsFeesOwed = manager.funds.btsFeesOwed;
435
- manager.resetFunds();
436
- manager.funds.btsFeesOwed = savedBtsFeesOwed;
437
- // Restore boundary index for StrategyEngine
438
- if (typeof boundaryIdx === 'number') {
439
- manager.boundaryIdx = boundaryIdx;
440
- // FIX: Use consistent optional chaining pattern for logger calls
441
- manager.logger?.log?.(`Restored boundary index: ${boundaryIdx}`, 'info');
429
+ }
430
+ /**
431
+ * Restore a persisted grid snapshot onto a manager instance.
432
+ * @param {import('./types').OrderManager} manager - The manager instance.
433
+ * @param {Array<import('./types').GridOrderSlot>} grid - The persisted grid array.
434
+ * @param {number|null} [boundaryIdx=null] - The master boundary index.
435
+ * @returns {Promise<void>}
436
+ */
437
+ async function loadGrid(manager, grid, boundaryIdx = null) {
438
+ if (!Array.isArray(grid))
439
+ return;
440
+ return await manager._gridLock.acquire(async () => {
441
+ try {
442
+ await manager._initializeAssets();
443
+ }
444
+ catch (e) {
445
+ manager.logger?.log?.(`Asset initialization failed during grid load: ${e.message}`, 'warn');
446
+ }
447
+ // RC-2: Use logic helper
448
+ _clearOrderCachesLogic(manager);
449
+ const savedBtsFeesOwed = manager.funds.btsFeesOwed;
450
+ manager.resetFunds();
451
+ manager.funds.btsFeesOwed = savedBtsFeesOwed;
452
+ // Restore boundary index for StrategyEngine
453
+ if (typeof boundaryIdx === 'number') {
454
+ manager.boundaryIdx = boundaryIdx;
455
+ // FIX: Use consistent optional chaining pattern for logger calls
456
+ manager.logger?.log?.(`Restored boundary index: ${boundaryIdx}`, 'info');
457
+ }
458
+ // Gap 6: Grid size cap — validate grid slot count against expected maximum.
459
+ // Formula: placedOrders (active+partial with orderId) + gapSlots + 1 tolerance slot.
460
+ const bloatResult = isGridBloated(manager, grid);
461
+ if (bloatResult.bloated) {
462
+ const d = bloatResult.details;
463
+ const grace = isGridBloatGraceActive(manager);
464
+ if (grace.active) {
465
+ manager.logger?.log?.(`[GRID-BLOAT] Grid size ${d.gridSize} exceeds expected maximum ${d.maxAllowed} ` +
466
+ `(grace period active ${grace.elapsed}ms/${grace.graceMs}ms). Skipping re-request.`, 'debug');
442
467
  }
443
- // Gap 6: Grid size cap — validate grid slot count against expected maximum.
444
- // Formula: placedOrders (active+partial with orderId) + gapSlots + 1 tolerance slot.
445
- const bloatResult = Grid.isGridBloated(manager, grid);
446
- if (bloatResult.bloated) {
447
- const d = bloatResult.details;
468
+ else {
448
469
  manager.logger?.log?.(`[GRID-BLOAT] Grid size ${d.gridSize} exceeds expected maximum ${d.maxAllowed} ` +
449
470
  `(placed=${d.placedCount} buy=${d.numBuyActive} sell=${d.numSellActive} ` +
450
471
  `gapSlots=${d.gapSlots}). Triggering protective structural resync.`, 'warn');
@@ -455,1345 +476,1344 @@ class Grid {
455
476
  });
456
477
  }
457
478
  }
458
- manager.pauseRecalcLogging();
459
- manager.pauseFundRecalc();
460
- try {
461
- // RC-2: Use applyOrderUpdate (PRIVATE/UNLOCKED)
462
- for (const order of grid) {
463
- let currentOrder = order;
464
- if (isPhantomOrder(order)) {
465
- manager.logger?.log?.(`Sanitizing corrupted order ${order.id}: ACTIVE/PARTIAL without orderId -> VIRTUAL`, 'warn');
466
- currentOrder = { ...order, state: ORDER_STATES.VIRTUAL };
467
- }
468
- await manager._applyOrderUpdate(currentOrder, 'grid-load', { skipAccounting: true });
469
- }
470
- const spreadCount = grid.filter(o => o.type === ORDER_TYPES.SPREAD).length;
471
- manager.targetSpreadCount = spreadCount;
472
- manager.currentSpreadCount = spreadCount;
473
- }
474
- finally {
475
- await manager.resumeFundRecalc();
476
- manager.resumeRecalcLogging();
477
- }
478
- manager.logger?.log?.(`Loaded ${manager.orders.size} orders from persisted grid.`, 'info');
479
- });
480
- }
481
- /**
482
- * Initialize the order grid with blockchain-aware sizing.
483
- * @param {import('./types').OrderManager} manager - The manager instance.
484
- * @returns {Promise<void>}
485
- * @throws {Error} If initialization fails or account totals are missing.
486
- */
487
- static async initializeGrid(manager) {
488
- if (!manager)
489
- throw new Error('initializeGrid requires a manager instance');
490
- await manager._initializeAssets();
491
- // FIX: Add explicit state validation to prevent cryptic errors later
492
- if (!manager.assets || !manager.assets.assetA || !manager.assets.assetB) {
493
- throw new Error('Asset initialization did not complete properly - assetA or assetB undefined');
494
479
  }
495
- if (!manager.config) {
496
- throw new Error('Manager config not initialized before grid initialization');
497
- }
498
- const mpRaw = manager.config.startPrice;
499
- manager.logger?.log?.(`[DIAGNOSTIC] initializeGrid: mpRaw type=${typeof mpRaw}, value=${mpRaw}`, 'debug');
500
- // Auto-derive price if not a fixed numeric value (e.g. "pool", "book", or undefined)
501
- if (typeof mpRaw !== 'number' || isNaN(mpRaw)) {
502
- try {
503
- const { BitShares } = require('../bitshares_client');
504
- const derived = await derivePrice(BitShares, manager.config.assetA, manager.config.assetB, manager.config.priceMode || 'auto');
505
- if (derived) {
506
- manager.logger?.log?.(`[DIAGNOSTIC] initializeGrid: Derived new startPrice=${derived.toFixed(8)} (mode=${manager.config.priceMode || 'auto'})`, 'info');
507
- manager.config.startPrice = Number(derived);
508
- }
509
- else {
510
- throw new Error(`Price derivation returned no result for ${manager.config.assetA}/${manager.config.assetB}`);
480
+ manager.pauseRecalcLogging();
481
+ manager.pauseFundRecalc();
482
+ try {
483
+ // RC-2: Use applyOrderUpdate (PRIVATE/UNLOCKED)
484
+ for (const order of grid) {
485
+ let currentOrder = order;
486
+ if (isPhantomOrder(order)) {
487
+ manager.logger?.log?.(`Sanitizing corrupted order ${order.id}: ACTIVE/PARTIAL without orderId -> VIRTUAL`, 'warn');
488
+ currentOrder = { ...order, state: ORDER_STATES.VIRTUAL };
511
489
  }
490
+ await manager._applyOrderUpdate(currentOrder, 'grid-load', { skipAccounting: true });
512
491
  }
513
- catch (err) {
514
- manager.logger?.log?.(`Failed to derive market price: ${err.message}`, 'warn');
515
- throw err; // Re-throw to prevent "pool" string reaching numeric math
516
- }
492
+ const spreadCount = grid.filter(o => o.type === ORDER_TYPES.SPREAD).length;
493
+ manager.targetSpreadCount = spreadCount;
494
+ manager.currentSpreadCount = spreadCount;
517
495
  }
518
- const configuredStartPrice = manager.config.startPrice;
519
- const configuredMinPrice = manager.config.minPrice;
520
- const configuredMaxPrice = manager.config.maxPrice;
521
- const mp = Number(manager.config.startPrice);
522
- // Derive gridPrice — separate reference for x-factor bounds (may differ from startPrice).
523
- // Supported modes:
524
- // - numeric: fixed value
525
- // - "pool" / "book": live blockchain price for the pair
526
- // - "ama"/"ama1".."ama4": center from profiles/orders/<botKey>.dynamicgrid.json
527
- // - null/anything else: fallback to startPrice (backward-compatible)
528
- let gp = mp;
529
- let gpSource = 'startPrice';
530
- let amaSnapshot = null;
531
- const whitelistFlags = getWhitelistFlags(manager.config.botKey);
532
- const isGridRangeScalingWhitelisted = whitelistFlags.asymmetricBounds === true;
533
- let gridPriceOffsetPct = 0;
534
- const gpRaw = manager.config.gridPrice;
535
- const gpMode = (typeof gpRaw === 'string') ? gpRaw.trim().toLowerCase() : null;
536
- if (typeof gpRaw === 'number' && Number.isFinite(gpRaw) && gpRaw > 0) {
537
- gp = gpRaw;
538
- gpSource = 'numeric';
539
- manager.logger?.log?.(`[DIAGNOSTIC] initializeGrid: gridPrice=numeric ${gp.toFixed(8)}`, 'info');
496
+ finally {
497
+ await manager.resumeFundRecalc();
498
+ manager.resumeRecalcLogging();
540
499
  }
541
- else if (gpMode === 'pool' || gpMode === 'book') {
542
- try {
543
- const { BitShares } = require('../bitshares_client');
544
- const derived = await derivePrice(BitShares, manager.config.assetA, manager.config.assetB, gpMode);
545
- if (derived) {
546
- gp = Number(derived);
547
- gpSource = gpMode;
548
- manager.logger?.log?.(`[DIAGNOSTIC] initializeGrid: gridPrice=${gpMode} ${gp.toFixed(8)}`, 'info');
549
- }
550
- else {
551
- manager.logger?.log?.(`initializeGrid: ${gpMode} gridPrice unavailable, falling back to startPrice`, 'warn');
552
- }
500
+ manager.logger?.log?.(`Loaded ${manager.orders.size} orders from persisted grid.`, 'info');
501
+ });
502
+ }
503
+ /**
504
+ * Initialize the order grid with blockchain-aware sizing.
505
+ * @param {import('./types').OrderManager} manager - The manager instance.
506
+ * @returns {Promise<void>}
507
+ * @throws {Error} If initialization fails or account totals are missing.
508
+ */
509
+ async function initializeGrid(manager) {
510
+ if (!manager)
511
+ throw new Error('initializeGrid requires a manager instance');
512
+ await manager._initializeAssets();
513
+ // FIX: Add explicit state validation to prevent cryptic errors later
514
+ if (!manager.assets || !manager.assets.assetA || !manager.assets.assetB) {
515
+ throw new Error('Asset initialization did not complete properly - assetA or assetB undefined');
516
+ }
517
+ if (!manager.config) {
518
+ throw new Error('Manager config not initialized before grid initialization');
519
+ }
520
+ const mpRaw = manager.config.startPrice;
521
+ manager.logger?.log?.(`[DIAGNOSTIC] initializeGrid: mpRaw type=${typeof mpRaw}, value=${mpRaw}`, 'debug');
522
+ // Auto-derive price if not a fixed numeric value (e.g. "pool", "book", or undefined)
523
+ if (typeof mpRaw !== 'number' || isNaN(mpRaw)) {
524
+ try {
525
+ const { BitShares } = require('../bitshares_client');
526
+ const derived = await derivePrice(BitShares, manager.config.assetA, manager.config.assetB, manager.config.priceMode || 'auto');
527
+ if (derived) {
528
+ manager.logger?.log?.(`[DIAGNOSTIC] initializeGrid: Derived new startPrice=${derived.toFixed(8)} (mode=${manager.config.priceMode || 'auto'})`, 'info');
529
+ manager.config.startPrice = Number(derived);
553
530
  }
554
- catch (err) {
555
- manager.logger?.log?.(`initializeGrid: ${gpMode} gridPrice derivation failed: ${err.message}`, 'warn');
531
+ else {
532
+ throw new Error(`Price derivation returned no result for ${manager.config.assetA}/${manager.config.assetB}`);
556
533
  }
557
534
  }
558
- else if (/^ama(?:[1-4])?$/.test(gpMode || '')) {
559
- amaSnapshot = loadAmaCenterSnapshot(manager.config.botKey);
560
- const amaCenter = amaSnapshot?.gridCenterPrice ?? loadAmaCenterPrice(manager.config.botKey);
561
- if (Number.isFinite(amaCenter) && amaCenter > 0) {
562
- gp = amaCenter;
563
- gpSource = 'ama';
564
- const snapshotGridPriceOffsetPct = Number(amaSnapshot?.gridPriceOffsetPct);
565
- const hasGridPriceOffset = isGridRangeScalingWhitelisted
566
- && Number.isFinite(snapshotGridPriceOffsetPct)
567
- && snapshotGridPriceOffsetPct !== 0;
568
- gridPriceOffsetPct = hasGridPriceOffset ? snapshotGridPriceOffsetPct : 0;
569
- manager.logger?.log?.(`[DIAGNOSTIC] initializeGrid: gridPrice=AMA center ${gp.toFixed(8)}`, 'info');
535
+ catch (err) {
536
+ manager.logger?.log?.(`Failed to derive market price: ${err.message}`, 'warn');
537
+ throw err; // Re-throw to prevent "pool" string reaching numeric math
538
+ }
539
+ }
540
+ const configuredStartPrice = manager.config.startPrice;
541
+ const configuredMinPrice = manager.config.minPrice;
542
+ const configuredMaxPrice = manager.config.maxPrice;
543
+ const mp = Number(manager.config.startPrice);
544
+ // Derive gridPrice — separate reference for x-factor bounds (may differ from startPrice).
545
+ // Supported modes:
546
+ // - numeric: fixed value
547
+ // - "pool" / "book": live blockchain price for the pair
548
+ // - "ama"/"ama1".."ama4": center from profiles/orders/<botKey>.dynamicgrid.json
549
+ // - null/anything else: fallback to startPrice (backward-compatible)
550
+ let gp = mp;
551
+ let gpSource = 'startPrice';
552
+ let amaSnapshot = null;
553
+ const whitelistFlags = getWhitelistFlags(manager.config.botKey);
554
+ const isGridRangeScalingWhitelisted = whitelistFlags.asymmetricBounds === true;
555
+ let gridPriceOffsetPct = 0;
556
+ const gpRaw = manager.config.gridPrice;
557
+ const gpMode = (typeof gpRaw === 'string') ? gpRaw.trim().toLowerCase() : null;
558
+ if (typeof gpRaw === 'number' && Number.isFinite(gpRaw) && gpRaw > 0) {
559
+ gp = gpRaw;
560
+ gpSource = 'numeric';
561
+ manager.logger?.log?.(`[DIAGNOSTIC] initializeGrid: gridPrice=numeric ${gp.toFixed(8)}`, 'info');
562
+ }
563
+ else if (gpMode === 'pool' || gpMode === 'book') {
564
+ try {
565
+ const { BitShares } = require('../bitshares_client');
566
+ const derived = await derivePrice(BitShares, manager.config.assetA, manager.config.assetB, gpMode);
567
+ if (derived) {
568
+ gp = Number(derived);
569
+ gpSource = gpMode;
570
+ manager.logger?.log?.(`[DIAGNOSTIC] initializeGrid: gridPrice=${gpMode} ${gp.toFixed(8)}`, 'info');
570
571
  }
571
572
  else {
572
- manager.logger?.log?.(`initializeGrid: AMA center unavailable for gridPrice, falling back to startPrice`, 'warn');
573
+ manager.logger?.log?.(`initializeGrid: ${gpMode} gridPrice unavailable, falling back to startPrice`, 'warn');
573
574
  }
574
575
  }
575
- const minP = resolveConfiguredPriceBound(manager.config.minPrice, DEFAULT_CONFIG.minPrice, gp, 'min');
576
- const maxP = resolveConfiguredPriceBound(manager.config.maxPrice, DEFAULT_CONFIG.maxPrice, gp, 'max');
577
- // Asymmetric bound adjustment: widen the bound in the AMA trend direction
578
- // and tighten the opposite side, giving the grid more room when the center
579
- // trails price. Uses slope data from the dynamicgrid.json snapshot.
580
- let resolvedMinP = minP;
581
- let resolvedMaxP = maxP;
582
- let rangeScalingFactor = null;
583
- if (gpSource === 'ama' && Number.isFinite(minP) && Number.isFinite(maxP)
584
- && isGridRangeScalingWhitelisted) {
585
- const dw = amaSnapshot?.dynamicWeights;
586
- // Fallback to root-level asymmetricBounds when dynamicWeights is
587
- // absent (asymmetricBounds: true without dynamicWeight: true).
588
- const rootBounds = !dw && amaSnapshot?.asymmetricBounds
589
- && typeof amaSnapshot.asymmetricBounds === 'object'
590
- ? amaSnapshot.asymmetricBounds
591
- : null;
592
- if (dw) {
593
- const maxAsymmetryFactor = resolveMaxAsymmetryFactor(manager.config.asymmetricBounds?.maxAsymmetryFactor, dw?.maxAsymmetryFactor, MARKET_ADAPTER.ASYMMETRIC_BOUNDS_MAX_ASYMMETRY_FACTOR);
594
- const adjustment = applyAsymmetricBounds({
595
- centerPrice: gp,
596
- minPrice: minP,
597
- maxPrice: maxP,
598
- trend: dw?.trend,
599
- slopeOffset: dw?.slopeOffset,
600
- maxSlopeOffset: dw?.maxSlopeOffset,
601
- maxAsymmetryFactor,
602
- });
603
- if (Number.isFinite(adjustment.appliedAsymmetryFactor)) {
604
- resolvedMinP = adjustment.resolvedMinPrice;
605
- resolvedMaxP = adjustment.resolvedMaxPrice;
606
- rangeScalingFactor = Number(adjustment.appliedAsymmetryFactor);
607
- manager.logger?.log?.(`[BOUND-ASYMMETRY] trend=${dw.trend} slopeOffset=${dw.slopeOffset.toFixed(4)} `
608
- + `raw=${(adjustment.rawAsymmetryFactor * 100).toFixed(1)}% `
609
- + `cap=${(maxAsymmetryFactor * 100).toFixed(0)}% `
610
- + `asymmetry=${(adjustment.appliedAsymmetryFactor * 100).toFixed(1)}% `
611
- + `min ${minP.toFixed(8)}→${resolvedMinP.toFixed(8)} `
612
- + `max ${maxP.toFixed(8)}→${resolvedMaxP.toFixed(8)}`, 'info');
613
- }
614
- }
615
- else if (rootBounds && Number.isFinite(rootBounds.appliedAsymmetryFactor)
616
- && (rootBounds.trend === 'UP' || rootBounds.trend === 'DOWN')) {
617
- const asymmetry = Number(rootBounds.appliedAsymmetryFactor);
618
- const rootTrend = rootBounds.trend;
619
- if (rootTrend === 'DOWN') {
620
- resolvedMinP = gp / ((gp / minP) * (1 + asymmetry));
621
- resolvedMaxP = gp * ((maxP / gp) * (1 - asymmetry));
622
- }
623
- else {
624
- resolvedMinP = gp / ((gp / minP) * (1 - asymmetry));
625
- resolvedMaxP = gp * ((maxP / gp) * (1 + asymmetry));
626
- }
627
- rangeScalingFactor = asymmetry;
628
- manager.logger?.log?.(`[BOUND-ASYMMETRY] trend=${rootTrend} `
629
- + `asymmetry=${(asymmetry * 100).toFixed(1)}% `
630
- + `(root-level) min ${minP.toFixed(8)}→${resolvedMinP.toFixed(8)} `
576
+ catch (err) {
577
+ manager.logger?.log?.(`initializeGrid: ${gpMode} gridPrice derivation failed: ${err.message}`, 'warn');
578
+ }
579
+ }
580
+ else if (/^ama(?:[1-4])?$/.test(gpMode || '')) {
581
+ amaSnapshot = loadAmaCenterSnapshot(manager.config.botKey);
582
+ const amaCenter = amaSnapshot?.gridCenterPrice ?? loadAmaCenterPrice(manager.config.botKey);
583
+ if (Number.isFinite(amaCenter) && amaCenter > 0) {
584
+ gp = amaCenter;
585
+ gpSource = 'ama';
586
+ const snapshotGridPriceOffsetPct = Number(amaSnapshot?.gridPriceOffsetPct);
587
+ const hasGridPriceOffset = isGridRangeScalingWhitelisted
588
+ && Number.isFinite(snapshotGridPriceOffsetPct)
589
+ && snapshotGridPriceOffsetPct !== 0;
590
+ gridPriceOffsetPct = hasGridPriceOffset ? snapshotGridPriceOffsetPct : 0;
591
+ manager.logger?.log?.(`[DIAGNOSTIC] initializeGrid: gridPrice=AMA center ${gp.toFixed(8)}`, 'info');
592
+ }
593
+ else {
594
+ manager.logger?.log?.(`initializeGrid: AMA center unavailable for gridPrice, falling back to startPrice`, 'warn');
595
+ }
596
+ }
597
+ const minP = resolveConfiguredPriceBound(manager.config.minPrice, DEFAULT_CONFIG.minPrice, gp, 'min');
598
+ const maxP = resolveConfiguredPriceBound(manager.config.maxPrice, DEFAULT_CONFIG.maxPrice, gp, 'max');
599
+ // Asymmetric bound adjustment: widen the bound in the AMA trend direction
600
+ // and tighten the opposite side, giving the grid more room when the center
601
+ // trails price. Uses slope data from the dynamicgrid.json snapshot.
602
+ let resolvedMinP = minP;
603
+ let resolvedMaxP = maxP;
604
+ let rangeScalingFactor = null;
605
+ if (gpSource === 'ama' && Number.isFinite(minP) && Number.isFinite(maxP)
606
+ && isGridRangeScalingWhitelisted) {
607
+ const dw = amaSnapshot?.dynamicWeights;
608
+ // Fallback to root-level asymmetricBounds when dynamicWeights is
609
+ // absent (asymmetricBounds: true without dynamicWeight: true).
610
+ const rootBounds = !dw && amaSnapshot?.asymmetricBounds
611
+ && typeof amaSnapshot.asymmetricBounds === 'object'
612
+ ? amaSnapshot.asymmetricBounds
613
+ : null;
614
+ if (dw) {
615
+ const maxAsymmetryFactor = resolveMaxAsymmetryFactor(manager.config.asymmetricBounds?.maxAsymmetryFactor, dw?.maxAsymmetryFactor, MARKET_ADAPTER.ASYMMETRIC_BOUNDS_MAX_ASYMMETRY_FACTOR);
616
+ const adjustment = applyAsymmetricBounds({
617
+ centerPrice: gp,
618
+ minPrice: minP,
619
+ maxPrice: maxP,
620
+ trend: dw?.trend,
621
+ slopeOffset: dw?.slopeOffset,
622
+ maxSlopeOffset: dw?.maxSlopeOffset,
623
+ maxAsymmetryFactor,
624
+ });
625
+ if (Number.isFinite(adjustment.appliedAsymmetryFactor)) {
626
+ resolvedMinP = adjustment.resolvedMinPrice;
627
+ resolvedMaxP = adjustment.resolvedMaxPrice;
628
+ rangeScalingFactor = Number(adjustment.appliedAsymmetryFactor);
629
+ manager.logger?.log?.(`[BOUND-ASYMMETRY] trend=${dw.trend} slopeOffset=${dw.slopeOffset.toFixed(4)} `
630
+ + `raw=${(adjustment.rawAsymmetryFactor * 100).toFixed(1)}% `
631
+ + `cap=${(maxAsymmetryFactor * 100).toFixed(0)}% `
632
+ + `asymmetry=${(adjustment.appliedAsymmetryFactor * 100).toFixed(1)}% `
633
+ + `min ${minP.toFixed(8)}→${resolvedMinP.toFixed(8)} `
631
634
  + `max ${maxP.toFixed(8)}→${resolvedMaxP.toFixed(8)}`, 'info');
632
635
  }
633
636
  }
634
- let gridStartPrice = mp;
635
- let offsetAdjustedStartPrice = gridStartPrice;
636
- if (gpSource === 'ama' && gridPriceOffsetPct !== 0 && Number.isFinite(gridStartPrice) && gridStartPrice > 0) {
637
- const adjustedMarketPrice = gridStartPrice * (1 + (gridPriceOffsetPct / 100));
638
- manager.logger?.log?.(`[DIAGNOSTIC] initializeGrid: applying AMA market-price offset ${gridPriceOffsetPct.toFixed(3)}% `
639
- + `to startPrice ${gridStartPrice.toFixed(8)} -> ${adjustedMarketPrice.toFixed(8)}`, 'info');
640
- gridStartPrice = adjustedMarketPrice;
641
- offsetAdjustedStartPrice = adjustedMarketPrice;
642
- }
643
- if (!(gridStartPrice >= resolvedMinP && gridStartPrice <= resolvedMaxP)) {
644
- if (Number.isFinite(gp) && gp > 0 && gp >= resolvedMinP && gp <= resolvedMaxP) {
645
- gridStartPrice = gp;
646
- manager.logger?.log?.(`initializeGrid: startPrice (${mp}) outside bounds [${resolvedMinP}, ${resolvedMaxP}]; using gridPrice center ${gp}`, 'warn');
637
+ else if (rootBounds && Number.isFinite(rootBounds.appliedAsymmetryFactor)
638
+ && (rootBounds.trend === 'UP' || rootBounds.trend === 'DOWN')) {
639
+ const asymmetry = Number(rootBounds.appliedAsymmetryFactor);
640
+ const rootTrend = rootBounds.trend;
641
+ if (rootTrend === 'DOWN') {
642
+ resolvedMinP = gp / ((gp / minP) * (1 + asymmetry));
643
+ resolvedMaxP = gp * ((maxP / gp) * (1 - asymmetry));
647
644
  }
648
645
  else {
649
- const clamped = Math.min(resolvedMaxP, Math.max(resolvedMinP, gridStartPrice));
650
- manager.logger?.log?.(`initializeGrid: startPrice (${mp}) outside bounds [${resolvedMinP}, ${resolvedMaxP}]; clamping to ${clamped}`, 'warn');
651
- gridStartPrice = clamped;
646
+ resolvedMinP = gp / ((gp / minP) * (1 - asymmetry));
647
+ resolvedMaxP = gp * ((maxP / gp) * (1 + asymmetry));
652
648
  }
649
+ rangeScalingFactor = asymmetry;
650
+ manager.logger?.log?.(`[BOUND-ASYMMETRY] trend=${rootTrend} `
651
+ + `asymmetry=${(asymmetry * 100).toFixed(1)}% `
652
+ + `(root-level) min ${minP.toFixed(8)}→${resolvedMinP.toFixed(8)} `
653
+ + `max ${maxP.toFixed(8)}→${resolvedMaxP.toFixed(8)}`, 'info');
653
654
  }
654
- manager.config.minPrice = resolvedMinP;
655
- manager.config.maxPrice = resolvedMaxP;
656
- manager._lastGridPricingContext = {
657
- gridPrice: gp,
658
- gridPriceOffsetPct,
659
- offsetAdjustedStartPrice,
660
- startPrice: gridStartPrice,
661
- configuredMinPrice,
662
- configuredMaxPrice,
663
- rangeScalingFactor
664
- };
665
- // Ensure percentage-based funds are resolved before sizing
666
- try {
667
- if (manager.accountId && !manager.accountTotals) {
668
- await manager.waitForAccountTotals(TIMING.ACCOUNT_TOTALS_TIMEOUT_MS);
669
- }
670
- }
671
- catch (e) {
672
- manager.logger?.log?.(`Failed to load account totals: ${e.message}`, 'warn');
673
- // FIX: Add error handling - cannot proceed with grid initialization without account totals
674
- // Continuing would create grid with 0 fund allocation, rendering it non-functional
675
- throw new Error(`Cannot initialize grid without account totals: ${e.message}`);
676
- }
677
- const { orders, boundaryIdx, initialSpreadCount } = Grid.createOrderGrid({
678
- ...manager.config,
679
- startPrice: gridStartPrice,
680
- minPrice: resolvedMinP,
681
- maxPrice: resolvedMaxP,
682
- });
683
- // RC-8: Update boundary with notification to dependent systems
684
- // Persist master boundary for StrategyEngine
685
- if (manager.boundaryIdx !== boundaryIdx) {
686
- manager.boundaryIdx = boundaryIdx;
687
- // RC-8: Notify StrategyEngine of boundary change (if method exists)
688
- if (typeof manager.notifyBoundaryUpdate === 'function') {
689
- try {
690
- manager.notifyBoundaryUpdate(boundaryIdx);
691
- }
692
- catch (err) {
693
- manager.logger?.log?.(`Error notifying boundary update: ${err.message}`, 'warn');
694
- }
695
- }
655
+ }
656
+ let gridStartPrice = mp;
657
+ let offsetAdjustedStartPrice = gridStartPrice;
658
+ if (gpSource === 'ama' && gridPriceOffsetPct !== 0 && Number.isFinite(gridStartPrice) && gridStartPrice > 0) {
659
+ const adjustedMarketPrice = gridStartPrice * (1 + (gridPriceOffsetPct / 100));
660
+ manager.logger?.log?.(`[DIAGNOSTIC] initializeGrid: applying AMA market-price offset ${gridPriceOffsetPct.toFixed(3)}% `
661
+ + `to startPrice ${gridStartPrice.toFixed(8)} -> ${adjustedMarketPrice.toFixed(8)}`, 'info');
662
+ gridStartPrice = adjustedMarketPrice;
663
+ offsetAdjustedStartPrice = adjustedMarketPrice;
664
+ }
665
+ if (!(gridStartPrice >= resolvedMinP && gridStartPrice <= resolvedMaxP)) {
666
+ if (Number.isFinite(gp) && gp > 0 && gp >= resolvedMinP && gp <= resolvedMaxP) {
667
+ gridStartPrice = gp;
668
+ manager.logger?.log?.(`initializeGrid: startPrice (${mp}) outside bounds [${resolvedMinP}, ${resolvedMaxP}]; using gridPrice center ${gp}`, 'warn');
696
669
  }
697
- const minSellSize = getMinAbsoluteOrderSize(ORDER_TYPES.SELL, manager.assets);
698
- const minBuySize = getMinAbsoluteOrderSize(ORDER_TYPES.BUY, manager.assets);
699
- const { A: precA, B: precB } = getPrecisionsForManager(manager.assets);
700
- // Use centralized sizing context for both sides.
701
- // Resolve funds once upfront so both contexts share the same snapshot,
702
- // avoiding a redundant recalculateFunds inside the second _getSizingContext call.
703
- await manager.recalculateFunds();
704
- const sellCtx = await Grid._getSizingContext(manager, 'sell', { skipRecalc: true });
705
- const buyCtx = await Grid._getSizingContext(manager, 'buy', { skipRecalc: true });
706
- if (!sellCtx || !buyCtx)
707
- throw new Error('Failed to retrieve sizing context for grid initialization');
708
- let sizedOrders = calculateOrderSizes(orders, manager.config, sellCtx.budget, buyCtx.budget, minSellSize, minBuySize, precA, precB);
709
- // Verification of sizes
710
- const sells = filterOrdersByType(sizedOrders, ORDER_TYPES.SELL).map(o => Number(o.size || 0));
711
- const buys = filterOrdersByType(sizedOrders, ORDER_TYPES.BUY).map(o => Number(o.size || 0));
712
- if (checkSizesBeforeMinimum(sells, minSellSize, precA) || checkSizesBeforeMinimum(buys, minBuySize, precB)) {
713
- throw new Error('Calculated orders fall below minimum allowable size.');
670
+ else {
671
+ const clamped = Math.min(resolvedMaxP, Math.max(resolvedMinP, gridStartPrice));
672
+ manager.logger?.log?.(`initializeGrid: startPrice (${mp}) outside bounds [${resolvedMinP}, ${resolvedMaxP}]; clamping to ${clamped}`, 'warn');
673
+ gridStartPrice = clamped;
714
674
  }
715
- // Check for warning if orders are near minimal size (regression fix)
716
- const warningSellSize = minSellSize > 0 ? getMinAbsoluteOrderSize(ORDER_TYPES.SELL, manager.assets, 100) : 0;
717
- const warningBuySize = minBuySize > 0 ? getMinAbsoluteOrderSize(ORDER_TYPES.BUY, manager.assets, 100) : 0;
718
- if (checkSizeThreshold(sells, warningSellSize, precA, false) || checkSizeThreshold(buys, warningBuySize, precB, false)) {
719
- manager.logger?.log?.("WARNING: Order grid contains orders near minimum size. To ensure the bot runs properly, consider increasing the funds of your bot.", "warn");
675
+ }
676
+ manager.config.minPrice = resolvedMinP;
677
+ manager.config.maxPrice = resolvedMaxP;
678
+ manager._lastGridPricingContext = {
679
+ gridPrice: gp,
680
+ gridPriceOffsetPct,
681
+ offsetAdjustedStartPrice,
682
+ startPrice: gridStartPrice,
683
+ configuredMinPrice,
684
+ configuredMaxPrice,
685
+ rangeScalingFactor
686
+ };
687
+ // Ensure percentage-based funds are resolved before sizing
688
+ try {
689
+ if (manager.accountId && !manager.accountTotals) {
690
+ await manager.waitForAccountTotals(TIMING.ACCOUNT_TOTALS_TIMEOUT_MS);
720
691
  }
721
- // RC-2: Wrap atomic changes in grid lock
722
- await manager._gridLock.acquire(async () => {
723
- Grid._clearOrderCachesLogic(manager);
724
- manager.resetFunds();
725
- manager.pauseRecalcLogging();
726
- manager.pauseFundRecalc();
692
+ }
693
+ catch (e) {
694
+ manager.logger?.log?.(`Failed to load account totals: ${e.message}`, 'warn');
695
+ // FIX: Add error handling - cannot proceed with grid initialization without account totals
696
+ // Continuing would create grid with 0 fund allocation, rendering it non-functional
697
+ throw new Error(`Cannot initialize grid without account totals: ${e.message}`);
698
+ }
699
+ const { orders, boundaryIdx, initialSpreadCount } = createOrderGrid({
700
+ ...manager.config,
701
+ startPrice: gridStartPrice,
702
+ minPrice: resolvedMinP,
703
+ maxPrice: resolvedMaxP,
704
+ });
705
+ // RC-8: Update boundary with notification to dependent systems
706
+ // Persist master boundary for StrategyEngine
707
+ if (manager.boundaryIdx !== boundaryIdx) {
708
+ manager.boundaryIdx = boundaryIdx;
709
+ // RC-8: Notify StrategyEngine of boundary change (if method exists)
710
+ if (typeof manager.notifyBoundaryUpdate === 'function') {
727
711
  try {
728
- // RC-2: Use _applyOrderUpdate (PRIVATE/UNLOCKED)
729
- for (const order of sizedOrders) {
730
- await manager._applyOrderUpdate(order, 'grid-init', { skipAccounting: true });
731
- }
712
+ manager.notifyBoundaryUpdate(boundaryIdx);
732
713
  }
733
- finally {
734
- await manager.resumeFundRecalc();
735
- manager.resumeRecalcLogging();
714
+ catch (err) {
715
+ manager.logger?.log?.(`Error notifying boundary update: ${err.message}`, 'warn');
736
716
  }
737
- // RC-6: Spread count updates protected by grid lock
738
- manager.targetSpreadCount = initialSpreadCount.buy + initialSpreadCount.sell;
739
- manager.currentSpreadCount = manager.targetSpreadCount;
740
- });
741
- // FIX: Use consistent optional chaining pattern for all logger calls
742
- manager.logger?.log?.(`Initialized grid with ${orders.length} orders.`, 'info');
743
- manager.logger?.logFundsStatus?.(manager);
744
- manager.logger?.logOrderGrid?.(Array.from(manager.orders.values()), gridStartPrice);
745
- }
746
- /**
747
- * Full grid resynchronization from blockchain state.
748
- * @param {import('./types').OrderManager} manager - The manager instance.
749
- * @param {Object} opts - Options for resynchronization.
750
- * @param {Function} opts.readOpenOrdersFn - Function to read open orders.
751
- * @param {Object} opts.chainOrders - Chain orders module.
752
- * @param {string} opts.account - Account name.
753
- * @param {string} opts.privateKey - Private key.
754
- * @returns {Promise<void>}
755
- */
756
- static async recalculateGrid(manager, opts) {
757
- const { readOpenOrdersFn, chainOrders, account, privateKey } = opts;
758
- // Suppress invariant warnings during full resync
759
- manager.startBootstrap();
717
+ }
718
+ }
719
+ const minSellSize = getMinAbsoluteOrderSize(ORDER_TYPES.SELL, manager.assets);
720
+ const minBuySize = getMinAbsoluteOrderSize(ORDER_TYPES.BUY, manager.assets);
721
+ const { A: precA, B: precB } = getPrecisionsForManager(manager.assets);
722
+ // Use centralized sizing context for both sides.
723
+ // Resolve funds once upfront so both contexts share the same snapshot,
724
+ // avoiding a redundant recalculateFunds inside the second _getSizingContext call.
725
+ await manager.recalculateFunds();
726
+ const sellCtx = await _getSizingContext(manager, 'sell', { skipRecalc: true });
727
+ const buyCtx = await _getSizingContext(manager, 'buy', { skipRecalc: true });
728
+ if (!sellCtx || !buyCtx)
729
+ throw new Error('Failed to retrieve sizing context for grid initialization');
730
+ let sizedOrders = calculateOrderSizes(orders, manager.config, sellCtx.budget, buyCtx.budget, minSellSize, minBuySize, precA, precB);
731
+ // Verification of sizes
732
+ const sells = filterOrdersByType(sizedOrders, ORDER_TYPES.SELL).map(o => Number(o.size || 0));
733
+ const buys = filterOrdersByType(sizedOrders, ORDER_TYPES.BUY).map(o => Number(o.size || 0));
734
+ if (checkSizesBeforeMinimum(sells, minSellSize, precA) || checkSizesBeforeMinimum(buys, minBuySize, precB)) {
735
+ throw new Error('Calculated orders fall below minimum allowable size.');
736
+ }
737
+ // Check for warning if orders are near minimal size (regression fix)
738
+ const warningSellSize = minSellSize > 0 ? getMinAbsoluteOrderSize(ORDER_TYPES.SELL, manager.assets, 100) : 0;
739
+ const warningBuySize = minBuySize > 0 ? getMinAbsoluteOrderSize(ORDER_TYPES.BUY, manager.assets, 100) : 0;
740
+ if (checkSizeThreshold(sells, warningSellSize, precA, false) || checkSizeThreshold(buys, warningBuySize, precB, false)) {
741
+ manager.logger?.log?.("WARNING: Order grid contains orders near minimum size. To ensure the bot runs properly, consider increasing the funds of your bot.", "warn");
742
+ }
743
+ // RC-2: Wrap atomic changes in grid lock
744
+ await manager._gridLock.acquire(async () => {
745
+ _clearOrderCachesLogic(manager);
746
+ manager.resetFunds();
747
+ manager.pauseRecalcLogging();
748
+ manager.pauseFundRecalc();
760
749
  try {
761
- // FIX: Use consistent optional chaining pattern for logger calls
762
- manager.logger?.log?.('Starting full resync...', 'info');
763
- await manager._initializeAssets();
764
- await manager.fetchAccountTotals();
765
- const chainOpenOrders = await readOpenOrdersFn();
766
- if (!Array.isArray(chainOpenOrders))
767
- return;
768
- // CRITICAL: Filter out PARTIAL orders before synchronizing - they're from old grid
769
- // and shouldn't be part of the fresh regenerated grid structure
770
- const activeOrders = chainOpenOrders.filter(o => o.state !== ORDER_STATES.PARTIAL);
771
- await manager.syncFromOpenOrders(activeOrders, { skipAccounting: true, fillLockAlreadyHeld: true });
772
- manager.resetFunds();
773
- await manager.persistGrid();
774
- await Grid.initializeGrid(manager);
775
- const { reconcileGridOrders } = require('./grid_reconcile');
776
- // FIX: Add error context for debugging grid recalculation issues
777
- try {
778
- await reconcileGridOrders({ manager, config: manager.config, account, privateKey, chainOrders, chainOpenOrders, fillLockAlreadyHeld: true });
779
- }
780
- catch (err) {
781
- manager.logger?.log?.(`Error during startup order reconciliation: ${err.message}`, 'error');
782
- throw new Error(`Grid recalculation failed during order reconciliation: ${err.message}`);
750
+ // RC-2: Use _applyOrderUpdate (PRIVATE/UNLOCKED)
751
+ for (const order of sizedOrders) {
752
+ await manager._applyOrderUpdate(order, 'grid-init', { skipAccounting: true });
783
753
  }
784
- // FIX: Use consistent optional chaining pattern for logger calls
785
- manager.logger?.log?.('Full resync complete.', 'info');
786
754
  }
787
755
  finally {
788
- manager.finishBootstrap();
756
+ await manager.resumeFundRecalc();
757
+ manager.resumeRecalcLogging();
758
+ }
759
+ // RC-6: Spread count updates protected by grid lock
760
+ manager.targetSpreadCount = initialSpreadCount.buy + initialSpreadCount.sell;
761
+ manager.currentSpreadCount = manager.targetSpreadCount;
762
+ });
763
+ // FIX: Use consistent optional chaining pattern for all logger calls
764
+ manager.logger?.log?.(`Initialized grid with ${orders.length} orders.`, 'info');
765
+ manager.logger?.logFundsStatus?.(manager);
766
+ manager.logger?.logOrderGrid?.(Array.from(manager.orders.values()), gridStartPrice);
767
+ }
768
+ /**
769
+ * Full grid resynchronization from blockchain state.
770
+ * @param {import('./types').OrderManager} manager - The manager instance.
771
+ * @param {Object} opts - Options for resynchronization.
772
+ * @param {Function} opts.readOpenOrdersFn - Function to read open orders.
773
+ * @param {Object} opts.chainOrders - Chain orders module.
774
+ * @param {string} opts.account - Account name.
775
+ * @param {string} opts.privateKey - Private key.
776
+ * @returns {Promise<void>}
777
+ */
778
+ async function recalculateGrid(manager, opts) {
779
+ const { readOpenOrdersFn, chainOrders, account, privateKey } = opts;
780
+ // Suppress invariant warnings during full resync
781
+ manager.startBootstrap();
782
+ try {
783
+ // FIX: Use consistent optional chaining pattern for logger calls
784
+ manager.logger?.log?.('Starting full resync...', 'info');
785
+ await manager._initializeAssets();
786
+ await manager.fetchAccountTotals();
787
+ const chainOpenOrders = await readOpenOrdersFn();
788
+ if (!Array.isArray(chainOpenOrders))
789
+ return;
790
+ // CRITICAL: Filter out PARTIAL orders before synchronizing - they're from old grid
791
+ // and shouldn't be part of the fresh regenerated grid structure
792
+ const activeOrders = chainOpenOrders.filter(o => o.state !== ORDER_STATES.PARTIAL);
793
+ await manager.syncFromOpenOrders(activeOrders, { skipAccounting: true });
794
+ manager.resetFunds();
795
+ await manager.persistGrid();
796
+ await initializeGrid(manager);
797
+ const { reconcileGridOrders } = require('./grid_reconcile');
798
+ // FIX: Add error context for debugging grid recalculation issues
799
+ try {
800
+ await reconcileGridOrders({ manager, config: manager.config, account, privateKey, chainOrders, chainOpenOrders });
789
801
  }
802
+ catch (err) {
803
+ manager.logger?.log?.(`Error during startup order reconciliation: ${err.message}`, 'error');
804
+ throw new Error(`Grid recalculation failed during order reconciliation: ${err.message}`);
805
+ }
806
+ // FIX: Use consistent optional chaining pattern for logger calls
807
+ manager.logger?.log?.('Full resync complete.', 'info');
790
808
  }
791
- /**
792
- * Check for grid divergence and trigger update if threshold is met.
793
- *
794
- * @param {import('./types').OrderManager} manager - Manager instance with order state
795
- * @returns {import('./types').SideUpdateFlags}
796
- */
797
- static checkAndUpdateGridIfNeeded(manager) {
798
- const threshold = manager.config?.gridLimits?.GRID_REGENERATION_PERCENTAGE;
799
- const chainSnap = manager.getChainFundsSnapshot();
800
- const gridBuy = Number(manager.funds?.total?.grid?.buy || 0);
801
- const gridSell = Number(manager.funds?.total?.grid?.sell || 0);
802
- const result = { buyUpdated: false, sellUpdated: false };
803
- const sides = [
804
- { name: 'buy', grid: gridBuy, orderType: ORDER_TYPES.BUY },
805
- { name: 'sell', grid: gridSell, orderType: ORDER_TYPES.SELL }
806
- ];
807
- for (const s of sides) {
808
- if (s.grid <= 0)
809
- continue;
810
- const availableFunds = calculateAvailableFundsValue(s.name, manager.accountTotals, manager.funds, manager.config.assetA, manager.config.assetB, manager.config.activeOrders, manager.config.min_BTS_value, manager.config.feeParams ?? null);
811
- // Denominator: side's allocated capital (or chain total fallback).
812
- const allocated = s.name === 'buy' ? chainSnap.allocatedBuy : chainSnap.allocatedSell;
813
- const denominator = (allocated > 0) ? allocated : (s.grid + availableFunds);
814
- const ratio = (denominator > 0) ? (availableFunds / denominator) * 100 : 0;
815
- manager.logger?.log?.(`[DIVERGENCE] ${s.name.toUpperCase()} ratio check: availableFunds=${availableFunds.toFixed(5)}, allocated=${allocated.toFixed(5)}, ratio=${ratio.toFixed(4)}% (threshold=${threshold}%) → ${ratio >= threshold ? 'TRIGGER' : 'no trigger'}`, 'debug');
816
- if (ratio >= threshold) {
817
- // RC-3: Use Set for automatic duplicate prevention
818
- if (!(manager._gridSidesUpdated instanceof Set))
819
- manager._gridSidesUpdated = new Set();
820
- manager._gridSidesUpdated.add(s.orderType);
821
- if (s.name === 'buy')
822
- result.buyUpdated = true;
823
- else
824
- result.sellUpdated = true;
825
- }
809
+ finally {
810
+ manager.finishBootstrap();
811
+ }
812
+ }
813
+ /**
814
+ * Check for grid divergence and trigger update if threshold is met.
815
+ *
816
+ * @param {import('./types').OrderManager} manager - Manager instance with order state
817
+ * @returns {import('./types').SideUpdateFlags}
818
+ */
819
+ function checkAndUpdateGridIfNeeded(manager) {
820
+ const threshold = manager.config?.gridLimits?.GRID_REGENERATION_PERCENTAGE;
821
+ const chainSnap = manager.getChainFundsSnapshot();
822
+ const gridBuy = Number(manager.funds?.total?.grid?.buy || 0);
823
+ const gridSell = Number(manager.funds?.total?.grid?.sell || 0);
824
+ const result = { buyUpdated: false, sellUpdated: false };
825
+ const sides = [
826
+ { name: 'buy', grid: gridBuy, orderType: ORDER_TYPES.BUY },
827
+ { name: 'sell', grid: gridSell, orderType: ORDER_TYPES.SELL }
828
+ ];
829
+ for (const s of sides) {
830
+ if (s.grid <= 0)
831
+ continue;
832
+ const availableFunds = calculateAvailableFundsValue(s.name, manager.accountTotals, manager.funds, manager.config.assetA, manager.config.assetB, manager.config.activeOrders, manager.config.min_BTS_value, manager.config.feeParams ?? null);
833
+ // Denominator: side's allocated capital (or chain total fallback).
834
+ const allocated = s.name === 'buy' ? chainSnap.allocatedBuy : chainSnap.allocatedSell;
835
+ const denominator = (allocated > 0) ? allocated : (s.grid + availableFunds);
836
+ const ratio = (denominator > 0) ? (availableFunds / denominator) * 100 : 0;
837
+ manager.logger?.log?.(`[DIVERGENCE] ${s.name.toUpperCase()} ratio check: availableFunds=${availableFunds.toFixed(5)}, allocated=${allocated.toFixed(5)}, ratio=${ratio.toFixed(4)}% (threshold=${threshold}%) → ${ratio >= threshold ? 'TRIGGER' : 'no trigger'}`, 'debug');
838
+ if (ratio >= threshold) {
839
+ // RC-3: Use Set for automatic duplicate prevention
840
+ if (!(manager._gridSidesUpdated instanceof Set))
841
+ manager._gridSidesUpdated = new Set();
842
+ manager._gridSidesUpdated.add(s.orderType);
843
+ if (s.name === 'buy')
844
+ result.buyUpdated = true;
845
+ else
846
+ result.sellUpdated = true;
826
847
  }
827
- return result;
828
- }
829
- /**
830
- * Standardize grid sizes using blockchain total context.
831
- *
832
- * FUND CAPPING STRATEGY:
833
- * =====================
834
- * During grid regeneration (e.g., after fills increase available funds),
835
- * this method recalculates all order sizes using geometric weighting.
836
- * However, ACTIVE/PARTIAL orders must not grow larger than currently available funds.
837
- *
838
- * Rationale for capping:
839
- * 1. POST-FILL EXPANSION PREVENTION: After a large fill, funds become available.
840
- * A naive size recalculation might expand orders, consuming all new capital.
841
- * Capping prevents this "resize explosion" by limiting growth to available free balance.
842
- * 2. VIRTUAL ORDER PROTECTION: Virtual orders (not yet placed) are uncapped,
843
- * allowing natural expansion when their slot comes up for placement.
844
- * 3. BLOCKCHAIN-BACKED CONSTRAINT: sideFreeAvailable tracks exactly what we can spend,
845
- * decreasing as commitments grow (proportional to realized delta).
846
- *
847
- * Fund Capping Algorithm:
848
- * ========================
849
- * For each ACTIVE/PARTIAL order slot:
850
- * 1. Calculate new size from geometric series
851
- * 2. If delta > 0 (growth):
852
- * - affordableDelta = min(delta, sideFreeAvailable)
853
- * - Cap growth to what we actually have: newSize = currentSize + affordableDelta
854
- * - Deduct from sideFreeAvailable (this spending is now committed)
855
- * 3. If delta < 0 (shrinkage):
856
- * - Release the freed capital back to sideFreeAvailable
857
- * - Allows later slots to grow into this freed capacity
858
- * 4. For VIRTUAL orders (not on-chain):
859
- * - Apply new size directly (no capping)
860
- * - They will be constrained when actually placed
861
- *
862
- * Example (2 slots, buy side, budget=1000, simplify to linear):
863
- * ========================================================
864
- * Initial: slot[0]=400 (ACTIVE), slot[1]=0 (VIRTUAL), sideFree=600
865
- * Recalc: newSizes=[500, 500]
866
- *
867
- * Process slot[0]:
868
- * - Type: ACTIVE, current=400, new=500, delta=+100
869
- * - affordableDelta = min(100, 600) = 100
870
- * - Apply: size=500 (full growth), sideFree=500
871
- *
872
- * Process slot[1]:
873
- * - Type: VIRTUAL (not capped), current=0, new=500, delta=+500
874
- * - Apply: size=500 (no cap check)
875
- * - Result: slot[1] ready for placement, will consume from sideFree when placed
876
- *
877
- * @param {import('./types').OrderManager} manager - OrderManager instance
878
- * @param {string} orderType - ORDER_TYPES.BUY or ORDER_TYPES.SELL
879
- * @param {Object} [options] - Options object
880
- * @param {import('./working_grid')} [options.workingGrid] - Working grid for COW pattern
881
- * @returns {Promise<{actions: Array, changed: boolean}|undefined>} - COW result or undefined
882
- * @private
883
- */
884
- static async _recalculateGridOrderSizesFromBlockchain(manager, orderType, options = {}) {
885
- if (!manager.assets)
886
- return options?.workingGrid ? { actions: [], changed: false } : undefined;
887
- const workingGrid = options?.workingGrid || null;
888
- const collectActions = !!workingGrid;
889
- const isBuy = orderType === ORDER_TYPES.BUY;
890
- const sideName = isBuy ? 'buy' : 'sell';
891
- // Use centralized sizing context (respects botFunds % allocation)
892
- const ctx = await Grid._getSizingContext(manager, sideName);
893
- if (!ctx)
894
- return collectActions ? { actions: [], changed: false } : undefined;
895
- // Get ALL slots for this side, sorted for calculateRotationOrderSizes
896
- // SELL: sorted ASC (Market to Edge)
897
- // BUY: sorted ASC (Edge to Market)
898
- const allSideSlots = Array.from(manager.orders.values())
899
- .filter(o => o.type === orderType)
900
- .sort((a, b) => a.price - b.price);
901
- if (allSideSlots.length === 0)
902
- return collectActions ? { actions: [], changed: false } : undefined;
903
- // Calculate geometric sizes for the ENTIRE rail
904
- const newSizes = calculateRotationOrderSizes(ctx.budget, 0, allSideSlots.length, orderType, manager.config, 0, ctx.precision);
905
- const actions = [];
906
- let changed = false;
907
- const freeKey = isBuy ? 'buyFree' : 'sellFree';
908
- let sideFreeAvailable = Number(manager.accountTotals?.[freeKey] || 0);
909
- if (!collectActions)
910
- manager.pauseRecalcLogging();
911
- try {
912
- // Apply new sizes to all slots on the side
913
- for (let i = 0; i < allSideSlots.length; i++) {
914
- const slot = allSideSlots[i];
915
- let newSize = newSizes[i] || 0;
916
- // FUND CAPPING FOR COMMITTED (ON-CHAIN) ORDERS:
917
- // Only ACTIVE/PARTIAL orders are constrained by available funds.
918
- // Virtual orders (not yet placed) will be constrained when they are actually placed.
919
- //
920
- // NOTE: BTS update fees are paid from BTS balance (separate from asset balance),
921
- // so they don't affect this asset-side size cap. Fee budgets are tracked in
922
- // funds.btsFeesOwed and reserved separately via btsFeesReservation.
923
- const isCommitted = isOrderOnChain(slot);
924
- if (isCommitted) {
925
- const currentSize = Number(slot.size || 0);
926
- const delta = newSize - currentSize;
927
- if (delta > 0) {
928
- // GROWTH: Cap to available free balance
929
- // This prevents aggressive expansion after fills
930
- const affordableDelta = Math.min(delta, Math.max(0, sideFreeAvailable));
931
- if (affordableDelta < delta) {
932
- // Cannot afford full growth; cap to what's available
933
- newSize = currentSize + affordableDelta;
934
- }
935
- sideFreeAvailable = Math.max(0, sideFreeAvailable - affordableDelta);
936
- }
937
- else if (delta < 0) {
938
- // SHRINKAGE: Release freed capital back for other slots
939
- sideFreeAvailable += Math.abs(delta);
848
+ }
849
+ return result;
850
+ }
851
+ /**
852
+ * Standardize grid sizes using blockchain total context.
853
+ *
854
+ * FUND CAPPING STRATEGY:
855
+ * =====================
856
+ * During grid regeneration (e.g., after fills increase available funds),
857
+ * this method recalculates all order sizes using geometric weighting.
858
+ * However, ACTIVE/PARTIAL orders must not grow larger than currently available funds.
859
+ *
860
+ * Rationale for capping:
861
+ * 1. POST-FILL EXPANSION PREVENTION: After a large fill, funds become available.
862
+ * A naive size recalculation might expand orders, consuming all new capital.
863
+ * Capping prevents this "resize explosion" by limiting growth to available free balance.
864
+ * 2. VIRTUAL ORDER PROTECTION: Virtual orders (not yet placed) are uncapped,
865
+ * allowing natural expansion when their slot comes up for placement.
866
+ * 3. BLOCKCHAIN-BACKED CONSTRAINT: sideFreeAvailable tracks exactly what we can spend,
867
+ * decreasing as commitments grow (proportional to realized delta).
868
+ *
869
+ * Fund Capping Algorithm:
870
+ * ========================
871
+ * For each ACTIVE/PARTIAL order slot:
872
+ * 1. Calculate new size from geometric series
873
+ * 2. If delta > 0 (growth):
874
+ * - affordableDelta = min(delta, sideFreeAvailable)
875
+ * - Cap growth to what we actually have: newSize = currentSize + affordableDelta
876
+ * - Deduct from sideFreeAvailable (this spending is now committed)
877
+ * 3. If delta < 0 (shrinkage):
878
+ * - Release the freed capital back to sideFreeAvailable
879
+ * - Allows later slots to grow into this freed capacity
880
+ * 4. For VIRTUAL orders (not on-chain):
881
+ * - Apply new size directly (no capping)
882
+ * - They will be constrained when actually placed
883
+ *
884
+ * Example (2 slots, buy side, budget=1000, simplify to linear):
885
+ * ========================================================
886
+ * Initial: slot[0]=400 (ACTIVE), slot[1]=0 (VIRTUAL), sideFree=600
887
+ * Recalc: newSizes=[500, 500]
888
+ *
889
+ * Process slot[0]:
890
+ * - Type: ACTIVE, current=400, new=500, delta=+100
891
+ * - affordableDelta = min(100, 600) = 100
892
+ * - Apply: size=500 (full growth), sideFree=500
893
+ *
894
+ * Process slot[1]:
895
+ * - Type: VIRTUAL (not capped), current=0, new=500, delta=+500
896
+ * - Apply: size=500 (no cap check)
897
+ * - Result: slot[1] ready for placement, will consume from sideFree when placed
898
+ *
899
+ * @param {import('./types').OrderManager} manager - OrderManager instance
900
+ * @param {string} orderType - ORDER_TYPES.BUY or ORDER_TYPES.SELL
901
+ * @param {Object} [options] - Options object
902
+ * @param {import('./working_grid')} [options.workingGrid] - Working grid for COW pattern
903
+ * @returns {Promise<{actions: Array, changed: boolean}|undefined>} - COW result or undefined
904
+ * @private
905
+ */
906
+ async function _recalculateGridOrderSizesFromBlockchain(manager, orderType, options = {}) {
907
+ if (!manager.assets)
908
+ return options?.workingGrid ? { actions: [], changed: false } : undefined;
909
+ const workingGrid = options?.workingGrid || null;
910
+ const collectActions = !!workingGrid;
911
+ const isBuy = orderType === ORDER_TYPES.BUY;
912
+ const sideName = isBuy ? 'buy' : 'sell';
913
+ // Use centralized sizing context (respects botFunds % allocation)
914
+ const ctx = await _getSizingContext(manager, sideName);
915
+ if (!ctx)
916
+ return collectActions ? { actions: [], changed: false } : undefined;
917
+ // Get ALL slots for this side, sorted for calculateRotationOrderSizes
918
+ // SELL: sorted ASC (Market to Edge)
919
+ // BUY: sorted ASC (Edge to Market)
920
+ const allSideSlots = Array.from(manager.orders.values())
921
+ .filter(o => o.type === orderType)
922
+ .sort((a, b) => a.price - b.price);
923
+ if (allSideSlots.length === 0)
924
+ return collectActions ? { actions: [], changed: false } : undefined;
925
+ // Calculate geometric sizes for the ENTIRE rail
926
+ const newSizes = calculateRotationOrderSizes(ctx.budget, 0, allSideSlots.length, orderType, manager.config, 0, ctx.precision);
927
+ const actions = [];
928
+ let changed = false;
929
+ const freeKey = isBuy ? 'buyFree' : 'sellFree';
930
+ let sideFreeAvailable = Number(manager.accountTotals?.[freeKey] || 0);
931
+ if (!collectActions)
932
+ manager.pauseRecalcLogging();
933
+ try {
934
+ // Apply new sizes to all slots on the side
935
+ for (let i = 0; i < allSideSlots.length; i++) {
936
+ const slot = allSideSlots[i];
937
+ let newSize = newSizes[i] || 0;
938
+ // FUND CAPPING FOR COMMITTED (ON-CHAIN) ORDERS:
939
+ // Only ACTIVE/PARTIAL orders are constrained by available funds.
940
+ // Virtual orders (not yet placed) will be constrained when they are actually placed.
941
+ //
942
+ // NOTE: BTS update fees are paid from BTS balance (separate from asset balance),
943
+ // so they don't affect this asset-side size cap. Fee budgets are tracked in
944
+ // funds.btsFeesOwed and reserved separately via btsFeesReservation.
945
+ const isCommitted = isOrderOnChain(slot);
946
+ if (isCommitted) {
947
+ const currentSize = Number(slot.size || 0);
948
+ const delta = newSize - currentSize;
949
+ if (delta > 0) {
950
+ // GROWTH: Cap to available free balance
951
+ // This prevents aggressive expansion after fills
952
+ const affordableDelta = Math.min(delta, Math.max(0, sideFreeAvailable));
953
+ if (affordableDelta < delta) {
954
+ // Cannot afford full growth; cap to what's available
955
+ newSize = currentSize + affordableDelta;
940
956
  }
957
+ sideFreeAvailable = Math.max(0, sideFreeAvailable - affordableDelta);
941
958
  }
942
- // Use integer comparison to avoid redundant updates from float noise
943
- const currentSizeInt = floatToBlockchainInt(slot.size || 0, ctx.precision);
944
- const newSizeInt = floatToBlockchainInt(newSize, ctx.precision);
945
- if (slot.size === undefined || currentSizeInt !== newSizeInt) {
946
- changed = true;
947
- if (collectActions) {
948
- workingGrid.set(slot.id, {
949
- ...slot,
950
- size: newSize
951
- });
952
- if (isCommitted && hasOnChainId(slot)) {
953
- actions.push({
954
- type: COW_ACTIONS.UPDATE,
959
+ else if (delta < 0) {
960
+ // SHRINKAGE: Release freed capital back for other slots
961
+ sideFreeAvailable += Math.abs(delta);
962
+ }
963
+ }
964
+ // Use integer comparison to avoid redundant updates from float noise
965
+ const currentSizeInt = floatToBlockchainInt(slot.size || 0, ctx.precision);
966
+ const newSizeInt = floatToBlockchainInt(newSize, ctx.precision);
967
+ if (slot.size === undefined || currentSizeInt !== newSizeInt) {
968
+ changed = true;
969
+ if (collectActions) {
970
+ workingGrid.set(slot.id, {
971
+ ...slot,
972
+ size: newSize
973
+ });
974
+ if (isCommitted && hasOnChainId(slot)) {
975
+ actions.push({
976
+ type: COW_ACTIONS.UPDATE,
977
+ id: slot.id,
978
+ orderId: slot.orderId,
979
+ newGridId: slot.id,
980
+ newSize,
981
+ newPrice: slot.price,
982
+ order: {
955
983
  id: slot.id,
956
- orderId: slot.orderId,
957
- newGridId: slot.id,
958
- newSize,
959
- newPrice: slot.price,
960
- order: {
961
- id: slot.id,
962
- type: slot.type,
963
- price: slot.price,
964
- size: newSize
965
- }
966
- });
967
- }
984
+ type: slot.type,
985
+ price: slot.price,
986
+ size: newSize
987
+ }
988
+ });
968
989
  }
969
- else {
970
- // CRITICAL: Set skipAccounting=false to ensure delta is consumed/released from ChainFree
971
- const resizeOk = await manager._updateOrder({ ...slot, size: newSize }, 'grid-resize', { skipAccounting: false, fee: 0 });
972
- if (resizeOk === false) {
973
- manager.logger?.log?.(`Failed to resize order ${slot.id}`, 'warn');
974
- }
990
+ }
991
+ else {
992
+ // CRITICAL: Set skipAccounting=false to ensure delta is consumed/released from ChainFree
993
+ const resizeOk = await manager._updateOrder({ ...slot, size: newSize }, 'grid-resize', { skipAccounting: false, fee: 0 });
994
+ if (resizeOk === false) {
995
+ manager.logger?.log?.(`Failed to resize order ${slot.id}`, 'warn');
975
996
  }
976
997
  }
977
998
  }
978
- if (!collectActions) {
979
- await manager.recalculateFunds();
980
- }
981
- }
982
- finally {
983
- if (!collectActions)
984
- manager.resumeRecalcLogging();
985
- }
986
- if (collectActions) {
987
- return { actions, changed };
988
- }
989
- return undefined;
990
- }
991
- /**
992
- * High-level entry for resizing grid from snapshot using COW pattern.
993
- * Creates working grid, calculates new sizes, generates UPDATE actions.
994
- * Master grid is only updated after successful blockchain confirmation.
995
- *
996
- * @param {import('./types').OrderManager} manager - Manager instance
997
- * @param {string} orderType - 'buy', 'sell', or 'both' - which sides to update
998
- * @param {boolean} [fromBlockchainTimer=false] - If true, skip refetch of account totals (already current)
999
- * @param {number|null} [overrideBoundaryIdx=null] - Optional override for boundary index
1000
- * @returns {Promise<{actions: Array, workingGrid: import('./working_grid'), workingIndexes: Object, workingBoundary: number, hasWorkingChanges: boolean, aborted: boolean}|null>}
1001
- */
1002
- static async updateGridFromBlockchainSnapshot(manager, orderType = 'both', fromBlockchainTimer = false, overrideBoundaryIdx = null) {
1003
- if (!fromBlockchainTimer && manager.config?.accountId) {
1004
- await manager.fetchAccountTotals(manager.config.accountId);
1005
- }
1006
- const { WorkingGrid } = require('./working_grid');
1007
- const workingGrid = new WorkingGrid(manager.orders, { baseVersion: manager._gridVersion });
1008
- const allActions = [];
1009
- let hasWorkingChanges = false;
1010
- // Calculate size updates for each side (via existing sizing function in COW mode)
1011
- if (orderType === ORDER_TYPES.BUY || orderType === 'both') {
1012
- const buyResult = await Grid._recalculateGridOrderSizesFromBlockchain(manager, ORDER_TYPES.BUY, { workingGrid });
1013
- allActions.push(...buyResult.actions);
1014
- hasWorkingChanges = hasWorkingChanges || buyResult.changed;
1015
- }
1016
- if (orderType === ORDER_TYPES.SELL || orderType === 'both') {
1017
- const sellResult = await Grid._recalculateGridOrderSizesFromBlockchain(manager, ORDER_TYPES.SELL, { workingGrid });
1018
- allActions.push(...sellResult.actions);
1019
- hasWorkingChanges = hasWorkingChanges || sellResult.changed;
1020
- }
1021
- // If the boundary is shifting, reassign slot types in the WorkingGrid now.
1022
- // This ensures the COW commit delivers consistent types + boundaryIdx in one
1023
- // atomic operation — manager.boundaryIdx must not be touched before the commit.
1024
- const newBoundary = (overrideBoundaryIdx !== null) ? overrideBoundaryIdx : manager.boundaryIdx;
1025
- if (overrideBoundaryIdx !== null && overrideBoundaryIdx !== manager.boundaryIdx) {
1026
- const gapSlots = Grid.calculateGapSlots(manager.config.incrementPercent, manager.config.targetSpreadPercent, manager.config.gridLimits);
1027
- const allSlots = Array.from(workingGrid.values())
1028
- .filter(s => s.price != null)
1029
- .sort((a, b) => a.price - b.price);
1030
- const updatedSlots = assignGridRoles(allSlots, newBoundary, gapSlots, ORDER_TYPES, ORDER_STATES);
1031
- for (const slot of updatedSlots) {
1032
- workingGrid.set(slot.id, slot);
1033
- }
1034
- hasWorkingChanges = true;
1035
- }
1036
- // Return COW result only if there are changes
1037
- if (allActions.length === 0 && !hasWorkingChanges) {
1038
- return null;
1039
999
  }
1040
- return {
1041
- actions: allActions,
1042
- workingGrid,
1043
- workingIndexes: workingGrid.getIndexes(),
1044
- workingBoundary: newBoundary,
1045
- hasWorkingChanges,
1046
- aborted: false
1047
- };
1048
- }
1049
- /**
1050
- * Compare ideal grid vs persisted grid to detect divergence.
1051
- * INDEPENDENT SIDE CHECKING: Buy and sell sides are evaluated independently.
1052
- * Each side's RMS divergence is compared against its own threshold.
1053
- * Only sides exceeding the threshold are marked for update.
1054
- *
1055
- * PURPOSE: Detect if the calculated in-memory grid has diverged significantly from the
1056
- * persisted grid state. High divergence indicates that order fills/rotations have caused
1057
- * size distributions to deviate, potentially requiring grid size recalculation.
1058
- *
1059
- * METRIC: RMS (Root Mean Square) percentage of relative size differences
1060
- * Formula: RMS% = sqrt(mean((calculated - persisted) / persisted)²) × 100
1061
- * This measures the typical relative error across all orders on each side.
1062
- *
1063
- * SIDE INDEPENDENCE:
1064
- * - Buy side RMS is checked against GRID_COMPARISON.RMS_PERCENTAGE independently
1065
- * - Sell side RMS is checked against GRID_COMPARISON.RMS_PERCENTAGE independently
1066
- * - One side can diverge while the other remains stable (no update for stable side)
1067
- *
1068
- * RC-4: Atomic snapshot taking prevents stale data from concurrent fill operations
1069
- * - Grids are snapshotted atomically before comparison
1070
- * - Prevents mixing old and new grid state
1071
- * - Ensures consistent RMS metrics across both sides
1072
- *
1073
- * @param {Array<import('./types').GridOrderSlot>} calculatedGrid - Ideal calculated grid
1074
- * @param {Array<import('./types').GridOrderSlot>} persistedGrid - Persisted grid state
1075
- * @param {import('./types').OrderManager|null} [manager=null] - Manager instance (for grid lock access)
1076
- * @returns {Promise<import('./types').GridComparisonResult>}
1077
- */
1078
- static async compareGrids(calculatedGrid, persistedGrid, manager = null) {
1079
- if (!Array.isArray(calculatedGrid) || !Array.isArray(persistedGrid)) {
1080
- return { buy: { metric: 0, updated: false }, sell: { metric: 0, updated: false } };
1081
- }
1082
- // RC-4: Take snapshots atomically to prevent concurrent modification races
1083
- // If manager has grid lock, use it to get consistent snapshots
1084
- let calculatedSnap = calculatedGrid;
1085
- let persistedSnap = persistedGrid;
1086
- if (manager?._gridLock?.acquire) {
1087
- const snapshotResult = await manager._gridLock.acquire(() => {
1088
- return {
1089
- calculated: Array.from(calculatedGrid),
1090
- persisted: Array.from(persistedGrid)
1091
- };
1092
- });
1093
- calculatedSnap = snapshotResult.calculated;
1094
- persistedSnap = snapshotResult.persisted;
1095
- }
1096
- // Filter to ACTIVE orders only (excludes PARTIAL/VIRTUAL/SPREAD)
1097
- // Partial orders are excluded from divergence calculation as they are expected to deviate;
1098
- // they are instead handled by the available-funds ratio check or follow-up correction.
1099
- // Must be sorted ASC for calculateRotationOrderSizes to match geometric weight distribution
1100
- const filterForRms = (orders, type) => {
1101
- const result = Array.isArray(orders) ? orders.filter(o => o && o.type === type && o.state === ORDER_STATES.ACTIVE) : [];
1102
- return result
1103
- .sort((a, b) => (a.price ?? 0) - (b.price ?? 0));
1104
- };
1105
- const calculatedBuys = filterForRms(calculatedSnap, ORDER_TYPES.BUY);
1106
- const calculatedSells = filterForRms(calculatedSnap, ORDER_TYPES.SELL);
1107
- const persistedBuys = filterForRms(persistedSnap, ORDER_TYPES.BUY);
1108
- const persistedSells = filterForRms(persistedSnap, ORDER_TYPES.SELL);
1109
- // Calculate ideal sizes for each order based on current available budget.
1110
- // The sizing context (which includes recalculateFunds) is resolved once per side up front
1111
- // so both buy and sell metrics share a single fund snapshot. This avoids the previous
1112
- // double-recalculateFunds between the two sides and keeps the metric consistent even if
1113
- // a fill event arrives between per-side calculations.
1114
- const computeSideIdeals = (activeOrders, type, ctx) => {
1115
- if (!manager || !ctx || ctx.budget <= 0 || activeOrders.length === 0)
1116
- return activeOrders;
1117
- // Identify ALL slots currently assigned to this side.
1118
- // Ideal sizing must use the full slot count to determine geometric share per slot.
1119
- const sideSlots = Array.from(manager.orders.values())
1120
- .filter(o => o.type === type)
1121
- .sort((a, b) => (a.price ?? 0) - (b.price ?? 0));
1122
- if (sideSlots.length === 0)
1123
- return activeOrders;
1124
- // Calculate geometric ideals for the ENTIRE side (all slots)
1125
- try {
1126
- const allIdealSizes = calculateRotationOrderSizes(ctx.budget, 0, sideSlots.length, type, manager.config, 0, ctx.precision);
1127
- // Map Ideal sizes to IDs for quick lookup
1128
- const idealMap = new Map();
1129
- sideSlots.forEach((slot, i) => idealMap.set(slot.id, allIdealSizes[i]));
1130
- // Return the activeOrders subset with their true geometric ideal sizes
1131
- return activeOrders.map(o => ({ ...o, size: idealMap.get(o.id) ?? 0 }));
1132
- }
1133
- catch (e) {
1134
- return activeOrders;
1135
- }
1136
- };
1137
- const needsBuy = calculatedBuys.length > 0 && manager?.assets;
1138
- const needsSell = calculatedSells.length > 0 && manager?.assets;
1139
- if (needsBuy || needsSell) {
1000
+ if (!collectActions) {
1140
1001
  await manager.recalculateFunds();
1141
1002
  }
1142
- const buyCtx = needsBuy
1143
- ? await Grid._getSizingContext(manager, 'buy', { skipRecalc: true })
1144
- : null;
1145
- const sellCtx = needsSell
1146
- ? await Grid._getSizingContext(manager, 'sell', { skipRecalc: true })
1147
- : null;
1148
- const buyIdeals = computeSideIdeals(calculatedBuys, ORDER_TYPES.BUY, buyCtx);
1149
- const sellIdeals = computeSideIdeals(calculatedSells, ORDER_TYPES.SELL, sellCtx);
1150
- // Calculate RMS divergence metric for each side
1151
- const buyMetric = calculateGridSideDivergenceMetric(buyIdeals, persistedBuys, 'buy');
1152
- const sellMetric = calculateGridSideDivergenceMetric(sellIdeals, persistedSells, 'sell');
1153
- // Check if metrics exceed threshold and flag sides for regeneration
1154
- // Set RMS_PERCENTAGE to 0 to disable RMS divergence checks
1155
- let buyUpdated = false, sellUpdated = false;
1156
- if (manager && (manager.config?.gridLimits?.GRID_COMPARISON?.RMS_PERCENTAGE ?? GRID_COMPARISON.RMS_PERCENTAGE) > 0) {
1157
- const limit = (manager.config?.gridLimits?.GRID_COMPARISON?.RMS_PERCENTAGE ?? GRID_COMPARISON.RMS_PERCENTAGE) / GRID_CONSTANTS.RMS_PERCENTAGE_SCALE;
1158
- if (buyMetric > limit) {
1159
- // RC-3: Use Set for automatic duplicate prevention
1160
- if (!(manager._gridSidesUpdated instanceof Set))
1161
- manager._gridSidesUpdated = new Set();
1162
- manager._gridSidesUpdated.add(ORDER_TYPES.BUY);
1163
- buyUpdated = true;
1164
- }
1165
- if (sellMetric > limit) {
1166
- // RC-3: Use Set for automatic duplicate prevention
1167
- if (!(manager._gridSidesUpdated instanceof Set))
1168
- manager._gridSidesUpdated = new Set();
1169
- manager._gridSidesUpdated.add(ORDER_TYPES.SELL);
1170
- sellUpdated = true;
1171
- }
1003
+ }
1004
+ finally {
1005
+ if (!collectActions)
1006
+ manager.resumeRecalcLogging();
1007
+ }
1008
+ if (collectActions) {
1009
+ return { actions, changed };
1010
+ }
1011
+ return undefined;
1012
+ }
1013
+ /**
1014
+ * High-level entry for resizing grid from snapshot using COW pattern.
1015
+ * Creates working grid, calculates new sizes, generates UPDATE actions.
1016
+ * Master grid is only updated after successful blockchain confirmation.
1017
+ *
1018
+ * @param {import('./types').OrderManager} manager - Manager instance
1019
+ * @param {string} orderType - 'buy', 'sell', or 'both' - which sides to update
1020
+ * @param {boolean} [fromBlockchainTimer=false] - If true, skip refetch of account totals (already current)
1021
+ * @param {number|null} [overrideBoundaryIdx=null] - Optional override for boundary index
1022
+ * @returns {Promise<{actions: Array, workingGrid: import('./working_grid'), workingIndexes: Object, workingBoundary: number, hasWorkingChanges: boolean, aborted: boolean}|null>}
1023
+ */
1024
+ async function updateGridFromBlockchainSnapshot(manager, orderType = 'both', fromBlockchainTimer = false, overrideBoundaryIdx = null) {
1025
+ if (!fromBlockchainTimer && manager.config?.accountId) {
1026
+ await manager.fetchAccountTotals(manager.config.accountId);
1027
+ }
1028
+ const { WorkingGrid } = require('./working_grid');
1029
+ const workingGrid = new WorkingGrid(manager.orders, { baseVersion: manager._gridVersion });
1030
+ const allActions = [];
1031
+ let hasWorkingChanges = false;
1032
+ // Calculate size updates for each side (via existing sizing function in COW mode)
1033
+ if (orderType === ORDER_TYPES.BUY || orderType === 'both') {
1034
+ const buyResult = await _recalculateGridOrderSizesFromBlockchain(manager, ORDER_TYPES.BUY, { workingGrid });
1035
+ allActions.push(...buyResult.actions);
1036
+ hasWorkingChanges = hasWorkingChanges || buyResult.changed;
1037
+ }
1038
+ if (orderType === ORDER_TYPES.SELL || orderType === 'both') {
1039
+ const sellResult = await _recalculateGridOrderSizesFromBlockchain(manager, ORDER_TYPES.SELL, { workingGrid });
1040
+ allActions.push(...sellResult.actions);
1041
+ hasWorkingChanges = hasWorkingChanges || sellResult.changed;
1042
+ }
1043
+ // If the boundary is shifting, reassign slot types in the WorkingGrid now.
1044
+ // This ensures the COW commit delivers consistent types + boundaryIdx in one
1045
+ // atomic operation — manager.boundaryIdx must not be touched before the commit.
1046
+ const newBoundary = (overrideBoundaryIdx !== null) ? overrideBoundaryIdx : manager.boundaryIdx;
1047
+ if (overrideBoundaryIdx !== null && overrideBoundaryIdx !== manager.boundaryIdx) {
1048
+ const gapSlots = calculateGapSlots(manager.config.incrementPercent, manager.config.targetSpreadPercent, manager.config.gridLimits);
1049
+ const allSlots = Array.from(workingGrid.values())
1050
+ .filter(s => s.price != null)
1051
+ .sort((a, b) => a.price - b.price);
1052
+ const updatedSlots = assignGridRoles(allSlots, newBoundary, gapSlots, ORDER_TYPES, ORDER_STATES);
1053
+ for (const slot of updatedSlots) {
1054
+ workingGrid.set(slot.id, slot);
1172
1055
  }
1173
- return {
1174
- buy: { metric: buyMetric, updated: buyUpdated },
1175
- sell: { metric: sellMetric, updated: sellUpdated },
1176
- totalMetric: (buyMetric + sellMetric) / 2
1177
- };
1056
+ hasWorkingChanges = true;
1178
1057
  }
1179
- /**
1180
- * Unified divergence monitoring.
1181
- * Performs both Ratio-based and RMS-based divergence checks.
1182
- *
1183
- * @param {import('./types').OrderManager} manager - Manager instance
1184
- * @param {Array<import('./types').GridOrderSlot>} calculatedGrid - Ideal/calculated grid
1185
- * @param {Array<import('./types').GridOrderSlot>} persistedGrid - Current/persisted grid
1186
- * @returns {Promise<import('./types').DivergenceResult>}
1187
- */
1188
- static async monitorDivergence(manager, calculatedGrid, persistedGrid) {
1189
- // 1. Check ratio-based divergence (available funds vs allocated)
1190
- const ratioResult = Grid.checkAndUpdateGridIfNeeded(manager);
1191
- if (ratioResult.buyUpdated || ratioResult.sellUpdated) {
1192
- const { getOrderTypeFromUpdatedFlags } = require('./utils/order');
1058
+ // Return COW result only if there are changes
1059
+ if (allActions.length === 0 && !hasWorkingChanges) {
1060
+ return null;
1061
+ }
1062
+ return {
1063
+ actions: allActions,
1064
+ workingGrid,
1065
+ workingIndexes: workingGrid.getIndexes(),
1066
+ workingBoundary: newBoundary,
1067
+ hasWorkingChanges,
1068
+ aborted: false
1069
+ };
1070
+ }
1071
+ /**
1072
+ * Compare ideal grid vs persisted grid to detect divergence.
1073
+ * INDEPENDENT SIDE CHECKING: Buy and sell sides are evaluated independently.
1074
+ * Each side's RMS divergence is compared against its own threshold.
1075
+ * Only sides exceeding the threshold are marked for update.
1076
+ *
1077
+ * PURPOSE: Detect if the calculated in-memory grid has diverged significantly from the
1078
+ * persisted grid state. High divergence indicates that order fills/rotations have caused
1079
+ * size distributions to deviate, potentially requiring grid size recalculation.
1080
+ *
1081
+ * METRIC: RMS (Root Mean Square) percentage of relative size differences
1082
+ * Formula: RMS% = sqrt(mean((calculated - persisted) / persisted)²) × 100
1083
+ * This measures the typical relative error across all orders on each side.
1084
+ *
1085
+ * SIDE INDEPENDENCE:
1086
+ * - Buy side RMS is checked against GRID_COMPARISON.RMS_PERCENTAGE independently
1087
+ * - Sell side RMS is checked against GRID_COMPARISON.RMS_PERCENTAGE independently
1088
+ * - One side can diverge while the other remains stable (no update for stable side)
1089
+ *
1090
+ * RC-4: Atomic snapshot taking prevents stale data from concurrent fill operations
1091
+ * - Grids are snapshotted atomically before comparison
1092
+ * - Prevents mixing old and new grid state
1093
+ * - Ensures consistent RMS metrics across both sides
1094
+ *
1095
+ * @param {Array<import('./types').GridOrderSlot>} calculatedGrid - Ideal calculated grid
1096
+ * @param {Array<import('./types').GridOrderSlot>} persistedGrid - Persisted grid state
1097
+ * @param {import('./types').OrderManager|null} [manager=null] - Manager instance (for grid lock access)
1098
+ * @returns {Promise<import('./types').GridComparisonResult>}
1099
+ */
1100
+ async function compareGrids(calculatedGrid, persistedGrid, manager = null) {
1101
+ if (!Array.isArray(calculatedGrid) || !Array.isArray(persistedGrid)) {
1102
+ return { buy: { metric: 0, updated: false }, sell: { metric: 0, updated: false } };
1103
+ }
1104
+ // RC-4: Take snapshots atomically to prevent concurrent modification races
1105
+ // If manager has grid lock, use it to get consistent snapshots
1106
+ let calculatedSnap = calculatedGrid;
1107
+ let persistedSnap = persistedGrid;
1108
+ if (manager?._gridLock?.acquire) {
1109
+ const snapshotResult = await manager._gridLock.acquire(() => {
1193
1110
  return {
1194
- needsUpdate: true,
1195
- buy: { updated: ratioResult.buyUpdated, ratio: ratioResult.buyUpdated, rms: false, metric: 0 },
1196
- sell: { updated: ratioResult.sellUpdated, ratio: ratioResult.sellUpdated, rms: false, metric: 0 },
1197
- orderType: getOrderTypeFromUpdatedFlags(ratioResult.buyUpdated, ratioResult.sellUpdated)
1111
+ calculated: Array.from(calculatedGrid),
1112
+ persisted: Array.from(persistedGrid)
1198
1113
  };
1199
- }
1200
- // 2. Check RMS-based divergence (structural deviation)
1201
- const rmsResult = await Grid.compareGrids(calculatedGrid, persistedGrid, manager);
1202
- const buyUpdated = ratioResult.buyUpdated || rmsResult.buy.updated;
1203
- const sellUpdated = ratioResult.sellUpdated || rmsResult.sell.updated;
1204
- const { getOrderTypeFromUpdatedFlags } = require('./utils/order');
1205
- return {
1206
- needsUpdate: buyUpdated || sellUpdated,
1207
- buy: { updated: buyUpdated, ratio: ratioResult.buyUpdated, rms: rmsResult.buy.updated, metric: rmsResult.buy.metric },
1208
- sell: { updated: sellUpdated, ratio: ratioResult.sellUpdated, rms: rmsResult.sell.updated, metric: rmsResult.sell.metric },
1209
- orderType: getOrderTypeFromUpdatedFlags(buyUpdated, sellUpdated)
1210
- };
1114
+ });
1115
+ calculatedSnap = snapshotResult.calculated;
1116
+ persistedSnap = snapshotResult.persisted;
1211
1117
  }
1212
- /**
1213
- * Collect on-chain buy and sell orders from the manager.
1214
- * Filters to orders with valid orderId and positive size.
1215
- * @param {import('./types').OrderManager} manager - The manager instance.
1216
- * @returns {{onChainBuys: Array<import('./types').Order>, onChainSells: Array<import('./types').Order>}}
1217
- */
1218
- static _getOnChainOrders(manager) {
1219
- const onChainBuys = [
1220
- ...manager.getOrdersByTypeAndState(ORDER_TYPES.BUY, ORDER_STATES.ACTIVE),
1221
- ...manager.getOrdersByTypeAndState(ORDER_TYPES.BUY, ORDER_STATES.PARTIAL)
1222
- ].filter(o => o?.orderId && Number(o?.size || 0) > 0);
1223
- const onChainSells = [
1224
- ...manager.getOrdersByTypeAndState(ORDER_TYPES.SELL, ORDER_STATES.ACTIVE),
1225
- ...manager.getOrdersByTypeAndState(ORDER_TYPES.SELL, ORDER_STATES.PARTIAL)
1226
- ].filter(o => o?.orderId && Number(o?.size || 0) > 0);
1227
- return { onChainBuys, onChainSells };
1228
- }
1229
- /**
1230
- * Calculate current market spread using on-chain orders.
1231
- * @param {import('./types').OrderManager} manager - The manager instance.
1232
- * @returns {number} The calculated spread percentage.
1233
- */
1234
- static calculateCurrentSpread(manager) {
1235
- const { onChainBuys, onChainSells } = Grid._getOnChainOrders(manager);
1236
- return calculateSpreadFromOrders(onChainBuys, onChainSells);
1237
- }
1238
- /**
1239
- * Proactive spread correction check.
1240
- *
1241
- * CRITICAL: Uses AsyncLock to prevent race conditions with fill processing.
1242
- * Without the lock, a TOCTOU (Time-Of-Check-To-Use) vulnerability exists where:
1243
- * - Fund snapshot is taken (check phase)
1244
- * - Fill processor modifies funds in another thread
1245
- * - Order is placed based on stale funds (use phase)
1246
- * Result: Orders placed beyond available liquidity, fund accounting errors
1247
- *
1248
- * DESIGN DECISION: Lock is released before blockchain operations for performance
1249
- * - Lock held: Fund verification and correction decision (synchronized)
1250
- * - Lock released: Blockchain submission (async, potentially slow)
1251
- * - RACE CONDITION WINDOW: Between lock release and blockchain submission
1252
- * - MITIGATION: Pre-flight fund verification before submission; comprehensive error handling
1253
- *
1254
- * See RACE_CONDITION_ANALYSIS.md for detailed vulnerability documentation.
1255
- *
1256
- * @param {import('./types').OrderManager} manager - Manager instance
1257
- * @param {Object} BitShares - BitShares API client
1258
- * @param {Function|null} [updateOrdersOnChainBatch=null] - Optional batch update function
1259
- * @returns {Promise<import('./types').SpreadCheckResult>}
1260
- */
1261
- static async checkSpreadCondition(manager, BitShares, updateOrdersOnChainBatch = null) {
1262
- // CRITICAL: Acquire corrections lock to serialize spread correction operations
1263
- // This prevents concurrent fill processing from modifying funds while we're making decisions
1264
- let correction = null;
1265
- let shouldApplyCorrection = false;
1266
- // Derive current market price from the bot's own grid (no blockchain call needed).
1267
- // Grid prices are in B/A format (e.g. BTS/XRP) so no inversion is required.
1268
- // Mid between best bid and best ask is the most current price the bot has.
1269
- // Falls back to config.startPrice when either side is empty (e.g. at startup).
1270
- const { onChainBuys, onChainSells } = Grid._getOnChainOrders(manager);
1271
- const { bestBuy, bestSell } = getGridBestPrices(onChainBuys, onChainSells);
1272
- const lastPrice = (bestBuy !== null && bestSell !== null)
1273
- ? (bestBuy + bestSell) / 2
1274
- : Number(manager.config.startPrice) || 0;
1275
- // FIX: Use optional chaining for lock - if no lock exists, execute synchronously
1276
- let fundSnapshot = null;
1277
- const executeSpreadCheck = async () => {
1278
- const currentSpread = Grid.calculateCurrentSpread(manager);
1279
- // Nominal spread is the configured target spread percentage.
1280
- // Keep this fixed: doubled-side flags are fill/replacement mechanics only.
1281
- const nominalSpread = manager.config.targetSpreadPercent ?? DEFAULT_CONFIG.targetSpreadPercent;
1282
- // Fixed tolerance: 0.5 steps = half increment (tighter spread check).
1283
- const toleranceSteps = 0.5;
1284
- const buyCount = manager.getOrdersByTypeAndState(ORDER_TYPES.BUY, ORDER_STATES.ACTIVE)
1285
- .concat(manager.getOrdersByTypeAndState(ORDER_TYPES.BUY, ORDER_STATES.PARTIAL))
1286
- .filter(o => o?.orderId && Number(o?.size || 0) > 0)
1287
- .length;
1288
- const sellCount = manager.getOrdersByTypeAndState(ORDER_TYPES.SELL, ORDER_STATES.ACTIVE)
1289
- .concat(manager.getOrdersByTypeAndState(ORDER_TYPES.SELL, ORDER_STATES.PARTIAL))
1290
- .filter(o => o?.orderId && Number(o?.size || 0) > 0)
1291
- .length;
1292
- manager.outOfSpread = shouldFlagOutOfSpread(currentSpread, nominalSpread, toleranceSteps, buyCount, sellCount, manager.config.incrementPercent);
1293
- if (manager.outOfSpread === 0)
1294
- return false;
1295
- // Limit spread = nominal + half increment tolerance (0.5 steps).
1296
- const limitSpread = nominalSpread + (manager.config.incrementPercent * toleranceSteps);
1297
- manager.logger?.log?.(`Spread too wide (${Format.formatPercent(currentSpread)} > ${Format.formatPercent(limitSpread)}), correcting with ${manager.outOfSpread} extra slot(s)...`, 'warn');
1298
- const decision = Grid.determineOrderSideByFunds(manager, lastPrice);
1299
- if (!decision.side)
1300
- return false;
1301
- // Perform spread correction by placing orders on the chosen side.
1302
- correction = await Grid.prepareSpreadCorrectionOrders(manager, decision.side);
1303
- if (!correction)
1304
- return false;
1305
- const placeCount = correction.ordersToPlace?.length || 0;
1306
- const updateCount = correction.ordersToUpdate?.length || 0;
1307
- // Capture fund snapshot under lock for pre-flight verification before broadcast
1308
- fundSnapshot = _snapshotFundState(manager);
1309
- return (placeCount + updateCount) > 0;
1310
- };
1118
+ // Filter to ACTIVE orders only (excludes PARTIAL/VIRTUAL/SPREAD)
1119
+ // Partial orders are excluded from divergence calculation as they are expected to deviate;
1120
+ // they are instead handled by the available-funds ratio check or follow-up correction.
1121
+ // Must be sorted ASC for calculateRotationOrderSizes to match geometric weight distribution
1122
+ const filterForRms = (orders, type) => {
1123
+ const result = Array.isArray(orders) ? orders.filter(o => o && o.type === type && o.state === ORDER_STATES.ACTIVE) : [];
1124
+ return result
1125
+ .sort((a, b) => (a.price ?? 0) - (b.price ?? 0));
1126
+ };
1127
+ const calculatedBuys = filterForRms(calculatedSnap, ORDER_TYPES.BUY);
1128
+ const calculatedSells = filterForRms(calculatedSnap, ORDER_TYPES.SELL);
1129
+ const persistedBuys = filterForRms(persistedSnap, ORDER_TYPES.BUY);
1130
+ const persistedSells = filterForRms(persistedSnap, ORDER_TYPES.SELL);
1131
+ // Calculate ideal sizes for each order based on current available budget.
1132
+ // The sizing context (which includes recalculateFunds) is resolved once per side up front
1133
+ // so both buy and sell metrics share a single fund snapshot. This avoids the previous
1134
+ // double-recalculateFunds between the two sides and keeps the metric consistent even if
1135
+ // a fill event arrives between per-side calculations.
1136
+ const computeSideIdeals = (activeOrders, type, ctx) => {
1137
+ if (!manager || !ctx || ctx.budget <= 0 || activeOrders.length === 0)
1138
+ return activeOrders;
1139
+ // Identify ALL slots currently assigned to this side.
1140
+ // Ideal sizing must use the full slot count to determine geometric share per slot.
1141
+ const sideSlots = Array.from(manager.orders.values())
1142
+ .filter(o => o.type === type)
1143
+ .sort((a, b) => (a.price ?? 0) - (b.price ?? 0));
1144
+ if (sideSlots.length === 0)
1145
+ return activeOrders;
1146
+ // Calculate geometric ideals for the ENTIRE side (all slots)
1311
1147
  try {
1312
- shouldApplyCorrection = await manager._gridLock.acquire(executeSpreadCheck);
1148
+ const allIdealSizes = calculateRotationOrderSizes(ctx.budget, 0, sideSlots.length, type, manager.config, 0, ctx.precision);
1149
+ // Map Ideal sizes to IDs for quick lookup
1150
+ const idealMap = new Map();
1151
+ sideSlots.forEach((slot, i) => idealMap.set(slot.id, allIdealSizes[i]));
1152
+ // Return the activeOrders subset with their true geometric ideal sizes
1153
+ return activeOrders.map(o => ({ ...o, size: idealMap.get(o.id) ?? 0 }));
1313
1154
  }
1314
- catch (err) {
1315
- manager.logger?.log?.(`Error checking spread condition: ${err.message}`, 'warn');
1316
- return { ordersPlaced: 0, partialsMoved: 0 };
1155
+ catch (e) {
1156
+ return activeOrders;
1317
1157
  }
1318
- // FIX: Apply blockchain operations OUTSIDE the lock to reduce lock contention
1319
- // The lock is only needed for fund verification; order placement doesn't need it
1320
- // Pre-flight fund verification to mitigate TOCTOU between lock release and broadcast
1321
- if (shouldApplyCorrection && updateOrdersOnChainBatch && correction && fundSnapshot) {
1322
- const currentFunds = _snapshotFundState(manager);
1323
- const fundChanged = fundSnapshot.buyFree !== currentFunds.buyFree
1324
- || fundSnapshot.sellFree !== currentFunds.sellFree
1325
- || fundSnapshot.buyLocked !== currentFunds.buyLocked
1326
- || fundSnapshot.sellLocked !== currentFunds.sellLocked;
1327
- if (fundChanged) {
1328
- manager.logger?.log?.(`Spread correction aborted: fund state changed between lock release and broadcast (pre-flight check)`, 'warn');
1329
- return { ordersPlaced: 0, partialsMoved: 0 };
1330
- }
1331
- try {
1332
- const batchResult = await updateOrdersOnChainBatch(correction);
1333
- if (!batchResult || batchResult.executed !== true) {
1334
- manager.logger?.log?.(`Spread correction batch was prepared but not executed. Keeping local state unchanged.`, 'warn');
1335
- return { ordersPlaced: 0, partialsMoved: 0 };
1336
- }
1337
- await manager.recalculateFunds();
1338
- const placed = correction.ordersToPlace?.length || 0;
1339
- const updated = correction.ordersToUpdate?.length || 0;
1340
- return { ordersPlaced: placed + updated, partialsMoved: updated };
1341
- }
1342
- catch (err) {
1343
- manager.logger?.log?.(`Error applying spread correction on-chain: ${err.message}`, 'warn');
1344
- return { ordersPlaced: 0, partialsMoved: 0 };
1345
- }
1158
+ };
1159
+ const needsBuy = calculatedBuys.length > 0 && manager?.assets;
1160
+ const needsSell = calculatedSells.length > 0 && manager?.assets;
1161
+ if (needsBuy || needsSell) {
1162
+ await manager.recalculateFunds();
1163
+ }
1164
+ const buyCtx = needsBuy
1165
+ ? await _getSizingContext(manager, 'buy', { skipRecalc: true })
1166
+ : null;
1167
+ const sellCtx = needsSell
1168
+ ? await _getSizingContext(manager, 'sell', { skipRecalc: true })
1169
+ : null;
1170
+ const buyIdeals = computeSideIdeals(calculatedBuys, ORDER_TYPES.BUY, buyCtx);
1171
+ const sellIdeals = computeSideIdeals(calculatedSells, ORDER_TYPES.SELL, sellCtx);
1172
+ // Calculate RMS divergence metric for each side
1173
+ const buyMetric = calculateGridSideDivergenceMetric(buyIdeals, persistedBuys, 'buy');
1174
+ const sellMetric = calculateGridSideDivergenceMetric(sellIdeals, persistedSells, 'sell');
1175
+ // Check if metrics exceed threshold and flag sides for regeneration
1176
+ // Set RMS_PERCENTAGE to 0 to disable RMS divergence checks
1177
+ let buyUpdated = false, sellUpdated = false;
1178
+ if (manager && (manager.config?.gridLimits?.GRID_COMPARISON?.RMS_PERCENTAGE ?? GRID_COMPARISON.RMS_PERCENTAGE) > 0) {
1179
+ const limit = (manager.config?.gridLimits?.GRID_COMPARISON?.RMS_PERCENTAGE ?? GRID_COMPARISON.RMS_PERCENTAGE) / GRID_CONSTANTS.RMS_PERCENTAGE_SCALE;
1180
+ if (buyMetric > limit) {
1181
+ // RC-3: Use Set for automatic duplicate prevention
1182
+ if (!(manager._gridSidesUpdated instanceof Set))
1183
+ manager._gridSidesUpdated = new Set();
1184
+ manager._gridSidesUpdated.add(ORDER_TYPES.BUY);
1185
+ buyUpdated = true;
1186
+ }
1187
+ if (sellMetric > limit) {
1188
+ // RC-3: Use Set for automatic duplicate prevention
1189
+ if (!(manager._gridSidesUpdated instanceof Set))
1190
+ manager._gridSidesUpdated = new Set();
1191
+ manager._gridSidesUpdated.add(ORDER_TYPES.SELL);
1192
+ sellUpdated = true;
1346
1193
  }
1347
- return { ordersPlaced: 0, partialsMoved: 0 };
1348
1194
  }
1349
- /**
1350
- * Grid health check for structural violations.
1351
- * Monitors for "Dust Partials" that are too small to be traded on-chain,
1352
- * scoped to the active buy/sell window.
1353
- *
1354
- * NOTE: Internal gaps (virtual slots between active ones) are no longer
1355
- * flagged as violations. The "Edge-First" placement strategy intentionally
1356
- * creates these gaps to maximize grid coverage during fund expansion.
1357
- *
1358
- * @param {import('./types').OrderManager} manager - The manager instance.
1359
- * @param {Function|null} [updateOrdersOnChainBatch=null] - Optional batch update function.
1360
- * @returns {Promise<import('./types').DustCheckResult>}
1361
- */
1362
- static async checkGridHealth(manager, updateOrdersOnChainBatch = null) {
1363
- if (!manager)
1364
- return { buyDust: false, sellDust: false, buyDustOrders: [], sellDustOrders: [] };
1365
- // Skip health checks during bootstrap to prevent spamming warnings
1366
- if (manager._state.isBootstrapping())
1367
- return { buyDust: false, sellDust: false, buyDustOrders: [], sellDustOrders: [] };
1368
- // Health checks are scoped to the active on-chain window only.
1369
- // This keeps detection aligned with maintenance actions that operate on
1370
- // active window partials.
1371
- const { buyDust, sellDust, buyDustOrders, sellDustOrders } = await Grid.checkWindowDust(manager);
1372
- // Partial split/merge maintenance is intentionally disabled.
1373
- // Health checks remain detection-only.
1374
- return { buyDust, sellDust, buyDustOrders, sellDustOrders };
1375
- }
1376
- /**
1377
- * Dust check covering all partial orders, with interior-only guard.
1378
- *
1379
- * The top-of-window partial (closest to market) is always eligible for dust
1380
- * detection since cancelling it is just the grid edge moving inward.
1381
- *
1382
- * Interior partials (further from market) are only eligible if they have a
1383
- * duplicate price level — another active order at essentially the same price.
1384
- * Cancelling such an interior partial won't leave a gap in the grid because
1385
- * the sibling active order already covers that price level.
1386
- *
1387
- * Returns boolean flags plus the actual dust order objects so callers can act
1388
- * on individual orders (e.g. DUST_CANCEL_DELAY_SEC auto-cancel).
1389
- *
1390
- * @param {import('./types').OrderManager} manager
1391
- * @returns {Promise<import('./types').DustCheckResult>}
1392
- */
1393
- static async checkWindowDust(manager) {
1394
- if (!manager)
1395
- return { buyDust: false, sellDust: false, buyDustOrders: [], sellDustOrders: [] };
1396
- const allOrders = Array.from(manager.orders.values());
1397
- const isLiveOrder = order => order &&
1398
- order.orderId &&
1399
- order.price != null &&
1400
- (order.state === ORDER_STATES.ACTIVE || order.state === ORDER_STATES.PARTIAL);
1401
- // Identify top-of-window orders (closest to market per side).
1402
- const topBuyOrder = allOrders
1403
- .filter(o => o.type === ORDER_TYPES.BUY && isLiveOrder(o))
1404
- .sort((a, b) => b.price - a.price)[0];
1405
- const topSellOrder = allOrders
1406
- .filter(o => o.type === ORDER_TYPES.SELL && isLiveOrder(o))
1407
- .sort((a, b) => a.price - b.price)[0];
1408
- // Check if an order has a duplicate price level — an active sibling at the
1409
- // same price within tolerance. If so, cancelling won't create a grid gap.
1410
- // Only checks ACTIVE siblings. If two PARTIALs share a price with no active
1411
- // sibling, neither qualifies and the gap is left to the rebalancer.
1412
- // Uses the LARGER size of the two orders for tolerance calculation to prevent
1413
- // a tiny dust order from inflating the tolerance window.
1414
- const hasDuplicatePriceLevel = (order, assets) => allOrders.some(o => {
1415
- if (o.id === order.id || o.type !== order.type)
1416
- return false;
1417
- if (o.state !== ORDER_STATES.ACTIVE || !o.orderId || o.price == null)
1418
- return false;
1419
- const toleranceSize = Math.max(order.size, o.size);
1420
- const tolerance = calculatePriceTolerance(Math.min(order.price, o.price), toleranceSize, order.type, assets);
1421
- return tolerance != null && Math.abs(o.price - order.price) <= tolerance;
1422
- });
1423
- const assets = manager.assets;
1424
- const allPartials = allOrders.filter((o) => isLiveOrder(o) && o.state === ORDER_STATES.PARTIAL);
1425
- const isTopBuy = (o) => topBuyOrder && o.id === topBuyOrder.id;
1426
- const isTopSell = (o) => topSellOrder && o.id === topSellOrder.id;
1427
- // Safety filter: top-of-window partials always qualify; interior partials
1428
- // only qualify if they have a duplicate price level (no gap risk).
1429
- const eligibleBuyPartials = allPartials.filter((o) => o.type === ORDER_TYPES.BUY && (isTopBuy(o) || hasDuplicatePriceLevel(o, assets)));
1430
- const eligibleSellPartials = allPartials.filter((o) => o.type === ORDER_TYPES.SELL && (isTopSell(o) || hasDuplicatePriceLevel(o, assets)));
1431
- const buyDustOrders = await Grid._getDustOrders(manager, eligibleBuyPartials, ORDER_TYPES.BUY);
1432
- const sellDustOrders = await Grid._getDustOrders(manager, eligibleSellPartials, ORDER_TYPES.SELL);
1195
+ return {
1196
+ buy: { metric: buyMetric, updated: buyUpdated },
1197
+ sell: { metric: sellMetric, updated: sellUpdated },
1198
+ totalMetric: (buyMetric + sellMetric) / 2
1199
+ };
1200
+ }
1201
+ /**
1202
+ * Unified divergence monitoring.
1203
+ * Performs both Ratio-based and RMS-based divergence checks.
1204
+ *
1205
+ * @param {import('./types').OrderManager} manager - Manager instance
1206
+ * @param {Array<import('./types').GridOrderSlot>} calculatedGrid - Ideal/calculated grid
1207
+ * @param {Array<import('./types').GridOrderSlot>} persistedGrid - Current/persisted grid
1208
+ * @returns {Promise<import('./types').DivergenceResult>}
1209
+ */
1210
+ async function monitorDivergence(manager, calculatedGrid, persistedGrid) {
1211
+ // 1. Check ratio-based divergence (available funds vs allocated)
1212
+ const ratioResult = checkAndUpdateGridIfNeeded(manager);
1213
+ if (ratioResult.buyUpdated || ratioResult.sellUpdated) {
1214
+ const { getOrderTypeFromUpdatedFlags } = require('./utils/order');
1433
1215
  return {
1434
- buyDust: buyDustOrders.length > 0,
1435
- sellDust: sellDustOrders.length > 0,
1436
- buyDustOrders,
1437
- sellDustOrders,
1216
+ needsUpdate: true,
1217
+ buy: { updated: ratioResult.buyUpdated, ratio: ratioResult.buyUpdated, rms: false, metric: 0 },
1218
+ sell: { updated: ratioResult.sellUpdated, ratio: ratioResult.sellUpdated, rms: false, metric: 0 },
1219
+ orderType: getOrderTypeFromUpdatedFlags(ratioResult.buyUpdated, ratioResult.sellUpdated)
1438
1220
  };
1439
1221
  }
1440
- /**
1441
- * Return the subset of partial orders that qualify as dust on a given side.
1442
- * Shares the same sizing context as _hasAnyDust but returns the actual order
1443
- * objects so callers can act on them (e.g. auto-cancel).
1444
- * @private
1445
- * @param {import('./types').OrderManager} manager
1446
- * @param {Array<import('./types').GridOrderSlot>} partials - Candidate partial orders to test.
1447
- * @param {string} type - ORDER_TYPES.BUY or ORDER_TYPES.SELL
1448
- * @returns {Promise<Array<import('./types').GridOrderSlot>>} Orders whose size is below the dust threshold.
1449
- */
1450
- static async _getDustOrders(manager, partials, type) {
1451
- if (!partials || partials.length === 0)
1452
- return [];
1453
- const side = type === ORDER_TYPES.BUY ? 'buy' : 'sell';
1454
- const ctx = await Grid._getSizingContext(manager, side);
1455
- if (!ctx || ctx.budget <= 0)
1456
- return [];
1457
- const sideSlots = Array.from(manager.orders.values())
1458
- .filter(o => o.type === type)
1459
- .sort((a, b) => a.price - b.price);
1460
- if (sideSlots.length === 0)
1461
- return [];
1462
- const idealSizes = allocateFundsByWeights(ctx.budget, sideSlots.length, manager.config.weightDistribution[side], manager.config.incrementPercent / 100, type === ORDER_TYPES.BUY, 0, ctx.precision);
1463
- return partials.filter(p => {
1464
- const idx = sideSlots.findIndex(s => s.id === p.id);
1465
- if (idx === -1)
1466
- return false;
1467
- const threshold = getSingleDustThreshold(idealSizes[idx], manager.config?.gridLimits?.PARTIAL_DUST_THRESHOLD_PERCENTAGE);
1468
- return p.size < threshold;
1469
- });
1470
- }
1471
- /**
1472
- * Check if any partial orders on a side represent "dust" that should be cleaned.
1473
- * @param {import('./types').OrderManager} manager - Manager instance
1474
- * @param {Array<import('./types').GridOrderSlot>} partials - Partial orders to check
1475
- * @param {string} type - ORDER_TYPES.BUY or ORDER_TYPES.SELL
1476
- * @returns {Promise<boolean>} true if dust partials exist
1477
- * @private
1478
- */
1479
- static async _hasAnyDust(manager, partials, type) {
1480
- return (await Grid._getDustOrders(manager, partials, type)).length > 0;
1481
- }
1482
- /**
1483
- * Public dust helper shared by StrategyEngine and Grid health checks.
1484
- * @param {import('./types').OrderManager} manager
1485
- * @param {Array<import('./types').GridOrderSlot>} partials
1486
- * @param {'buy'|'sell'} side
1487
- * @returns {Promise<boolean>}
1488
- */
1489
- static async hasAnyDust(manager, partials, side) {
1490
- const type = side === 'buy' ? ORDER_TYPES.BUY : side === 'sell' ? ORDER_TYPES.SELL : null;
1491
- if (!type)
1222
+ // 2. Check RMS-based divergence (structural deviation)
1223
+ const rmsResult = await compareGrids(calculatedGrid, persistedGrid, manager);
1224
+ const buyUpdated = ratioResult.buyUpdated || rmsResult.buy.updated;
1225
+ const sellUpdated = ratioResult.sellUpdated || rmsResult.sell.updated;
1226
+ const { getOrderTypeFromUpdatedFlags } = require('./utils/order');
1227
+ return {
1228
+ needsUpdate: buyUpdated || sellUpdated,
1229
+ buy: { updated: buyUpdated, ratio: ratioResult.buyUpdated, rms: rmsResult.buy.updated, metric: rmsResult.buy.metric },
1230
+ sell: { updated: sellUpdated, ratio: ratioResult.sellUpdated, rms: rmsResult.sell.updated, metric: rmsResult.sell.metric },
1231
+ orderType: getOrderTypeFromUpdatedFlags(buyUpdated, sellUpdated)
1232
+ };
1233
+ }
1234
+ /**
1235
+ * Collect on-chain buy and sell orders from the manager.
1236
+ * Filters to orders with valid orderId and positive size.
1237
+ * @param {import('./types').OrderManager} manager - The manager instance.
1238
+ * @returns {{onChainBuys: Array<import('./types').Order>, onChainSells: Array<import('./types').Order>}}
1239
+ */
1240
+ function _getOnChainOrders(manager) {
1241
+ const onChainBuys = [
1242
+ ...manager.getOrdersByTypeAndState(ORDER_TYPES.BUY, ORDER_STATES.ACTIVE),
1243
+ ...manager.getOrdersByTypeAndState(ORDER_TYPES.BUY, ORDER_STATES.PARTIAL)
1244
+ ].filter(o => o?.orderId && Number(o?.size || 0) > 0);
1245
+ const onChainSells = [
1246
+ ...manager.getOrdersByTypeAndState(ORDER_TYPES.SELL, ORDER_STATES.ACTIVE),
1247
+ ...manager.getOrdersByTypeAndState(ORDER_TYPES.SELL, ORDER_STATES.PARTIAL)
1248
+ ].filter(o => o?.orderId && Number(o?.size || 0) > 0);
1249
+ return { onChainBuys, onChainSells };
1250
+ }
1251
+ /**
1252
+ * Calculate current market spread using on-chain orders.
1253
+ * @param {import('./types').OrderManager} manager - The manager instance.
1254
+ * @returns {number} The calculated spread percentage.
1255
+ */
1256
+ function calculateCurrentSpread(manager) {
1257
+ const { onChainBuys, onChainSells } = _getOnChainOrders(manager);
1258
+ return calculateSpreadFromOrders(onChainBuys, onChainSells);
1259
+ }
1260
+ /**
1261
+ * Proactive spread correction check.
1262
+ *
1263
+ * CRITICAL: Uses AsyncLock to prevent race conditions with fill processing.
1264
+ * Without the lock, a TOCTOU (Time-Of-Check-To-Use) vulnerability exists where:
1265
+ * - Fund snapshot is taken (check phase)
1266
+ * - Fill processor modifies funds in another thread
1267
+ * - Order is placed based on stale funds (use phase)
1268
+ * Result: Orders placed beyond available liquidity, fund accounting errors
1269
+ *
1270
+ * DESIGN DECISION: Lock is released before blockchain operations for performance
1271
+ * - Lock held: Fund verification and correction decision (synchronized)
1272
+ * - Lock released: Blockchain submission (async, potentially slow)
1273
+ * - RACE CONDITION WINDOW: Between lock release and blockchain submission
1274
+ * - MITIGATION: Pre-flight fund verification before submission; comprehensive error handling
1275
+ *
1276
+ * See RACE_CONDITION_ANALYSIS.md for detailed vulnerability documentation.
1277
+ *
1278
+ * @param {import('./types').OrderManager} manager - Manager instance
1279
+ * @param {Object} BitShares - BitShares API client
1280
+ * @param {Function|null} [updateOrdersOnChainBatch=null] - Optional batch update function
1281
+ * @returns {Promise<import('./types').SpreadCheckResult>}
1282
+ */
1283
+ async function checkSpreadCondition(manager, BitShares, updateOrdersOnChainBatch = null) {
1284
+ // CRITICAL: Acquire corrections lock to serialize spread correction operations
1285
+ // This prevents concurrent fill processing from modifying funds while we're making decisions
1286
+ let correction = null;
1287
+ let shouldApplyCorrection = false;
1288
+ // Derive current market price from the bot's own grid (no blockchain call needed).
1289
+ // Grid prices are in B/A format (e.g. BTS/XRP) so no inversion is required.
1290
+ // Mid between best bid and best ask is the most current price the bot has.
1291
+ // Falls back to config.startPrice when either side is empty (e.g. at startup).
1292
+ const { onChainBuys, onChainSells } = _getOnChainOrders(manager);
1293
+ const { bestBuy, bestSell } = getGridBestPrices(onChainBuys, onChainSells);
1294
+ const lastPrice = (bestBuy !== null && bestSell !== null)
1295
+ ? (bestBuy + bestSell) / 2
1296
+ : Number(manager.config.startPrice) || 0;
1297
+ // FIX: Use optional chaining for lock - if no lock exists, execute synchronously
1298
+ let fundSnapshot = null;
1299
+ const executeSpreadCheck = async () => {
1300
+ const currentSpread = calculateCurrentSpread(manager);
1301
+ // Nominal spread is the configured target spread percentage.
1302
+ // Keep this fixed: doubled-side flags are fill/replacement mechanics only.
1303
+ const nominalSpread = manager.config.targetSpreadPercent ?? DEFAULT_CONFIG.targetSpreadPercent;
1304
+ // Fixed tolerance: 0.5 steps = half increment (tighter spread check).
1305
+ const toleranceSteps = 0.5;
1306
+ const buyCount = manager.getOrdersByTypeAndState(ORDER_TYPES.BUY, ORDER_STATES.ACTIVE)
1307
+ .concat(manager.getOrdersByTypeAndState(ORDER_TYPES.BUY, ORDER_STATES.PARTIAL))
1308
+ .filter(o => o?.orderId && Number(o?.size || 0) > 0)
1309
+ .length;
1310
+ const sellCount = manager.getOrdersByTypeAndState(ORDER_TYPES.SELL, ORDER_STATES.ACTIVE)
1311
+ .concat(manager.getOrdersByTypeAndState(ORDER_TYPES.SELL, ORDER_STATES.PARTIAL))
1312
+ .filter(o => o?.orderId && Number(o?.size || 0) > 0)
1313
+ .length;
1314
+ manager.outOfSpread = shouldFlagOutOfSpread(currentSpread, nominalSpread, toleranceSteps, buyCount, sellCount, manager.config.incrementPercent);
1315
+ if (manager.outOfSpread === 0)
1492
1316
  return false;
1493
- return await Grid._hasAnyDust(manager, partials, type);
1494
- }
1495
- /**
1496
- * Public dust helper that returns the subset of candidate partials currently below
1497
- * the configured dust threshold for the requested side.
1498
- * @param {import('./types').OrderManager} manager
1499
- * @param {Array<import('./types').GridOrderSlot>} partials
1500
- * @param {'buy'|'sell'} side
1501
- * @returns {Promise<Array<import('./types').GridOrderSlot>>}
1502
- */
1503
- static async getDustOrders(manager, partials, side) {
1504
- const type = side === 'buy' ? ORDER_TYPES.BUY : side === 'sell' ? ORDER_TYPES.SELL : null;
1505
- if (!type)
1506
- return [];
1507
- return await Grid._getDustOrders(manager, partials, type);
1508
- }
1509
- /**
1510
- * Determine which side has more available funds for spread correction.
1511
- * @param {import('./types').OrderManager} manager - The manager instance.
1512
- * @param {number} currentMarketPrice - Last traded price in B/A format (e.g. BTS/XRP), used to
1513
- * normalize sell-side funds into buy-side units for a fair cross-asset comparison.
1514
- * @returns {{ side: import('./types').OrderType|null, reason: string }} The side to correct on, or null if insufficient funds.
1515
- */
1516
- static determineOrderSideByFunds(manager, currentMarketPrice) {
1517
- const buyAvailable = Math.min(Number(manager.funds?.available?.buy || 0), Number(manager.accountTotals?.buyFree || 0));
1518
- const sellAvailable = Math.min(Number(manager.funds?.available?.sell || 0), Number(manager.accountTotals?.sellFree || 0));
1519
- // Need at least some funds on a side to justify correction
1520
- const buyPrecision = manager.assets?.assetB?.precision;
1521
- const sellPrecision = manager.assets?.assetA?.precision;
1522
- if (buyPrecision === undefined || sellPrecision === undefined) {
1523
- throw new Error(`CRITICAL: Asset precision unavailable for grid correction check`);
1524
- }
1525
- const buyMinUnit = 1 / Math.pow(10, buyPrecision);
1526
- const sellMinUnit = 1 / Math.pow(10, sellPrecision);
1527
- const buyViable = buyAvailable > buyMinUnit;
1528
- const sellViable = sellAvailable > sellMinUnit;
1529
- let side = null;
1530
- if (buyViable && sellViable) {
1531
- // Normalize sell (assetA) to assetB units using market price so both sides
1532
- // are comparable. Without this, a raw number comparison (e.g. 2192 BTS vs
1533
- // 0.12 XRP) always picks BUY even when the sell side is larger in value.
1534
- const marketPrice = Number(currentMarketPrice);
1535
- const sellInBuyUnits = (Number.isFinite(marketPrice) && marketPrice > 0)
1536
- ? sellAvailable * marketPrice
1537
- : sellAvailable;
1538
- side = buyAvailable >= sellInBuyUnits ? ORDER_TYPES.BUY : ORDER_TYPES.SELL;
1539
- }
1540
- else if (buyViable) {
1541
- side = ORDER_TYPES.BUY;
1542
- }
1543
- else if (sellViable) {
1544
- side = ORDER_TYPES.SELL;
1545
- }
1546
- if (!side) {
1547
- const committedBuy = Math.max(0, Number(manager.funds?.committed?.chain?.buy || 0));
1548
- const committedSell = Math.max(0, Number(manager.funds?.committed?.chain?.sell || 0));
1549
- const marketPrice = Number(currentMarketPrice);
1550
- const hasValidPrice = Number.isFinite(marketPrice) && marketPrice > 0;
1551
- if (committedBuy > buyMinUnit || committedSell > sellMinUnit) {
1552
- if (hasValidPrice) {
1553
- const buyComparable = committedBuy;
1554
- const sellComparable = committedSell * marketPrice;
1555
- side = buyComparable >= sellComparable ? ORDER_TYPES.BUY : ORDER_TYPES.SELL;
1556
- }
1557
- else if (committedBuy > buyMinUnit && committedSell <= sellMinUnit) {
1558
- side = ORDER_TYPES.BUY;
1559
- }
1560
- else if (committedSell > sellMinUnit && committedBuy <= buyMinUnit) {
1561
- side = ORDER_TYPES.SELL;
1562
- }
1563
- else {
1564
- // Deterministic fallback when both sides hold inventory but market valuation is unavailable.
1565
- side = ORDER_TYPES.BUY;
1566
- }
1567
- manager.logger?.log?.(`Spread correction using redistribution fallback on ${side} ` +
1568
- `(free buy=${Format.formatAmount8(buyAvailable)}, free sell=${Format.formatAmount8(sellAvailable)}, ` +
1569
- `price=${hasValidPrice ? Format.formatAmount8(marketPrice) : 'unavailable'})`, 'info');
1570
- }
1571
- }
1572
- if (!side) {
1573
- manager.logger?.log?.(`Spread correction skipped: insufficient free funds and no committed inventory to redistribute ` +
1574
- `(buy=${Format.formatAmount8(buyAvailable)}, sell=${Format.formatAmount8(sellAvailable)})`, 'warn');
1317
+ // Limit spread = nominal + half increment tolerance (0.5 steps).
1318
+ const limitSpread = nominalSpread + (manager.config.incrementPercent * toleranceSteps);
1319
+ manager.logger?.log?.(`Spread too wide (${Format.formatPercent(currentSpread)} > ${Format.formatPercent(limitSpread)}), correcting with ${manager.outOfSpread} extra slot(s)...`, 'warn');
1320
+ const decision = determineOrderSideByFunds(manager, lastPrice);
1321
+ if (!decision.side)
1322
+ return false;
1323
+ // Perform spread correction by placing orders on the chosen side.
1324
+ correction = await prepareSpreadCorrectionOrders(manager, decision.side);
1325
+ if (!correction)
1326
+ return false;
1327
+ const placeCount = correction.ordersToPlace?.length || 0;
1328
+ const updateCount = correction.ordersToUpdate?.length || 0;
1329
+ // Capture fund snapshot under lock for pre-flight verification before broadcast
1330
+ fundSnapshot = _snapshotFundState(manager);
1331
+ return (placeCount + updateCount) > 0;
1332
+ };
1333
+ try {
1334
+ shouldApplyCorrection = await manager._gridLock.acquire(executeSpreadCheck);
1335
+ }
1336
+ catch (err) {
1337
+ manager.logger?.log?.(`Error checking spread condition: ${err.message}`, 'warn');
1338
+ return { ordersPlaced: 0, partialsMoved: 0 };
1339
+ }
1340
+ // FIX: Apply blockchain operations OUTSIDE the lock to reduce lock contention
1341
+ // The lock is only needed for fund verification; order placement doesn't need it
1342
+ // Pre-flight fund verification to mitigate TOCTOU between lock release and broadcast
1343
+ if (shouldApplyCorrection && updateOrdersOnChainBatch && correction && fundSnapshot) {
1344
+ const currentFunds = _snapshotFundState(manager);
1345
+ const fundChanged = fundSnapshot.buyFree !== currentFunds.buyFree
1346
+ || fundSnapshot.sellFree !== currentFunds.sellFree
1347
+ || fundSnapshot.buyLocked !== currentFunds.buyLocked
1348
+ || fundSnapshot.sellLocked !== currentFunds.sellLocked;
1349
+ if (fundChanged) {
1350
+ manager.logger?.log?.(`Spread correction aborted: fund state changed between lock release and broadcast (pre-flight check)`, 'warn');
1351
+ return { ordersPlaced: 0, partialsMoved: 0 };
1575
1352
  }
1576
- return { side, reason: side ? `Choosing ${side}` : 'Insufficient funds or committed inventory' };
1577
- }
1578
- /**
1579
- * Calculate the geometric ideal size for a new order being placed during spread correction.
1580
- * @param {import('./types').OrderManager} manager - The manager instance.
1581
- * @param {import('./types').OrderType} targetType - The type of order being placed (ORDER_TYPES.BUY or ORDER_TYPES.SELL).
1582
- * @returns {Promise<number|null>} The calculated geometric size.
1583
- */
1584
- static async calculateGeometricSizeForSpreadCorrection(manager, targetType) {
1585
- const side = targetType === ORDER_TYPES.BUY ? 'buy' : 'sell';
1586
- const slotsCount = Array.from(manager.orders.values()).filter(o => o.type === targetType).length + 1;
1587
- // Use centralized sizing context (respects botFunds % allocation)
1588
- const ctx = await Grid._getSizingContext(manager, side);
1589
- if (!ctx || ctx.budget <= 0 || slotsCount < 1)
1590
- return null;
1591
- // ALLOW slotsCount === 1 to enable spread correction even if a side is completely missing
1592
- const dummy = Array.from({ length: slotsCount }, () => ({ type: targetType }));
1593
1353
  try {
1594
- const sized = calculateOrderSizes(dummy, manager.config, side === 'sell' ? ctx.budget : 0, side === 'buy' ? ctx.budget : 0, 0, 0, ctx.precision, ctx.precision);
1595
- if (!Array.isArray(sized) || sized.length === 0) {
1596
- manager.logger?.log?.(`calculateOrderSizes returned invalid result for spread correction`, 'warn');
1597
- return null;
1354
+ const batchResult = await updateOrdersOnChainBatch(correction);
1355
+ if (!batchResult || batchResult.executed !== true) {
1356
+ manager.logger?.log?.(`Spread correction batch was prepared but not executed. Keeping local state unchanged.`, 'warn');
1357
+ return { ordersPlaced: 0, partialsMoved: 0 };
1598
1358
  }
1599
- return side === 'sell' ? sized[0].size : sized[sized.length - 1].size;
1359
+ await manager.recalculateFunds();
1360
+ const placed = correction.ordersToPlace?.length || 0;
1361
+ const updated = correction.ordersToUpdate?.length || 0;
1362
+ return { ordersPlaced: placed + updated, partialsMoved: updated };
1600
1363
  }
1601
- catch (e) {
1602
- manager.logger?.log?.(`Error calculating geometric size for spread correction: ${e.message}`, 'warn');
1603
- return null;
1364
+ catch (err) {
1365
+ manager.logger?.log?.(`Error applying spread correction on-chain: ${err.message}`, 'warn');
1366
+ return { ordersPlaced: 0, partialsMoved: 0 };
1604
1367
  }
1605
1368
  }
1606
- /**
1607
- * Prepares one or more orders to correct a wide spread.
1608
- * @param {import('./types').OrderManager} manager - The OrderManager instance.
1609
- * @param {string} preferredSide - The side to place the correction on (ORDER_TYPES.BUY/SELL).
1610
- * @returns {Promise<import('./types').SpreadCorrectionResult>}
1611
- * @throws {Error} If preferredSide is invalid.
1612
- */
1613
- static async prepareSpreadCorrectionOrders(manager, preferredSide) {
1614
- // FIX: Validate preferredSide parameter to prevent silent logic errors
1615
- if (preferredSide !== ORDER_TYPES.BUY && preferredSide !== ORDER_TYPES.SELL) {
1616
- throw new Error(`Invalid preferredSide: ${preferredSide}. Must be '${ORDER_TYPES.BUY}' or '${ORDER_TYPES.SELL}'.`);
1617
- }
1618
- const ordersToPlace = [];
1619
- const ordersToUpdate = [];
1620
- const railType = preferredSide;
1621
- const sideName = railType === ORDER_TYPES.BUY ? 'buy' : 'sell';
1622
- const configuredMissingSlots = Number(manager.outOfSpread || 0);
1623
- const missingSlots = configuredMissingSlots > 0
1624
- ? Math.floor(configuredMissingSlots)
1625
- : 1;
1626
- // STRATEGY: Edge-Based Correction (Safe Bridging)
1627
- // Instead of calculating a "mid-price" (which can be dangerous in wide gaps),
1628
- // we strictly target the orders closest to the spread gap.
1629
- // 1. Priority: Update existing PARTIAL orders at the edge (Highest Buy / Lowest Sell).
1630
- // 2. Fallback: Activate SPREAD slots at the edge (Lowest Spread for Buy / Highest Spread for Sell).
1631
- const allOrders = Array.from(manager.orders.values());
1632
- let edgePartial = null;
1633
- const partials = allOrders
1634
- .filter(o => o.type === railType && o.state === ORDER_STATES.PARTIAL)
1635
- .sort((a, b) => railType === ORDER_TYPES.BUY ? b.price - a.price : a.price - b.price);
1636
- if (partials.length > 0) {
1637
- edgePartial = partials[0];
1638
- manager.logger?.log?.(`[SPREAD-CORRECTION] Identified partial order at ${edgePartial.price} for update`, 'debug');
1639
- }
1640
- // Primary candidates: SPREAD-type slots adjacent to the gap.
1641
- const typedSpreadCandidates = allOrders
1642
- .filter(o => o.type === ORDER_TYPES.SPREAD && isSlotAvailable(o))
1643
- .sort((a, b) => railType === ORDER_TYPES.BUY ? a.price - b.price : b.price - a.price)
1644
- .slice(0, missingSlots);
1645
- // Secondary candidates: orphaned virtual slots of the correct side-type that have
1646
- // lost their order (e.g. stale-cleaned after a race condition during a crash).
1647
- // These sit inside the active window and are invisible to the SPREAD-type filter above.
1648
- const orphanedVirtualCandidates = allOrders
1649
- .filter(o => o.type === railType && o.state === ORDER_STATES.VIRTUAL && !o.orderId && Number(o.size || 0) === 0)
1650
- .sort((a, b) => railType === ORDER_TYPES.BUY ? b.price - a.price : a.price - b.price)
1651
- .slice(0, missingSlots);
1652
- // Merge: prefer orphaned virtuals (they already occupy correct grid positions) then
1653
- // fall back to SPREAD slots for any remaining quota.
1654
- const remainingQuota = Math.max(0, missingSlots - orphanedVirtualCandidates.length);
1655
- const spreadCandidates = [
1656
- ...orphanedVirtualCandidates,
1657
- ...typedSpreadCandidates.slice(0, remainingQuota)
1658
- ];
1659
- if (spreadCandidates.length > 0) {
1660
- manager.logger?.log?.(`[SPREAD-CORRECTION] Identified ${spreadCandidates.length}/${missingSlots} slot(s) for activation on ${sideName} (orphaned=${orphanedVirtualCandidates.length}, spread=${spreadCandidates.length - orphanedVirtualCandidates.length})`, 'debug');
1661
- }
1662
- if (!edgePartial && spreadCandidates.length === 0) {
1663
- manager.logger?.log?.(`[SPREAD-CORRECTION] No suitable partials, orphaned virtual slots, or spread slots found. Skipping.`, 'warn');
1664
- return { ordersToPlace: [], ordersToUpdate: [] };
1665
- }
1666
- const orphanedIds = new Set(orphanedVirtualCandidates.map(o => o.id));
1667
- const sideSlots = allOrders
1668
- .filter(o => o.type === railType && !orphanedIds.has(o.id))
1669
- .sort((a, b) => a.price - b.price);
1670
- const syntheticSideSlots = [
1671
- ...sideSlots,
1672
- ...spreadCandidates.map(slot => ({ ...slot, type: railType }))
1673
- ].sort((a, b) => a.price - b.price);
1674
- const ctx = await Grid._getSizingContext(manager, sideName);
1675
- if (!ctx || ctx.budget <= 0 || syntheticSideSlots.length === 0) {
1676
- return { ordersToPlace: [], ordersToUpdate: [] };
1677
- }
1678
- const precisionEpsilon = getPrecisionSlack(ctx.precision, 1);
1679
- const idealSizes = allocateFundsByWeights(ctx.budget, syntheticSideSlots.length, manager.config.weightDistribution[sideName], manager.config.incrementPercent / 100, railType === ORDER_TYPES.BUY, 0, ctx.precision);
1680
- const idealById = new Map();
1681
- syntheticSideSlots.forEach((slot, idx) => {
1682
- idealById.set(slot.id, Number(idealSizes[idx] || 0));
1683
- });
1684
- const availableFund = Math.max(0, Math.min(Number(manager.funds?.available?.[sideName] || 0), Number(sideName === 'buy' ? manager.accountTotals?.buyFree : manager.accountTotals?.sellFree) || 0));
1685
- const minAbsoluteSize = getMinAbsoluteOrderSize(railType, manager.assets);
1686
- const prioritizedTargets = [];
1687
- if (edgePartial && edgePartial.id) {
1688
- const ideal = Number(idealById.get(edgePartial.id) || 0);
1689
- const current = Number(edgePartial.size || 0);
1690
- if (ideal > current + precisionEpsilon) {
1691
- prioritizedTargets.push({
1692
- kind: 'partial-topup',
1693
- order: edgePartial,
1694
- current,
1695
- ideal,
1696
- needed: Math.max(0, ideal - current)
1697
- });
1369
+ return { ordersPlaced: 0, partialsMoved: 0 };
1370
+ }
1371
+ /**
1372
+ * Grid health check for structural violations.
1373
+ * Monitors for "Dust Partials" that are too small to be traded on-chain,
1374
+ * scoped to the active buy/sell window.
1375
+ *
1376
+ * NOTE: Internal gaps (virtual slots between active ones) are no longer
1377
+ * flagged as violations. The "Edge-First" placement strategy intentionally
1378
+ * creates these gaps to maximize grid coverage during fund expansion.
1379
+ *
1380
+ * @param {import('./types').OrderManager} manager - The manager instance.
1381
+ * @param {Function|null} [updateOrdersOnChainBatch=null] - Optional batch update function.
1382
+ * @returns {Promise<import('./types').DustCheckResult>}
1383
+ */
1384
+ async function checkGridHealth(manager, updateOrdersOnChainBatch = null) {
1385
+ if (!manager)
1386
+ return { buyDust: false, sellDust: false, buyDustOrders: [], sellDustOrders: [] };
1387
+ // Skip health checks during bootstrap to prevent spamming warnings
1388
+ if (manager.isBootstrapping())
1389
+ return { buyDust: false, sellDust: false, buyDustOrders: [], sellDustOrders: [] };
1390
+ // Health checks are scoped to the active on-chain window only.
1391
+ // This keeps detection aligned with maintenance actions that operate on
1392
+ // active window partials.
1393
+ const { buyDust, sellDust, buyDustOrders, sellDustOrders } = await checkWindowDust(manager);
1394
+ // Partial split/merge maintenance is intentionally disabled.
1395
+ // Health checks remain detection-only.
1396
+ return { buyDust, sellDust, buyDustOrders, sellDustOrders };
1397
+ }
1398
+ /**
1399
+ * Dust check covering all partial orders, with interior-only guard.
1400
+ *
1401
+ * The top-of-window partial (closest to market) is always eligible for dust
1402
+ * detection since cancelling it is just the grid edge moving inward.
1403
+ *
1404
+ * Interior partials (further from market) are only eligible if they have a
1405
+ * duplicate price level — another active order at essentially the same price.
1406
+ * Cancelling such an interior partial won't leave a gap in the grid because
1407
+ * the sibling active order already covers that price level.
1408
+ *
1409
+ * Returns boolean flags plus the actual dust order objects so callers can act
1410
+ * on individual orders (e.g. DUST_CANCEL_DELAY_SEC auto-cancel).
1411
+ *
1412
+ * @param {import('./types').OrderManager} manager
1413
+ * @returns {Promise<import('./types').DustCheckResult>}
1414
+ */
1415
+ async function checkWindowDust(manager) {
1416
+ if (!manager)
1417
+ return { buyDust: false, sellDust: false, buyDustOrders: [], sellDustOrders: [] };
1418
+ const allOrders = Array.from(manager.orders.values());
1419
+ const isLiveOrder = order => order &&
1420
+ order.orderId &&
1421
+ order.price != null &&
1422
+ (order.state === ORDER_STATES.ACTIVE || order.state === ORDER_STATES.PARTIAL);
1423
+ // Identify top-of-window orders (closest to market per side).
1424
+ const topBuyOrder = allOrders
1425
+ .filter(o => o.type === ORDER_TYPES.BUY && isLiveOrder(o))
1426
+ .sort((a, b) => b.price - a.price)[0];
1427
+ const topSellOrder = allOrders
1428
+ .filter(o => o.type === ORDER_TYPES.SELL && isLiveOrder(o))
1429
+ .sort((a, b) => a.price - b.price)[0];
1430
+ // Check if an order has a duplicate price level — an active sibling at the
1431
+ // same price within tolerance. If so, cancelling won't create a grid gap.
1432
+ // Only checks ACTIVE siblings. If two PARTIALs share a price with no active
1433
+ // sibling, neither qualifies and the gap is left to the rebalancer.
1434
+ // Uses the LARGER size of the two orders for tolerance calculation to prevent
1435
+ // a tiny dust order from inflating the tolerance window.
1436
+ const hasDuplicatePriceLevel = (order, assets) => allOrders.some(o => {
1437
+ if (o.id === order.id || o.type !== order.type)
1438
+ return false;
1439
+ if (o.state !== ORDER_STATES.ACTIVE || !o.orderId || o.price == null)
1440
+ return false;
1441
+ const toleranceSize = Math.max(order.size, o.size);
1442
+ const tolerance = calculatePriceTolerance(Math.min(order.price, o.price), toleranceSize, order.type, assets);
1443
+ return tolerance != null && Math.abs(o.price - order.price) <= tolerance;
1444
+ });
1445
+ const assets = manager.assets;
1446
+ const allPartials = allOrders.filter((o) => isLiveOrder(o) && o.state === ORDER_STATES.PARTIAL);
1447
+ const isTopBuy = (o) => topBuyOrder && o.id === topBuyOrder.id;
1448
+ const isTopSell = (o) => topSellOrder && o.id === topSellOrder.id;
1449
+ // Safety filter: top-of-window partials always qualify; interior partials
1450
+ // only qualify if they have a duplicate price level (no gap risk).
1451
+ const eligibleBuyPartials = allPartials.filter((o) => o.type === ORDER_TYPES.BUY && (isTopBuy(o) || hasDuplicatePriceLevel(o, assets)));
1452
+ const eligibleSellPartials = allPartials.filter((o) => o.type === ORDER_TYPES.SELL && (isTopSell(o) || hasDuplicatePriceLevel(o, assets)));
1453
+ const buyDustOrders = await _getDustOrders(manager, eligibleBuyPartials, ORDER_TYPES.BUY);
1454
+ const sellDustOrders = await _getDustOrders(manager, eligibleSellPartials, ORDER_TYPES.SELL);
1455
+ return {
1456
+ buyDust: buyDustOrders.length > 0,
1457
+ sellDust: sellDustOrders.length > 0,
1458
+ buyDustOrders,
1459
+ sellDustOrders,
1460
+ };
1461
+ }
1462
+ /**
1463
+ * Return the subset of partial orders that qualify as dust on a given side.
1464
+ * Shares the same sizing context as _hasAnyDust but returns the actual order
1465
+ * objects so callers can act on them (e.g. auto-cancel).
1466
+ * @private
1467
+ * @param {import('./types').OrderManager} manager
1468
+ * @param {Array<import('./types').GridOrderSlot>} partials - Candidate partial orders to test.
1469
+ * @param {string} type - ORDER_TYPES.BUY or ORDER_TYPES.SELL
1470
+ * @returns {Promise<Array<import('./types').GridOrderSlot>>} Orders whose size is below the dust threshold.
1471
+ */
1472
+ async function _getDustOrders(manager, partials, type) {
1473
+ if (!partials || partials.length === 0)
1474
+ return [];
1475
+ const side = type === ORDER_TYPES.BUY ? 'buy' : 'sell';
1476
+ const ctx = await _getSizingContext(manager, side);
1477
+ if (!ctx || ctx.budget <= 0)
1478
+ return [];
1479
+ const sideSlots = Array.from(manager.orders.values())
1480
+ .filter(o => o.type === type)
1481
+ .sort((a, b) => a.price - b.price);
1482
+ if (sideSlots.length === 0)
1483
+ return [];
1484
+ const idealSizes = allocateFundsByWeights(ctx.budget, sideSlots.length, manager.config.weightDistribution[side], manager.config.incrementPercent / 100, type === ORDER_TYPES.BUY, 0, ctx.precision);
1485
+ return partials.filter(p => {
1486
+ const idx = sideSlots.findIndex(s => s.id === p.id);
1487
+ if (idx === -1)
1488
+ return false;
1489
+ const threshold = getSingleDustThreshold(idealSizes[idx], manager.config?.gridLimits?.PARTIAL_DUST_THRESHOLD_PERCENTAGE);
1490
+ return p.size < threshold;
1491
+ });
1492
+ }
1493
+ /**
1494
+ * Check if any partial orders on a side represent "dust" that should be cleaned.
1495
+ * @param {import('./types').OrderManager} manager - Manager instance
1496
+ * @param {Array<import('./types').GridOrderSlot>} partials - Partial orders to check
1497
+ * @param {string} type - ORDER_TYPES.BUY or ORDER_TYPES.SELL
1498
+ * @returns {Promise<boolean>} true if dust partials exist
1499
+ * @private
1500
+ */
1501
+ async function _hasAnyDust(manager, partials, type) {
1502
+ return (await _getDustOrders(manager, partials, type)).length > 0;
1503
+ }
1504
+ /**
1505
+ * Public dust helper shared by StrategyEngine and Grid health checks.
1506
+ * @param {import('./types').OrderManager} manager
1507
+ * @param {Array<import('./types').GridOrderSlot>} partials
1508
+ * @param {'buy'|'sell'} side
1509
+ * @returns {Promise<boolean>}
1510
+ */
1511
+ async function hasAnyDust(manager, partials, side) {
1512
+ const type = side === 'buy' ? ORDER_TYPES.BUY : side === 'sell' ? ORDER_TYPES.SELL : null;
1513
+ if (!type)
1514
+ return false;
1515
+ return await _hasAnyDust(manager, partials, type);
1516
+ }
1517
+ /**
1518
+ * Public dust helper that returns the subset of candidate partials currently below
1519
+ * the configured dust threshold for the requested side.
1520
+ * @param {import('./types').OrderManager} manager
1521
+ * @param {Array<import('./types').GridOrderSlot>} partials
1522
+ * @param {'buy'|'sell'} side
1523
+ * @returns {Promise<Array<import('./types').GridOrderSlot>>}
1524
+ */
1525
+ async function getDustOrders(manager, partials, side) {
1526
+ const type = side === 'buy' ? ORDER_TYPES.BUY : side === 'sell' ? ORDER_TYPES.SELL : null;
1527
+ if (!type)
1528
+ return [];
1529
+ return await _getDustOrders(manager, partials, type);
1530
+ }
1531
+ /**
1532
+ * Determine which side has more available funds for spread correction.
1533
+ * @param {import('./types').OrderManager} manager - The manager instance.
1534
+ * @param {number} currentMarketPrice - Last traded price in B/A format (e.g. BTS/XRP), used to
1535
+ * normalize sell-side funds into buy-side units for a fair cross-asset comparison.
1536
+ * @returns {{ side: import('./types').OrderType|null, reason: string }} The side to correct on, or null if insufficient funds.
1537
+ */
1538
+ function determineOrderSideByFunds(manager, currentMarketPrice) {
1539
+ const buyAvailable = Math.min(Number(manager.funds?.available?.buy || 0), Number(manager.accountTotals?.buyFree || 0));
1540
+ const sellAvailable = Math.min(Number(manager.funds?.available?.sell || 0), Number(manager.accountTotals?.sellFree || 0));
1541
+ // Need at least some funds on a side to justify correction
1542
+ const buyPrecision = manager.assets?.assetB?.precision;
1543
+ const sellPrecision = manager.assets?.assetA?.precision;
1544
+ if (buyPrecision === undefined || sellPrecision === undefined) {
1545
+ throw new Error(`CRITICAL: Asset precision unavailable for grid correction check`);
1546
+ }
1547
+ const buyMinUnit = 1 / Math.pow(10, buyPrecision);
1548
+ const sellMinUnit = 1 / Math.pow(10, sellPrecision);
1549
+ const buyViable = buyAvailable > buyMinUnit;
1550
+ const sellViable = sellAvailable > sellMinUnit;
1551
+ let side = null;
1552
+ if (buyViable && sellViable) {
1553
+ // Normalize sell (assetA) to assetB units using market price so both sides
1554
+ // are comparable. Without this, a raw number comparison (e.g. 2192 BTS vs
1555
+ // 0.12 XRP) always picks BUY even when the sell side is larger in value.
1556
+ const marketPrice = Number(currentMarketPrice);
1557
+ const sellInBuyUnits = (Number.isFinite(marketPrice) && marketPrice > 0)
1558
+ ? sellAvailable * marketPrice
1559
+ : sellAvailable;
1560
+ side = buyAvailable >= sellInBuyUnits ? ORDER_TYPES.BUY : ORDER_TYPES.SELL;
1561
+ }
1562
+ else if (buyViable) {
1563
+ side = ORDER_TYPES.BUY;
1564
+ }
1565
+ else if (sellViable) {
1566
+ side = ORDER_TYPES.SELL;
1567
+ }
1568
+ if (!side) {
1569
+ const committedBuy = Math.max(0, Number(manager.funds?.committed?.chain?.buy || 0));
1570
+ const committedSell = Math.max(0, Number(manager.funds?.committed?.chain?.sell || 0));
1571
+ const marketPrice = Number(currentMarketPrice);
1572
+ const hasValidPrice = Number.isFinite(marketPrice) && marketPrice > 0;
1573
+ if (committedBuy > buyMinUnit || committedSell > sellMinUnit) {
1574
+ if (hasValidPrice) {
1575
+ const buyComparable = committedBuy;
1576
+ const sellComparable = committedSell * marketPrice;
1577
+ side = buyComparable >= sellComparable ? ORDER_TYPES.BUY : ORDER_TYPES.SELL;
1698
1578
  }
1699
- }
1700
- for (const slot of spreadCandidates) {
1701
- const ideal = Number(idealById.get(slot.id) || 0);
1702
- if (ideal > precisionEpsilon) {
1703
- prioritizedTargets.push({
1704
- kind: 'create',
1705
- order: slot,
1706
- current: 0,
1707
- ideal,
1708
- needed: ideal
1709
- });
1579
+ else if (committedBuy > buyMinUnit && committedSell <= sellMinUnit) {
1580
+ side = ORDER_TYPES.BUY;
1710
1581
  }
1711
- }
1712
- if (prioritizedTargets.length === 0) {
1713
- return { ordersToPlace: [], ordersToUpdate: [] };
1714
- }
1715
- const totalNeeded = prioritizedTargets.reduce((sum, t) => sum + Math.max(0, Number(t.needed || 0)), 0);
1716
- let recoveredBudget = 0;
1717
- const redistributionUpdates = [];
1718
- if (totalNeeded > availableFund + precisionEpsilon) {
1719
- let shortfall = totalNeeded - availableFund;
1720
- const donors = sideSlots
1721
- .filter(o => hasOnChainId(o) && (o.state === ORDER_STATES.ACTIVE || o.state === ORDER_STATES.PARTIAL))
1722
- .filter(o => !edgePartial || o.id !== edgePartial.id)
1723
- .sort((a, b) => railType === ORDER_TYPES.BUY ? a.price - b.price : b.price - a.price);
1724
- for (const donor of donors) {
1725
- if (shortfall <= precisionEpsilon)
1726
- break;
1727
- const donorCurrent = Number(donor.size || 0);
1728
- const donorIdeal = Number(idealById.get(donor.id) || 0);
1729
- const donorFloor = Math.max(minAbsoluteSize, donorIdeal);
1730
- const donorReducible = Math.max(0, donorCurrent - donorFloor);
1731
- if (donorReducible <= precisionEpsilon)
1732
- continue;
1733
- const reduction = Math.min(donorReducible, shortfall);
1734
- const donorNext = donorCurrent - reduction;
1735
- if (donorNext <= precisionEpsilon)
1736
- continue;
1737
- if (!isOrderHealthy(donorNext, railType, manager.assets, donorIdeal || donorNext))
1738
- continue;
1739
- redistributionUpdates.push({ partialOrder: { ...donor }, newSize: donorNext });
1740
- recoveredBudget += reduction;
1741
- shortfall -= reduction;
1582
+ else if (committedSell > sellMinUnit && committedBuy <= buyMinUnit) {
1583
+ side = ORDER_TYPES.SELL;
1742
1584
  }
1743
- if (recoveredBudget > precisionEpsilon) {
1744
- manager.logger?.log?.(`[SPREAD-CORRECTION] Recovered ${Format.formatSizeByOrderType(recoveredBudget, railType, manager.assets)} on ${sideName} via redistribution`, 'info');
1585
+ else {
1586
+ // Deterministic fallback when both sides hold inventory but market valuation is unavailable.
1587
+ side = ORDER_TYPES.BUY;
1745
1588
  }
1589
+ manager.logger?.log?.(`Spread correction using redistribution fallback on ${side} ` +
1590
+ `(free buy=${Format.formatAmount8(buyAvailable)}, free sell=${Format.formatAmount8(sellAvailable)}, ` +
1591
+ `price=${hasValidPrice ? Format.formatAmount8(marketPrice) : 'unavailable'})`, 'info');
1746
1592
  }
1747
- let remainingBudget = availableFund + recoveredBudget;
1748
- for (const target of prioritizedTargets) {
1749
- if (remainingBudget <= precisionEpsilon)
1593
+ }
1594
+ if (!side) {
1595
+ manager.logger?.log?.(`Spread correction skipped: insufficient free funds and no committed inventory to redistribute ` +
1596
+ `(buy=${Format.formatAmount8(buyAvailable)}, sell=${Format.formatAmount8(sellAvailable)})`, 'warn');
1597
+ }
1598
+ return { side, reason: side ? `Choosing ${side}` : 'Insufficient funds or committed inventory' };
1599
+ }
1600
+ /**
1601
+ * Calculate the geometric ideal size for a new order being placed during spread correction.
1602
+ * @param {import('./types').OrderManager} manager - The manager instance.
1603
+ * @param {import('./types').OrderType} targetType - The type of order being placed (ORDER_TYPES.BUY or ORDER_TYPES.SELL).
1604
+ * @returns {Promise<number|null>} The calculated geometric size.
1605
+ */
1606
+ async function calculateGeometricSizeForSpreadCorrection(manager, targetType) {
1607
+ const side = targetType === ORDER_TYPES.BUY ? 'buy' : 'sell';
1608
+ const slotsCount = Array.from(manager.orders.values()).filter(o => o.type === targetType).length + 1;
1609
+ // Use centralized sizing context (respects botFunds % allocation)
1610
+ const ctx = await _getSizingContext(manager, side);
1611
+ if (!ctx || ctx.budget <= 0 || slotsCount < 1)
1612
+ return null;
1613
+ // ALLOW slotsCount === 1 to enable spread correction even if a side is completely missing
1614
+ const dummy = Array.from({ length: slotsCount }, () => ({ type: targetType }));
1615
+ try {
1616
+ const sized = calculateOrderSizes(dummy, manager.config, side === 'sell' ? ctx.budget : 0, side === 'buy' ? ctx.budget : 0, 0, 0, ctx.precision, ctx.precision);
1617
+ if (!Array.isArray(sized) || sized.length === 0) {
1618
+ manager.logger?.log?.(`calculateOrderSizes returned invalid result for spread correction`, 'warn');
1619
+ return null;
1620
+ }
1621
+ return side === 'sell' ? sized[0].size : sized[sized.length - 1].size;
1622
+ }
1623
+ catch (e) {
1624
+ manager.logger?.log?.(`Error calculating geometric size for spread correction: ${e.message}`, 'warn');
1625
+ return null;
1626
+ }
1627
+ }
1628
+ /**
1629
+ * Prepares one or more orders to correct a wide spread.
1630
+ * @param {import('./types').OrderManager} manager - The OrderManager instance.
1631
+ * @param {string} preferredSide - The side to place the correction on (ORDER_TYPES.BUY/SELL).
1632
+ * @returns {Promise<import('./types').SpreadCorrectionResult>}
1633
+ * @throws {Error} If preferredSide is invalid.
1634
+ */
1635
+ async function prepareSpreadCorrectionOrders(manager, preferredSide) {
1636
+ // FIX: Validate preferredSide parameter to prevent silent logic errors
1637
+ if (preferredSide !== ORDER_TYPES.BUY && preferredSide !== ORDER_TYPES.SELL) {
1638
+ throw new Error(`Invalid preferredSide: ${preferredSide}. Must be '${ORDER_TYPES.BUY}' or '${ORDER_TYPES.SELL}'.`);
1639
+ }
1640
+ const ordersToPlace = [];
1641
+ const ordersToUpdate = [];
1642
+ const railType = preferredSide;
1643
+ const sideName = railType === ORDER_TYPES.BUY ? 'buy' : 'sell';
1644
+ const configuredMissingSlots = Number(manager.outOfSpread || 0);
1645
+ const missingSlots = configuredMissingSlots > 0
1646
+ ? Math.floor(configuredMissingSlots)
1647
+ : 1;
1648
+ // STRATEGY: Edge-Based Correction (Safe Bridging)
1649
+ // Instead of calculating a "mid-price" (which can be dangerous in wide gaps),
1650
+ // we strictly target the orders closest to the spread gap.
1651
+ // 1. Priority: Update existing PARTIAL orders at the edge (Highest Buy / Lowest Sell).
1652
+ // 2. Fallback: Activate SPREAD slots at the edge (Lowest Spread for Buy / Highest Spread for Sell).
1653
+ const allOrders = Array.from(manager.orders.values());
1654
+ let edgePartial = null;
1655
+ const partials = allOrders
1656
+ .filter(o => o.type === railType && o.state === ORDER_STATES.PARTIAL)
1657
+ .sort((a, b) => railType === ORDER_TYPES.BUY ? b.price - a.price : a.price - b.price);
1658
+ if (partials.length > 0) {
1659
+ edgePartial = partials[0];
1660
+ manager.logger?.log?.(`[SPREAD-CORRECTION] Identified partial order at ${edgePartial.price} for update`, 'debug');
1661
+ }
1662
+ // Primary candidates: SPREAD-type slots adjacent to the gap.
1663
+ const typedSpreadCandidates = allOrders
1664
+ .filter(o => o.type === ORDER_TYPES.SPREAD && isSlotAvailable(o))
1665
+ .sort((a, b) => railType === ORDER_TYPES.BUY ? a.price - b.price : b.price - a.price)
1666
+ .slice(0, missingSlots);
1667
+ // Secondary candidates: orphaned virtual slots of the correct side-type that have
1668
+ // lost their order (e.g. stale-cleaned after a race condition during a crash).
1669
+ // These sit inside the active window and are invisible to the SPREAD-type filter above.
1670
+ const orphanedVirtualCandidates = allOrders
1671
+ .filter(o => o.type === railType && o.state === ORDER_STATES.VIRTUAL && !o.orderId && Number(o.size || 0) === 0)
1672
+ .sort((a, b) => railType === ORDER_TYPES.BUY ? b.price - a.price : a.price - b.price)
1673
+ .slice(0, missingSlots);
1674
+ // Merge: prefer orphaned virtuals (they already occupy correct grid positions) then
1675
+ // fall back to SPREAD slots for any remaining quota.
1676
+ const remainingQuota = Math.max(0, missingSlots - orphanedVirtualCandidates.length);
1677
+ const spreadCandidates = [
1678
+ ...orphanedVirtualCandidates,
1679
+ ...typedSpreadCandidates.slice(0, remainingQuota)
1680
+ ];
1681
+ if (spreadCandidates.length > 0) {
1682
+ manager.logger?.log?.(`[SPREAD-CORRECTION] Identified ${spreadCandidates.length}/${missingSlots} slot(s) for activation on ${sideName} (orphaned=${orphanedVirtualCandidates.length}, spread=${spreadCandidates.length - orphanedVirtualCandidates.length})`, 'debug');
1683
+ }
1684
+ if (!edgePartial && spreadCandidates.length === 0) {
1685
+ manager.logger?.log?.(`[SPREAD-CORRECTION] No suitable partials, orphaned virtual slots, or spread slots found. Skipping.`, 'warn');
1686
+ return { ordersToPlace: [], ordersToUpdate: [] };
1687
+ }
1688
+ const orphanedIds = new Set(orphanedVirtualCandidates.map(o => o.id));
1689
+ const sideSlots = allOrders
1690
+ .filter(o => o.type === railType && !orphanedIds.has(o.id))
1691
+ .sort((a, b) => a.price - b.price);
1692
+ const syntheticSideSlots = [
1693
+ ...sideSlots,
1694
+ ...spreadCandidates.map(slot => ({ ...slot, type: railType }))
1695
+ ].sort((a, b) => a.price - b.price);
1696
+ const ctx = await _getSizingContext(manager, sideName);
1697
+ if (!ctx || ctx.budget <= 0 || syntheticSideSlots.length === 0) {
1698
+ return { ordersToPlace: [], ordersToUpdate: [] };
1699
+ }
1700
+ const precisionEpsilon = getPrecisionSlack(ctx.precision, 1);
1701
+ const idealSizes = allocateFundsByWeights(ctx.budget, syntheticSideSlots.length, manager.config.weightDistribution[sideName], manager.config.incrementPercent / 100, railType === ORDER_TYPES.BUY, 0, ctx.precision);
1702
+ const idealById = new Map();
1703
+ syntheticSideSlots.forEach((slot, idx) => {
1704
+ idealById.set(slot.id, Number(idealSizes[idx] || 0));
1705
+ });
1706
+ const availableFund = Math.max(0, Math.min(Number(manager.funds?.available?.[sideName] || 0), Number(sideName === 'buy' ? manager.accountTotals?.buyFree : manager.accountTotals?.sellFree) || 0));
1707
+ const minAbsoluteSize = getMinAbsoluteOrderSize(railType, manager.assets);
1708
+ const prioritizedTargets = [];
1709
+ if (edgePartial && edgePartial.id) {
1710
+ const ideal = Number(idealById.get(edgePartial.id) || 0);
1711
+ const current = Number(edgePartial.size || 0);
1712
+ if (ideal > current + precisionEpsilon) {
1713
+ prioritizedTargets.push({
1714
+ kind: 'partial-topup',
1715
+ order: edgePartial,
1716
+ current,
1717
+ ideal,
1718
+ needed: Math.max(0, ideal - current)
1719
+ });
1720
+ }
1721
+ }
1722
+ for (const slot of spreadCandidates) {
1723
+ const ideal = Number(idealById.get(slot.id) || 0);
1724
+ if (ideal > precisionEpsilon) {
1725
+ prioritizedTargets.push({
1726
+ kind: 'create',
1727
+ order: slot,
1728
+ current: 0,
1729
+ ideal,
1730
+ needed: ideal
1731
+ });
1732
+ }
1733
+ }
1734
+ if (prioritizedTargets.length === 0) {
1735
+ return { ordersToPlace: [], ordersToUpdate: [] };
1736
+ }
1737
+ const totalNeeded = prioritizedTargets.reduce((sum, t) => sum + Math.max(0, Number(t.needed || 0)), 0);
1738
+ let recoveredBudget = 0;
1739
+ const redistributionUpdates = [];
1740
+ if (totalNeeded > availableFund + precisionEpsilon) {
1741
+ let shortfall = totalNeeded - availableFund;
1742
+ const donors = sideSlots
1743
+ .filter(o => hasOnChainId(o) && (o.state === ORDER_STATES.ACTIVE || o.state === ORDER_STATES.PARTIAL))
1744
+ .filter(o => !edgePartial || o.id !== edgePartial.id)
1745
+ .sort((a, b) => railType === ORDER_TYPES.BUY ? a.price - b.price : b.price - a.price);
1746
+ for (const donor of donors) {
1747
+ if (shortfall <= precisionEpsilon)
1750
1748
  break;
1751
- if (target.kind === 'partial-topup') {
1752
- const topUp = Math.min(target.needed, remainingBudget);
1753
- const newSize = target.current + topUp;
1754
- if (newSize > target.current + precisionEpsilon && isOrderHealthy(newSize, railType, manager.assets, target.ideal)) {
1755
- ordersToUpdate.push({ partialOrder: { ...target.order }, newSize });
1756
- remainingBudget -= topUp;
1757
- }
1758
- continue;
1759
- }
1760
- const createSize = Math.min(target.ideal, remainingBudget);
1761
- if (createSize <= precisionEpsilon)
1749
+ const donorCurrent = Number(donor.size || 0);
1750
+ const donorIdeal = Number(idealById.get(donor.id) || 0);
1751
+ const donorFloor = Math.max(minAbsoluteSize, donorIdeal);
1752
+ const donorReducible = Math.max(0, donorCurrent - donorFloor);
1753
+ if (donorReducible <= precisionEpsilon)
1762
1754
  continue;
1763
- if (!isOrderHealthy(createSize, railType, manager.assets, target.ideal))
1755
+ const reduction = Math.min(donorReducible, shortfall);
1756
+ const donorNext = donorCurrent - reduction;
1757
+ if (donorNext <= precisionEpsilon)
1764
1758
  continue;
1765
- ordersToPlace.push({
1766
- ...target.order,
1767
- type: railType,
1768
- size: createSize,
1769
- state: ORDER_STATES.VIRTUAL
1770
- });
1771
- remainingBudget -= createSize;
1772
- }
1773
- const combinedUpdates = [...redistributionUpdates];
1774
- for (const plannedUpdate of ordersToUpdate) {
1775
- const id = plannedUpdate?.partialOrder?.id || plannedUpdate?.id;
1776
- if (!id)
1759
+ if (!isOrderHealthy(donorNext, railType, manager.assets, donorIdeal || donorNext))
1777
1760
  continue;
1778
- const existingIdx = combinedUpdates.findIndex(u => (u?.partialOrder?.id || u?.id) === id);
1779
- if (existingIdx >= 0) {
1780
- combinedUpdates[existingIdx] = plannedUpdate;
1781
- }
1782
- else {
1783
- combinedUpdates.push(plannedUpdate);
1784
- }
1785
- }
1786
- if (spreadCandidates.length < missingSlots) {
1787
- manager.logger?.log?.(`[SPREAD-CORRECTION] Requested ${missingSlots} extra slot(s), found ${spreadCandidates.length} available slot(s) on ${sideName}`, 'warn');
1761
+ redistributionUpdates.push({ partialOrder: { ...donor }, newSize: donorNext });
1762
+ recoveredBudget += reduction;
1763
+ shortfall -= reduction;
1788
1764
  }
1789
- if (ordersToPlace.length < spreadCandidates.length) {
1790
- manager.logger?.log?.(`[SPREAD-CORRECTION] Fund-constrained placement on ${sideName}: planned ${spreadCandidates.length}, placing ${ordersToPlace.length}`, 'info');
1765
+ if (recoveredBudget > precisionEpsilon) {
1766
+ manager.logger?.log?.(`[SPREAD-CORRECTION] Recovered ${Format.formatSizeByOrderType(recoveredBudget, railType, manager.assets)} on ${sideName} via redistribution`, 'info');
1791
1767
  }
1792
- if (combinedUpdates.length > 0 || ordersToPlace.length > 0) {
1793
- manager.logger?.log?.(`[SPREAD-CORRECTION] Prepared updates=${combinedUpdates.length}, creates=${ordersToPlace.length}, remainingBudget=${Format.formatSizeByOrderType(Math.max(0, remainingBudget), railType, manager.assets)}`, 'debug');
1768
+ }
1769
+ let remainingBudget = availableFund + recoveredBudget;
1770
+ for (const target of prioritizedTargets) {
1771
+ if (remainingBudget <= precisionEpsilon)
1772
+ break;
1773
+ if (target.kind === 'partial-topup') {
1774
+ const topUp = Math.min(target.needed, remainingBudget);
1775
+ const newSize = target.current + topUp;
1776
+ if (newSize > target.current + precisionEpsilon && isOrderHealthy(newSize, railType, manager.assets, target.ideal)) {
1777
+ ordersToUpdate.push({ partialOrder: { ...target.order }, newSize });
1778
+ remainingBudget -= topUp;
1779
+ }
1780
+ continue;
1781
+ }
1782
+ const createSize = Math.min(target.ideal, remainingBudget);
1783
+ if (createSize <= precisionEpsilon)
1784
+ continue;
1785
+ if (!isOrderHealthy(createSize, railType, manager.assets, target.ideal))
1786
+ continue;
1787
+ ordersToPlace.push({
1788
+ ...target.order,
1789
+ type: railType,
1790
+ size: createSize,
1791
+ state: ORDER_STATES.VIRTUAL
1792
+ });
1793
+ remainingBudget -= createSize;
1794
+ }
1795
+ const combinedUpdates = [...redistributionUpdates];
1796
+ for (const plannedUpdate of ordersToUpdate) {
1797
+ const id = plannedUpdate?.partialOrder?.id || plannedUpdate?.id;
1798
+ if (!id)
1799
+ continue;
1800
+ const existingIdx = combinedUpdates.findIndex(u => (u?.partialOrder?.id || u?.id) === id);
1801
+ if (existingIdx >= 0) {
1802
+ combinedUpdates[existingIdx] = plannedUpdate;
1803
+ }
1804
+ else {
1805
+ combinedUpdates.push(plannedUpdate);
1794
1806
  }
1795
- return { ordersToPlace, ordersToUpdate: combinedUpdates };
1796
1807
  }
1808
+ if (spreadCandidates.length < missingSlots) {
1809
+ manager.logger?.log?.(`[SPREAD-CORRECTION] Requested ${missingSlots} extra slot(s), found ${spreadCandidates.length} available slot(s) on ${sideName}`, 'warn');
1810
+ }
1811
+ if (ordersToPlace.length < spreadCandidates.length) {
1812
+ manager.logger?.log?.(`[SPREAD-CORRECTION] Fund-constrained placement on ${sideName}: planned ${spreadCandidates.length}, placing ${ordersToPlace.length}`, 'info');
1813
+ }
1814
+ if (combinedUpdates.length > 0 || ordersToPlace.length > 0) {
1815
+ manager.logger?.log?.(`[SPREAD-CORRECTION] Prepared updates=${combinedUpdates.length}, creates=${ordersToPlace.length}, remainingBudget=${Format.formatSizeByOrderType(Math.max(0, remainingBudget), railType, manager.assets)}`, 'debug');
1816
+ }
1817
+ return { ordersToPlace, ordersToUpdate: combinedUpdates };
1797
1818
  }
1798
- module.exports = Grid;
1799
1819
  //# sourceMappingURL=grid.js.map