dexbot 1.1.13 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/market_adapter/market_adapter.js +1 -1
- package/dist/market_adapter/market_adapter.js.map +1 -1
- package/dist/modules/bitshares-native/lru_cache.d.ts +20 -0
- package/dist/modules/bitshares-native/lru_cache.d.ts.map +1 -0
- package/dist/modules/bitshares-native/lru_cache.js +45 -0
- package/dist/modules/bitshares-native/lru_cache.js.map +1 -0
- package/dist/modules/bitshares-native/resolvers.d.ts +2 -18
- package/dist/modules/bitshares-native/resolvers.d.ts.map +1 -1
- package/dist/modules/bitshares-native/resolvers.js +2 -41
- package/dist/modules/bitshares-native/resolvers.js.map +1 -1
- package/dist/modules/bitshares-native/serial/chain_constants.d.ts +1 -0
- package/dist/modules/bitshares-native/serial/chain_constants.d.ts.map +1 -1
- package/dist/modules/bitshares-native/serial/chain_constants.js +3 -0
- package/dist/modules/bitshares-native/serial/chain_constants.js.map +1 -1
- package/dist/modules/bitshares-native/signing_client.d.ts.map +1 -1
- package/dist/modules/bitshares-native/signing_client.js +12 -1
- package/dist/modules/bitshares-native/signing_client.js.map +1 -1
- package/dist/modules/bitshares-native/subscriptions.d.ts.map +1 -1
- package/dist/modules/bitshares-native/subscriptions.js +42 -3
- package/dist/modules/bitshares-native/subscriptions.js.map +1 -1
- package/dist/modules/bitshares-native/transport.d.ts.map +1 -1
- package/dist/modules/bitshares-native/transport.js +77 -27
- package/dist/modules/bitshares-native/transport.js.map +1 -1
- package/dist/modules/bitshares-native/tx/builder.d.ts.map +1 -1
- package/dist/modules/bitshares-native/tx/builder.js +26 -16
- package/dist/modules/bitshares-native/tx/builder.js.map +1 -1
- package/dist/modules/bitshares-native/tx/tx_cache.d.ts +17 -0
- package/dist/modules/bitshares-native/tx/tx_cache.d.ts.map +1 -0
- package/dist/modules/bitshares-native/tx/tx_cache.js +62 -0
- package/dist/modules/bitshares-native/tx/tx_cache.js.map +1 -0
- package/dist/modules/bot_settings.d.ts.map +1 -1
- package/dist/modules/bot_settings.js +2 -1
- package/dist/modules/bot_settings.js.map +1 -1
- package/dist/modules/chain_orders.d.ts +4 -1
- package/dist/modules/chain_orders.d.ts.map +1 -1
- package/dist/modules/chain_orders.js +30 -1
- package/dist/modules/chain_orders.js.map +1 -1
- package/dist/modules/config.d.ts +6 -0
- package/dist/modules/config.d.ts.map +1 -1
- package/dist/modules/config.js +11 -0
- package/dist/modules/config.js.map +1 -1
- package/dist/modules/constants.d.ts +17 -1
- package/dist/modules/constants.d.ts.map +1 -1
- package/dist/modules/constants.js +84 -18
- package/dist/modules/constants.js.map +1 -1
- package/dist/modules/credit_runtime.d.ts.map +1 -1
- package/dist/modules/credit_runtime.js +40 -29
- package/dist/modules/credit_runtime.js.map +1 -1
- package/dist/modules/dexbot_class.d.ts +16 -12
- package/dist/modules/dexbot_class.d.ts.map +1 -1
- package/dist/modules/dexbot_class.js +183 -78
- package/dist/modules/dexbot_class.js.map +1 -1
- package/dist/modules/dexbot_credential_client.d.ts +1 -0
- package/dist/modules/dexbot_credential_client.d.ts.map +1 -1
- package/dist/modules/dexbot_credential_client.js +15 -6
- package/dist/modules/dexbot_credential_client.js.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.d.ts +1 -5
- package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.js +78 -42
- package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
- package/dist/modules/key_store.d.ts.map +1 -1
- package/dist/modules/key_store.js +2 -0
- package/dist/modules/key_store.js.map +1 -1
- package/dist/modules/launcher/child_env.d.ts.map +1 -1
- package/dist/modules/launcher/child_env.js +1 -0
- package/dist/modules/launcher/child_env.js.map +1 -1
- package/dist/modules/launcher/launch_modes.d.ts +2 -0
- package/dist/modules/launcher/launch_modes.d.ts.map +1 -1
- package/dist/modules/launcher/launch_modes.js +7 -6
- package/dist/modules/launcher/launch_modes.js.map +1 -1
- package/dist/modules/node_manager.d.ts +9 -0
- package/dist/modules/node_manager.d.ts.map +1 -1
- package/dist/modules/node_manager.js +35 -17
- package/dist/modules/node_manager.js.map +1 -1
- package/dist/modules/order/accounting.d.ts.map +1 -1
- package/dist/modules/order/accounting.js +56 -13
- package/dist/modules/order/accounting.js.map +1 -1
- package/dist/modules/order/async_lock.d.ts +33 -4
- package/dist/modules/order/async_lock.d.ts.map +1 -1
- package/dist/modules/order/async_lock.js +69 -7
- package/dist/modules/order/async_lock.js.map +1 -1
- package/dist/modules/order/grid.d.ts +435 -450
- package/dist/modules/order/grid.d.ts.map +1 -1
- package/dist/modules/order/grid.js +1621 -1548
- package/dist/modules/order/grid.js.map +1 -1
- package/dist/modules/order/grid_reconcile.d.ts +3 -73
- package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
- package/dist/modules/order/grid_reconcile.js +11 -843
- package/dist/modules/order/grid_reconcile.js.map +1 -1
- package/dist/modules/order/grid_reconcile_internal.d.ts +8 -0
- package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -0
- package/dist/modules/order/grid_reconcile_internal.js +771 -0
- package/dist/modules/order/grid_reconcile_internal.js.map +1 -0
- package/dist/modules/order/logger.d.ts +1 -1
- package/dist/modules/order/logger.d.ts.map +1 -1
- package/dist/modules/order/logger.js +26 -2
- package/dist/modules/order/logger.js.map +1 -1
- package/dist/modules/order/manager.d.ts +40 -3
- package/dist/modules/order/manager.d.ts.map +1 -1
- package/dist/modules/order/manager.js +97 -15
- package/dist/modules/order/manager.js.map +1 -1
- package/dist/modules/order/runner.js +2 -2
- package/dist/modules/order/runner.js.map +1 -1
- package/dist/modules/order/strategy.js +3 -3
- package/dist/modules/order/strategy.js.map +1 -1
- package/dist/modules/order/sync_engine.d.ts +1 -1
- package/dist/modules/order/sync_engine.d.ts.map +1 -1
- package/dist/modules/order/sync_engine.js +121 -29
- package/dist/modules/order/sync_engine.js.map +1 -1
- package/dist/modules/order/utils/order.d.ts +17 -0
- package/dist/modules/order/utils/order.d.ts.map +1 -1
- package/dist/modules/order/utils/order.js +56 -19
- package/dist/modules/order/utils/order.js.map +1 -1
- package/dist/modules/order/utils/system.js +4 -4
- package/dist/modules/order/utils/system.js.map +1 -1
- package/dist/modules/order/utils/validate.d.ts.map +1 -1
- package/dist/modules/order/utils/validate.js +6 -8
- package/dist/modules/order/utils/validate.js.map +1 -1
- package/dist/scripts/print_grid.js +3 -3
- package/dist/scripts/print_grid.js.map +1 -1
- package/dist/scripts/test-credit-renewal.js +1 -1
- package/dist/scripts/test-credit-renewal.js.map +1 -1
- package/dist/unlock.d.ts.map +1 -1
- package/dist/unlock.js +26 -12
- package/dist/unlock.js.map +1 -1
- package/package.json +14 -4
|
@@ -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
|
|
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
|
|
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,1636 +136,1684 @@ 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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 };
|
|
189
|
+
}
|
|
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;
|
|
166
241
|
const isBtsSide = (isBuy && manager.config.assetB === 'BTS') || (!isBuy && manager.config.assetA === 'BTS');
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const targetSell = Math.max(0, manager.config.activeOrders?.sell ?? 1);
|
|
171
|
-
const totalTarget = targetBuy + targetSell;
|
|
172
|
-
const btsFees = calculateOrderCreationFees(manager.config.assetA, manager.config.assetB, totalTarget, btsReservationMultiplier);
|
|
173
|
-
budget = Math.max(0, budget - btsFees);
|
|
174
|
-
}
|
|
175
|
-
// Non-BTS pair: reserve proportional share for BTS fee budget
|
|
176
|
-
if (!isBtsSide && budget > 0) {
|
|
177
|
-
const targetBuy = Math.max(0, manager.config.activeOrders?.buy ?? 1);
|
|
178
|
-
const targetSell = Math.max(0, manager.config.activeOrders?.sell ?? 1);
|
|
179
|
-
const totalTarget = targetBuy + targetSell;
|
|
180
|
-
const formulaBudget = calculateOrderCreationFees(manager.config.assetA, manager.config.assetB, totalTarget, btsReservationMultiplier);
|
|
181
|
-
const configMin = manager.config.min_BTS_value;
|
|
182
|
-
const effectiveMin = (configMin > 0) ? configMin : formulaBudget;
|
|
183
|
-
const btsFree = Format.toFiniteNumber(manager.funds?.btsBalance?.free, 0);
|
|
184
|
-
const btsDeficit = Math.max(0, effectiveMin - btsFree);
|
|
185
|
-
if (btsDeficit > 0) {
|
|
186
|
-
const sideFree = Format.toFiniteNumber(isBuy ? manager.accountTotals?.buyFree : manager.accountTotals?.sellFree, 0);
|
|
187
|
-
const totalFree = Format.toFiniteNumber(manager.accountTotals?.buyFree, 0)
|
|
188
|
-
+ Format.toFiniteNumber(manager.accountTotals?.sellFree, 0);
|
|
189
|
-
const share = totalFree > 0 ? sideFree / totalFree : 0.5;
|
|
190
|
-
budget = Math.max(0, budget - btsDeficit * share);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return {
|
|
194
|
-
budget,
|
|
195
|
-
precision: getPrecisionByOrderType(manager.assets, type),
|
|
196
|
-
config: manager.config
|
|
197
|
-
};
|
|
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));
|
|
198
245
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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();
|
|
370
422
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
423
|
+
}
|
|
424
|
+
if (manager._ordersByType) {
|
|
425
|
+
for (const key of Object.keys(manager._ordersByType)) {
|
|
426
|
+
manager._ordersByType[key] = new Set();
|
|
375
427
|
}
|
|
376
428
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
return
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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');
|
|
404
467
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
manager.logger?.log?.(`
|
|
413
|
-
|
|
414
|
-
}
|
|
415
|
-
await manager._applyOrderUpdate(currentOrder, 'grid-load', { skipAccounting: true });
|
|
468
|
+
else {
|
|
469
|
+
manager.logger?.log?.(`[GRID-BLOAT] Grid size ${d.gridSize} exceeds expected maximum ${d.maxAllowed} ` +
|
|
470
|
+
`(placed=${d.placedCount} buy=${d.numBuyActive} sell=${d.numSellActive} ` +
|
|
471
|
+
`gapSlots=${d.gapSlots}). Triggering protective structural resync.`, 'warn');
|
|
472
|
+
manager._gridBloatDetectedAt = Date.now();
|
|
473
|
+
if (typeof manager.requestStructuralGridResync === 'function') {
|
|
474
|
+
manager.requestStructuralGridResync('grid-bloat-detected', { reason: `Grid size ${d.gridSize} exceeds maximum ${d.maxAllowed}` }).catch((err) => {
|
|
475
|
+
manager.logger?.log?.(`[GRID-BLOAT] Structural resync request failed: ${err.message}`, 'error');
|
|
476
|
+
});
|
|
416
477
|
}
|
|
417
|
-
const spreadCount = grid.filter(o => o.type === ORDER_TYPES.SPREAD).length;
|
|
418
|
-
manager.targetSpreadCount = spreadCount;
|
|
419
|
-
manager.currentSpreadCount = spreadCount;
|
|
420
|
-
}
|
|
421
|
-
finally {
|
|
422
|
-
await manager.resumeFundRecalc();
|
|
423
|
-
manager.resumeRecalcLogging();
|
|
424
478
|
}
|
|
425
|
-
manager.logger?.log?.(`Loaded ${manager.orders.size} orders from persisted grid.`, 'info');
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
/**
|
|
429
|
-
* Initialize the order grid with blockchain-aware sizing.
|
|
430
|
-
* @param {import('./types').OrderManager} manager - The manager instance.
|
|
431
|
-
* @returns {Promise<void>}
|
|
432
|
-
* @throws {Error} If initialization fails or account totals are missing.
|
|
433
|
-
*/
|
|
434
|
-
static async initializeGrid(manager) {
|
|
435
|
-
if (!manager)
|
|
436
|
-
throw new Error('initializeGrid requires a manager instance');
|
|
437
|
-
await manager._initializeAssets();
|
|
438
|
-
// FIX: Add explicit state validation to prevent cryptic errors later
|
|
439
|
-
if (!manager.assets || !manager.assets.assetA || !manager.assets.assetB) {
|
|
440
|
-
throw new Error('Asset initialization did not complete properly - assetA or assetB undefined');
|
|
441
479
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
const derived = await derivePrice(BitShares, manager.config.assetA, manager.config.assetB, manager.config.priceMode || 'auto');
|
|
452
|
-
if (derived) {
|
|
453
|
-
manager.logger?.log?.(`[DIAGNOSTIC] initializeGrid: Derived new startPrice=${derived.toFixed(8)} (mode=${manager.config.priceMode || 'auto'})`, 'info');
|
|
454
|
-
manager.config.startPrice = Number(derived);
|
|
455
|
-
}
|
|
456
|
-
else {
|
|
457
|
-
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 };
|
|
458
489
|
}
|
|
490
|
+
await manager._applyOrderUpdate(currentOrder, 'grid-load', { skipAccounting: true });
|
|
459
491
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
}
|
|
492
|
+
const spreadCount = grid.filter(o => o.type === ORDER_TYPES.SPREAD).length;
|
|
493
|
+
manager.targetSpreadCount = spreadCount;
|
|
494
|
+
manager.currentSpreadCount = spreadCount;
|
|
464
495
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const mp = Number(manager.config.startPrice);
|
|
469
|
-
// Derive gridPrice — separate reference for x-factor bounds (may differ from startPrice).
|
|
470
|
-
// Supported modes:
|
|
471
|
-
// - numeric: fixed value
|
|
472
|
-
// - "pool" / "book": live blockchain price for the pair
|
|
473
|
-
// - "ama"/"ama1".."ama4": center from profiles/orders/<botKey>.dynamicgrid.json
|
|
474
|
-
// - null/anything else: fallback to startPrice (backward-compatible)
|
|
475
|
-
let gp = mp;
|
|
476
|
-
let gpSource = 'startPrice';
|
|
477
|
-
let amaSnapshot = null;
|
|
478
|
-
const whitelistFlags = getWhitelistFlags(manager.config.botKey);
|
|
479
|
-
const isGridRangeScalingWhitelisted = whitelistFlags.asymmetricBounds === true;
|
|
480
|
-
let gridPriceOffsetPct = 0;
|
|
481
|
-
const gpRaw = manager.config.gridPrice;
|
|
482
|
-
const gpMode = (typeof gpRaw === 'string') ? gpRaw.trim().toLowerCase() : null;
|
|
483
|
-
if (typeof gpRaw === 'number' && Number.isFinite(gpRaw) && gpRaw > 0) {
|
|
484
|
-
gp = gpRaw;
|
|
485
|
-
gpSource = 'numeric';
|
|
486
|
-
manager.logger?.log?.(`[DIAGNOSTIC] initializeGrid: gridPrice=numeric ${gp.toFixed(8)}`, 'info');
|
|
496
|
+
finally {
|
|
497
|
+
await manager.resumeFundRecalc();
|
|
498
|
+
manager.resumeRecalcLogging();
|
|
487
499
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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);
|
|
500
530
|
}
|
|
501
|
-
|
|
502
|
-
|
|
531
|
+
else {
|
|
532
|
+
throw new Error(`Price derivation returned no result for ${manager.config.assetA}/${manager.config.assetB}`);
|
|
503
533
|
}
|
|
504
534
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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');
|
|
517
571
|
}
|
|
518
572
|
else {
|
|
519
|
-
manager.logger?.log?.(`initializeGrid:
|
|
573
|
+
manager.logger?.log?.(`initializeGrid: ${gpMode} gridPrice unavailable, falling back to startPrice`, 'warn');
|
|
520
574
|
}
|
|
521
575
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
rangeScalingFactor =
|
|
575
|
-
manager.logger?.log?.(`[BOUND-ASYMMETRY] trend=${
|
|
576
|
-
+ `
|
|
577
|
-
+ `(
|
|
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)} `
|
|
578
634
|
+ `max ${maxP.toFixed(8)}→${resolvedMaxP.toFixed(8)}`, 'info');
|
|
579
635
|
}
|
|
580
636
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
offsetAdjustedStartPrice = adjustedMarketPrice;
|
|
589
|
-
}
|
|
590
|
-
if (!(gridStartPrice >= resolvedMinP && gridStartPrice <= resolvedMaxP)) {
|
|
591
|
-
if (Number.isFinite(gp) && gp > 0 && gp >= resolvedMinP && gp <= resolvedMaxP) {
|
|
592
|
-
gridStartPrice = gp;
|
|
593
|
-
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));
|
|
594
644
|
}
|
|
595
645
|
else {
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
gridStartPrice = clamped;
|
|
646
|
+
resolvedMinP = gp / ((gp / minP) * (1 - asymmetry));
|
|
647
|
+
resolvedMaxP = gp * ((maxP / gp) * (1 + asymmetry));
|
|
599
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');
|
|
600
654
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
startPrice
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
await manager.waitForAccountTotals(TIMING.ACCOUNT_TOTALS_TIMEOUT_MS);
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
catch (e) {
|
|
619
|
-
manager.logger?.log?.(`Failed to load account totals: ${e.message}`, 'warn');
|
|
620
|
-
// FIX: Add error handling - cannot proceed with grid initialization without account totals
|
|
621
|
-
// Continuing would create grid with 0 fund allocation, rendering it non-functional
|
|
622
|
-
throw new Error(`Cannot initialize grid without account totals: ${e.message}`);
|
|
623
|
-
}
|
|
624
|
-
const { orders, boundaryIdx, initialSpreadCount } = Grid.createOrderGrid({
|
|
625
|
-
...manager.config,
|
|
626
|
-
startPrice: gridStartPrice,
|
|
627
|
-
minPrice: resolvedMinP,
|
|
628
|
-
maxPrice: resolvedMaxP,
|
|
629
|
-
});
|
|
630
|
-
// RC-8: Update boundary with notification to dependent systems
|
|
631
|
-
// Persist master boundary for StrategyEngine
|
|
632
|
-
if (manager.boundaryIdx !== boundaryIdx) {
|
|
633
|
-
manager.boundaryIdx = boundaryIdx;
|
|
634
|
-
// RC-8: Notify StrategyEngine of boundary change (if method exists)
|
|
635
|
-
if (typeof manager.notifyBoundaryUpdate === 'function') {
|
|
636
|
-
try {
|
|
637
|
-
manager.notifyBoundaryUpdate(boundaryIdx);
|
|
638
|
-
}
|
|
639
|
-
catch (err) {
|
|
640
|
-
manager.logger?.log?.(`Error notifying boundary update: ${err.message}`, 'warn');
|
|
641
|
-
}
|
|
642
|
-
}
|
|
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');
|
|
643
669
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
// Resolve funds once upfront so both contexts share the same snapshot,
|
|
649
|
-
// avoiding a redundant recalculateFunds inside the second _getSizingContext call.
|
|
650
|
-
await manager.recalculateFunds();
|
|
651
|
-
const sellCtx = await Grid._getSizingContext(manager, 'sell', { skipRecalc: true });
|
|
652
|
-
const buyCtx = await Grid._getSizingContext(manager, 'buy', { skipRecalc: true });
|
|
653
|
-
if (!sellCtx || !buyCtx)
|
|
654
|
-
throw new Error('Failed to retrieve sizing context for grid initialization');
|
|
655
|
-
let sizedOrders = calculateOrderSizes(orders, manager.config, sellCtx.budget, buyCtx.budget, minSellSize, minBuySize, precA, precB);
|
|
656
|
-
// Verification of sizes
|
|
657
|
-
const sells = filterOrdersByType(sizedOrders, ORDER_TYPES.SELL).map(o => Number(o.size || 0));
|
|
658
|
-
const buys = filterOrdersByType(sizedOrders, ORDER_TYPES.BUY).map(o => Number(o.size || 0));
|
|
659
|
-
if (checkSizesBeforeMinimum(sells, minSellSize, precA) || checkSizesBeforeMinimum(buys, minBuySize, precB)) {
|
|
660
|
-
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;
|
|
661
674
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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);
|
|
667
691
|
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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') {
|
|
674
711
|
try {
|
|
675
|
-
|
|
676
|
-
for (const order of sizedOrders) {
|
|
677
|
-
await manager._applyOrderUpdate(order, 'grid-init', { skipAccounting: true });
|
|
678
|
-
}
|
|
712
|
+
manager.notifyBoundaryUpdate(boundaryIdx);
|
|
679
713
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
manager.resumeRecalcLogging();
|
|
714
|
+
catch (err) {
|
|
715
|
+
manager.logger?.log?.(`Error notifying boundary update: ${err.message}`, 'warn');
|
|
683
716
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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();
|
|
707
749
|
try {
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
await manager.fetchAccountTotals();
|
|
712
|
-
const chainOpenOrders = await readOpenOrdersFn();
|
|
713
|
-
if (!Array.isArray(chainOpenOrders))
|
|
714
|
-
return;
|
|
715
|
-
// CRITICAL: Filter out PARTIAL orders before synchronizing - they're from old grid
|
|
716
|
-
// and shouldn't be part of the fresh regenerated grid structure
|
|
717
|
-
const activeOrders = chainOpenOrders.filter(o => o.state !== ORDER_STATES.PARTIAL);
|
|
718
|
-
await manager.syncFromOpenOrders(activeOrders, { skipAccounting: true, fillLockAlreadyHeld: true });
|
|
719
|
-
manager.resetFunds();
|
|
720
|
-
await manager.persistGrid();
|
|
721
|
-
await Grid.initializeGrid(manager);
|
|
722
|
-
const { reconcileGridOrders } = require('./grid_reconcile');
|
|
723
|
-
// FIX: Add error context for debugging grid recalculation issues
|
|
724
|
-
try {
|
|
725
|
-
await reconcileGridOrders({ manager, config: manager.config, account, privateKey, chainOrders, chainOpenOrders, fillLockAlreadyHeld: true });
|
|
750
|
+
// RC-2: Use _applyOrderUpdate (PRIVATE/UNLOCKED)
|
|
751
|
+
for (const order of sizedOrders) {
|
|
752
|
+
await manager._applyOrderUpdate(order, 'grid-init', { skipAccounting: true });
|
|
726
753
|
}
|
|
727
|
-
catch (err) {
|
|
728
|
-
manager.logger?.log?.(`Error during startup order reconciliation: ${err.message}`, 'error');
|
|
729
|
-
throw new Error(`Grid recalculation failed during order reconciliation: ${err.message}`);
|
|
730
|
-
}
|
|
731
|
-
// FIX: Use consistent optional chaining pattern for logger calls
|
|
732
|
-
manager.logger?.log?.('Full resync complete.', 'info');
|
|
733
754
|
}
|
|
734
755
|
finally {
|
|
735
|
-
manager.
|
|
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 });
|
|
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}`);
|
|
736
805
|
}
|
|
806
|
+
// FIX: Use consistent optional chaining pattern for logger calls
|
|
807
|
+
manager.logger?.log?.('Full resync complete.', 'info');
|
|
737
808
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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;
|
|
773
847
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
sideFreeAvailable = Math.max(0, sideFreeAvailable - affordableDelta);
|
|
883
|
-
}
|
|
884
|
-
else if (delta < 0) {
|
|
885
|
-
// SHRINKAGE: Release freed capital back for other slots
|
|
886
|
-
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;
|
|
887
956
|
}
|
|
957
|
+
sideFreeAvailable = Math.max(0, sideFreeAvailable - affordableDelta);
|
|
888
958
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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: {
|
|
902
983
|
id: slot.id,
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
newSize
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
id: slot.id,
|
|
909
|
-
type: slot.type,
|
|
910
|
-
price: slot.price,
|
|
911
|
-
size: newSize
|
|
912
|
-
}
|
|
913
|
-
});
|
|
914
|
-
}
|
|
984
|
+
type: slot.type,
|
|
985
|
+
price: slot.price,
|
|
986
|
+
size: newSize
|
|
987
|
+
}
|
|
988
|
+
});
|
|
915
989
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
}
|
|
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');
|
|
922
996
|
}
|
|
923
997
|
}
|
|
924
998
|
}
|
|
925
|
-
if (!collectActions) {
|
|
926
|
-
await manager.recalculateFunds();
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
finally {
|
|
930
|
-
if (!collectActions)
|
|
931
|
-
manager.resumeRecalcLogging();
|
|
932
|
-
}
|
|
933
|
-
if (collectActions) {
|
|
934
|
-
return { actions, changed };
|
|
935
|
-
}
|
|
936
|
-
return undefined;
|
|
937
|
-
}
|
|
938
|
-
/**
|
|
939
|
-
* High-level entry for resizing grid from snapshot using COW pattern.
|
|
940
|
-
* Creates working grid, calculates new sizes, generates UPDATE actions.
|
|
941
|
-
* Master grid is only updated after successful blockchain confirmation.
|
|
942
|
-
*
|
|
943
|
-
* @param {import('./types').OrderManager} manager - Manager instance
|
|
944
|
-
* @param {string} orderType - 'buy', 'sell', or 'both' - which sides to update
|
|
945
|
-
* @param {boolean} [fromBlockchainTimer=false] - If true, skip refetch of account totals (already current)
|
|
946
|
-
* @param {number|null} [overrideBoundaryIdx=null] - Optional override for boundary index
|
|
947
|
-
* @returns {Promise<{actions: Array, workingGrid: import('./working_grid'), workingIndexes: Object, workingBoundary: number, hasWorkingChanges: boolean, aborted: boolean}|null>}
|
|
948
|
-
*/
|
|
949
|
-
static async updateGridFromBlockchainSnapshot(manager, orderType = 'both', fromBlockchainTimer = false, overrideBoundaryIdx = null) {
|
|
950
|
-
if (!fromBlockchainTimer && manager.config?.accountId) {
|
|
951
|
-
await manager.fetchAccountTotals(manager.config.accountId);
|
|
952
|
-
}
|
|
953
|
-
const { WorkingGrid } = require('./working_grid');
|
|
954
|
-
const workingGrid = new WorkingGrid(manager.orders, { baseVersion: manager._gridVersion });
|
|
955
|
-
const allActions = [];
|
|
956
|
-
let hasWorkingChanges = false;
|
|
957
|
-
// Calculate size updates for each side (via existing sizing function in COW mode)
|
|
958
|
-
if (orderType === ORDER_TYPES.BUY || orderType === 'both') {
|
|
959
|
-
const buyResult = await Grid._recalculateGridOrderSizesFromBlockchain(manager, ORDER_TYPES.BUY, { workingGrid });
|
|
960
|
-
allActions.push(...buyResult.actions);
|
|
961
|
-
hasWorkingChanges = hasWorkingChanges || buyResult.changed;
|
|
962
|
-
}
|
|
963
|
-
if (orderType === ORDER_TYPES.SELL || orderType === 'both') {
|
|
964
|
-
const sellResult = await Grid._recalculateGridOrderSizesFromBlockchain(manager, ORDER_TYPES.SELL, { workingGrid });
|
|
965
|
-
allActions.push(...sellResult.actions);
|
|
966
|
-
hasWorkingChanges = hasWorkingChanges || sellResult.changed;
|
|
967
999
|
}
|
|
968
|
-
|
|
969
|
-
// This ensures the COW commit delivers consistent types + boundaryIdx in one
|
|
970
|
-
// atomic operation — manager.boundaryIdx must not be touched before the commit.
|
|
971
|
-
const newBoundary = (overrideBoundaryIdx !== null) ? overrideBoundaryIdx : manager.boundaryIdx;
|
|
972
|
-
if (overrideBoundaryIdx !== null && overrideBoundaryIdx !== manager.boundaryIdx) {
|
|
973
|
-
const gapSlots = Grid.calculateGapSlots(manager.config.incrementPercent, manager.config.targetSpreadPercent, manager.config.gridLimits);
|
|
974
|
-
const allSlots = Array.from(workingGrid.values())
|
|
975
|
-
.filter(s => s.price != null)
|
|
976
|
-
.sort((a, b) => a.price - b.price);
|
|
977
|
-
const updatedSlots = assignGridRoles(allSlots, newBoundary, gapSlots, ORDER_TYPES, ORDER_STATES);
|
|
978
|
-
for (const slot of updatedSlots) {
|
|
979
|
-
workingGrid.set(slot.id, slot);
|
|
980
|
-
}
|
|
981
|
-
hasWorkingChanges = true;
|
|
982
|
-
}
|
|
983
|
-
// Return COW result only if there are changes
|
|
984
|
-
if (allActions.length === 0 && !hasWorkingChanges) {
|
|
985
|
-
return null;
|
|
986
|
-
}
|
|
987
|
-
return {
|
|
988
|
-
actions: allActions,
|
|
989
|
-
workingGrid,
|
|
990
|
-
workingIndexes: workingGrid.getIndexes(),
|
|
991
|
-
workingBoundary: newBoundary,
|
|
992
|
-
hasWorkingChanges,
|
|
993
|
-
aborted: false
|
|
994
|
-
};
|
|
995
|
-
}
|
|
996
|
-
/**
|
|
997
|
-
* Compare ideal grid vs persisted grid to detect divergence.
|
|
998
|
-
* INDEPENDENT SIDE CHECKING: Buy and sell sides are evaluated independently.
|
|
999
|
-
* Each side's RMS divergence is compared against its own threshold.
|
|
1000
|
-
* Only sides exceeding the threshold are marked for update.
|
|
1001
|
-
*
|
|
1002
|
-
* PURPOSE: Detect if the calculated in-memory grid has diverged significantly from the
|
|
1003
|
-
* persisted grid state. High divergence indicates that order fills/rotations have caused
|
|
1004
|
-
* size distributions to deviate, potentially requiring grid size recalculation.
|
|
1005
|
-
*
|
|
1006
|
-
* METRIC: RMS (Root Mean Square) percentage of relative size differences
|
|
1007
|
-
* Formula: RMS% = sqrt(mean((calculated - persisted) / persisted)²) × 100
|
|
1008
|
-
* This measures the typical relative error across all orders on each side.
|
|
1009
|
-
*
|
|
1010
|
-
* SIDE INDEPENDENCE:
|
|
1011
|
-
* - Buy side RMS is checked against GRID_COMPARISON.RMS_PERCENTAGE independently
|
|
1012
|
-
* - Sell side RMS is checked against GRID_COMPARISON.RMS_PERCENTAGE independently
|
|
1013
|
-
* - One side can diverge while the other remains stable (no update for stable side)
|
|
1014
|
-
*
|
|
1015
|
-
* RC-4: Atomic snapshot taking prevents stale data from concurrent fill operations
|
|
1016
|
-
* - Grids are snapshotted atomically before comparison
|
|
1017
|
-
* - Prevents mixing old and new grid state
|
|
1018
|
-
* - Ensures consistent RMS metrics across both sides
|
|
1019
|
-
*
|
|
1020
|
-
* @param {Array<import('./types').GridOrderSlot>} calculatedGrid - Ideal calculated grid
|
|
1021
|
-
* @param {Array<import('./types').GridOrderSlot>} persistedGrid - Persisted grid state
|
|
1022
|
-
* @param {import('./types').OrderManager|null} [manager=null] - Manager instance (for grid lock access)
|
|
1023
|
-
* @returns {Promise<import('./types').GridComparisonResult>}
|
|
1024
|
-
*/
|
|
1025
|
-
static async compareGrids(calculatedGrid, persistedGrid, manager = null) {
|
|
1026
|
-
if (!Array.isArray(calculatedGrid) || !Array.isArray(persistedGrid)) {
|
|
1027
|
-
return { buy: { metric: 0, updated: false }, sell: { metric: 0, updated: false } };
|
|
1028
|
-
}
|
|
1029
|
-
// RC-4: Take snapshots atomically to prevent concurrent modification races
|
|
1030
|
-
// If manager has grid lock, use it to get consistent snapshots
|
|
1031
|
-
let calculatedSnap = calculatedGrid;
|
|
1032
|
-
let persistedSnap = persistedGrid;
|
|
1033
|
-
if (manager?._gridLock?.acquire) {
|
|
1034
|
-
const snapshotResult = await manager._gridLock.acquire(() => {
|
|
1035
|
-
return {
|
|
1036
|
-
calculated: Array.from(calculatedGrid),
|
|
1037
|
-
persisted: Array.from(persistedGrid)
|
|
1038
|
-
};
|
|
1039
|
-
});
|
|
1040
|
-
calculatedSnap = snapshotResult.calculated;
|
|
1041
|
-
persistedSnap = snapshotResult.persisted;
|
|
1042
|
-
}
|
|
1043
|
-
// Filter to ACTIVE orders only (excludes PARTIAL/VIRTUAL/SPREAD)
|
|
1044
|
-
// Partial orders are excluded from divergence calculation as they are expected to deviate;
|
|
1045
|
-
// they are instead handled by the available-funds ratio check or follow-up correction.
|
|
1046
|
-
// Must be sorted ASC for calculateRotationOrderSizes to match geometric weight distribution
|
|
1047
|
-
const filterForRms = (orders, type) => {
|
|
1048
|
-
const result = Array.isArray(orders) ? orders.filter(o => o && o.type === type && o.state === ORDER_STATES.ACTIVE) : [];
|
|
1049
|
-
return result
|
|
1050
|
-
.sort((a, b) => (a.price ?? 0) - (b.price ?? 0));
|
|
1051
|
-
};
|
|
1052
|
-
const calculatedBuys = filterForRms(calculatedSnap, ORDER_TYPES.BUY);
|
|
1053
|
-
const calculatedSells = filterForRms(calculatedSnap, ORDER_TYPES.SELL);
|
|
1054
|
-
const persistedBuys = filterForRms(persistedSnap, ORDER_TYPES.BUY);
|
|
1055
|
-
const persistedSells = filterForRms(persistedSnap, ORDER_TYPES.SELL);
|
|
1056
|
-
// Calculate ideal sizes for each order based on current available budget.
|
|
1057
|
-
// The sizing context (which includes recalculateFunds) is resolved once per side up front
|
|
1058
|
-
// so both buy and sell metrics share a single fund snapshot. This avoids the previous
|
|
1059
|
-
// double-recalculateFunds between the two sides and keeps the metric consistent even if
|
|
1060
|
-
// a fill event arrives between per-side calculations.
|
|
1061
|
-
const computeSideIdeals = (activeOrders, type, ctx) => {
|
|
1062
|
-
if (!manager || !ctx || ctx.budget <= 0 || activeOrders.length === 0)
|
|
1063
|
-
return activeOrders;
|
|
1064
|
-
// Identify ALL slots currently assigned to this side.
|
|
1065
|
-
// Ideal sizing must use the full slot count to determine geometric share per slot.
|
|
1066
|
-
const sideSlots = Array.from(manager.orders.values())
|
|
1067
|
-
.filter(o => o.type === type)
|
|
1068
|
-
.sort((a, b) => (a.price ?? 0) - (b.price ?? 0));
|
|
1069
|
-
if (sideSlots.length === 0)
|
|
1070
|
-
return activeOrders;
|
|
1071
|
-
// Calculate geometric ideals for the ENTIRE side (all slots)
|
|
1072
|
-
try {
|
|
1073
|
-
const allIdealSizes = calculateRotationOrderSizes(ctx.budget, 0, sideSlots.length, type, manager.config, 0, ctx.precision);
|
|
1074
|
-
// Map Ideal sizes to IDs for quick lookup
|
|
1075
|
-
const idealMap = new Map();
|
|
1076
|
-
sideSlots.forEach((slot, i) => idealMap.set(slot.id, allIdealSizes[i]));
|
|
1077
|
-
// Return the activeOrders subset with their true geometric ideal sizes
|
|
1078
|
-
return activeOrders.map(o => ({ ...o, size: idealMap.get(o.id) ?? 0 }));
|
|
1079
|
-
}
|
|
1080
|
-
catch (e) {
|
|
1081
|
-
return activeOrders;
|
|
1082
|
-
}
|
|
1083
|
-
};
|
|
1084
|
-
const needsBuy = calculatedBuys.length > 0 && manager?.assets;
|
|
1085
|
-
const needsSell = calculatedSells.length > 0 && manager?.assets;
|
|
1086
|
-
if (needsBuy || needsSell) {
|
|
1000
|
+
if (!collectActions) {
|
|
1087
1001
|
await manager.recalculateFunds();
|
|
1088
1002
|
}
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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);
|
|
1119
1055
|
}
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1056
|
+
hasWorkingChanges = true;
|
|
1057
|
+
}
|
|
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 } };
|
|
1125
1103
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
* @param {Array<import('./types').GridOrderSlot>} persistedGrid - Current/persisted grid
|
|
1133
|
-
* @returns {Promise<import('./types').DivergenceResult>}
|
|
1134
|
-
*/
|
|
1135
|
-
static async monitorDivergence(manager, calculatedGrid, persistedGrid) {
|
|
1136
|
-
// 1. Check ratio-based divergence (available funds vs allocated)
|
|
1137
|
-
const ratioResult = Grid.checkAndUpdateGridIfNeeded(manager);
|
|
1138
|
-
if (ratioResult.buyUpdated || ratioResult.sellUpdated) {
|
|
1139
|
-
const { getOrderTypeFromUpdatedFlags } = require('./utils/order');
|
|
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(() => {
|
|
1140
1110
|
return {
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
sell: { updated: ratioResult.sellUpdated, ratio: ratioResult.sellUpdated, rms: false, metric: 0 },
|
|
1144
|
-
orderType: getOrderTypeFromUpdatedFlags(ratioResult.buyUpdated, ratioResult.sellUpdated)
|
|
1111
|
+
calculated: Array.from(calculatedGrid),
|
|
1112
|
+
persisted: Array.from(persistedGrid)
|
|
1145
1113
|
};
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
const buyUpdated = ratioResult.buyUpdated || rmsResult.buy.updated;
|
|
1150
|
-
const sellUpdated = ratioResult.sellUpdated || rmsResult.sell.updated;
|
|
1151
|
-
const { getOrderTypeFromUpdatedFlags } = require('./utils/order');
|
|
1152
|
-
return {
|
|
1153
|
-
needsUpdate: buyUpdated || sellUpdated,
|
|
1154
|
-
buy: { updated: buyUpdated, ratio: ratioResult.buyUpdated, rms: rmsResult.buy.updated, metric: rmsResult.buy.metric },
|
|
1155
|
-
sell: { updated: sellUpdated, ratio: ratioResult.sellUpdated, rms: rmsResult.sell.updated, metric: rmsResult.sell.metric },
|
|
1156
|
-
orderType: getOrderTypeFromUpdatedFlags(buyUpdated, sellUpdated)
|
|
1157
|
-
};
|
|
1114
|
+
});
|
|
1115
|
+
calculatedSnap = snapshotResult.calculated;
|
|
1116
|
+
persistedSnap = snapshotResult.persisted;
|
|
1158
1117
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
const
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
* CRITICAL: Uses AsyncLock to prevent race conditions with fill processing.
|
|
1189
|
-
* Without the lock, a TOCTOU (Time-Of-Check-To-Use) vulnerability exists where:
|
|
1190
|
-
* - Fund snapshot is taken (check phase)
|
|
1191
|
-
* - Fill processor modifies funds in another thread
|
|
1192
|
-
* - Order is placed based on stale funds (use phase)
|
|
1193
|
-
* Result: Orders placed beyond available liquidity, fund accounting errors
|
|
1194
|
-
*
|
|
1195
|
-
* DESIGN DECISION: Lock is released before blockchain operations for performance
|
|
1196
|
-
* - Lock held: Fund verification and correction decision (synchronized)
|
|
1197
|
-
* - Lock released: Blockchain submission (async, potentially slow)
|
|
1198
|
-
* - RACE CONDITION WINDOW: Between lock release and blockchain submission
|
|
1199
|
-
* - MITIGATION: Pre-flight fund verification before submission; comprehensive error handling
|
|
1200
|
-
*
|
|
1201
|
-
* See RACE_CONDITION_ANALYSIS.md for detailed vulnerability documentation.
|
|
1202
|
-
*
|
|
1203
|
-
* @param {import('./types').OrderManager} manager - Manager instance
|
|
1204
|
-
* @param {Object} BitShares - BitShares API client
|
|
1205
|
-
* @param {Function|null} [updateOrdersOnChainBatch=null] - Optional batch update function
|
|
1206
|
-
* @returns {Promise<import('./types').SpreadCheckResult>}
|
|
1207
|
-
*/
|
|
1208
|
-
static async checkSpreadCondition(manager, BitShares, updateOrdersOnChainBatch = null) {
|
|
1209
|
-
// CRITICAL: Acquire corrections lock to serialize spread correction operations
|
|
1210
|
-
// This prevents concurrent fill processing from modifying funds while we're making decisions
|
|
1211
|
-
let correction = null;
|
|
1212
|
-
let shouldApplyCorrection = false;
|
|
1213
|
-
// Derive current market price from the bot's own grid (no blockchain call needed).
|
|
1214
|
-
// Grid prices are in B/A format (e.g. BTS/XRP) so no inversion is required.
|
|
1215
|
-
// Mid between best bid and best ask is the most current price the bot has.
|
|
1216
|
-
// Falls back to config.startPrice when either side is empty (e.g. at startup).
|
|
1217
|
-
const { onChainBuys, onChainSells } = Grid._getOnChainOrders(manager);
|
|
1218
|
-
const { bestBuy, bestSell } = getGridBestPrices(onChainBuys, onChainSells);
|
|
1219
|
-
const lastPrice = (bestBuy !== null && bestSell !== null)
|
|
1220
|
-
? (bestBuy + bestSell) / 2
|
|
1221
|
-
: Number(manager.config.startPrice) || 0;
|
|
1222
|
-
// FIX: Use optional chaining for lock - if no lock exists, execute synchronously
|
|
1223
|
-
let fundSnapshot = null;
|
|
1224
|
-
const executeSpreadCheck = async () => {
|
|
1225
|
-
const currentSpread = Grid.calculateCurrentSpread(manager);
|
|
1226
|
-
// Nominal spread is the configured target spread percentage.
|
|
1227
|
-
// Keep this fixed: doubled-side flags are fill/replacement mechanics only.
|
|
1228
|
-
const nominalSpread = manager.config.targetSpreadPercent ?? DEFAULT_CONFIG.targetSpreadPercent;
|
|
1229
|
-
// Fixed tolerance: 0.5 steps = half increment (tighter spread check).
|
|
1230
|
-
const toleranceSteps = 0.5;
|
|
1231
|
-
const buyCount = manager.getOrdersByTypeAndState(ORDER_TYPES.BUY, ORDER_STATES.ACTIVE)
|
|
1232
|
-
.concat(manager.getOrdersByTypeAndState(ORDER_TYPES.BUY, ORDER_STATES.PARTIAL))
|
|
1233
|
-
.filter(o => o?.orderId && Number(o?.size || 0) > 0)
|
|
1234
|
-
.length;
|
|
1235
|
-
const sellCount = manager.getOrdersByTypeAndState(ORDER_TYPES.SELL, ORDER_STATES.ACTIVE)
|
|
1236
|
-
.concat(manager.getOrdersByTypeAndState(ORDER_TYPES.SELL, ORDER_STATES.PARTIAL))
|
|
1237
|
-
.filter(o => o?.orderId && Number(o?.size || 0) > 0)
|
|
1238
|
-
.length;
|
|
1239
|
-
manager.outOfSpread = shouldFlagOutOfSpread(currentSpread, nominalSpread, toleranceSteps, buyCount, sellCount, manager.config.incrementPercent);
|
|
1240
|
-
if (manager.outOfSpread === 0)
|
|
1241
|
-
return false;
|
|
1242
|
-
// Limit spread = nominal + half increment tolerance (0.5 steps).
|
|
1243
|
-
const limitSpread = nominalSpread + (manager.config.incrementPercent * toleranceSteps);
|
|
1244
|
-
manager.logger?.log?.(`Spread too wide (${Format.formatPercent(currentSpread)} > ${Format.formatPercent(limitSpread)}), correcting with ${manager.outOfSpread} extra slot(s)...`, 'warn');
|
|
1245
|
-
const decision = Grid.determineOrderSideByFunds(manager, lastPrice);
|
|
1246
|
-
if (!decision.side)
|
|
1247
|
-
return false;
|
|
1248
|
-
// Perform spread correction by placing orders on the chosen side.
|
|
1249
|
-
correction = await Grid.prepareSpreadCorrectionOrders(manager, decision.side);
|
|
1250
|
-
if (!correction)
|
|
1251
|
-
return false;
|
|
1252
|
-
const placeCount = correction.ordersToPlace?.length || 0;
|
|
1253
|
-
const updateCount = correction.ordersToUpdate?.length || 0;
|
|
1254
|
-
// Capture fund snapshot under lock for pre-flight verification before broadcast
|
|
1255
|
-
fundSnapshot = _snapshotFundState(manager);
|
|
1256
|
-
return (placeCount + updateCount) > 0;
|
|
1257
|
-
};
|
|
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)
|
|
1258
1147
|
try {
|
|
1259
|
-
|
|
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 }));
|
|
1260
1154
|
}
|
|
1261
|
-
catch (
|
|
1262
|
-
|
|
1263
|
-
return { ordersPlaced: 0, partialsMoved: 0 };
|
|
1155
|
+
catch (e) {
|
|
1156
|
+
return activeOrders;
|
|
1264
1157
|
}
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
manager.
|
|
1291
|
-
|
|
1292
|
-
|
|
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;
|
|
1293
1193
|
}
|
|
1294
|
-
return { ordersPlaced: 0, partialsMoved: 0 };
|
|
1295
1194
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
// This keeps detection aligned with maintenance actions that operate on
|
|
1317
|
-
// active window partials.
|
|
1318
|
-
const { buyDust, sellDust, buyDustOrders, sellDustOrders } = await Grid.checkWindowDust(manager);
|
|
1319
|
-
// Partial split/merge maintenance is intentionally disabled.
|
|
1320
|
-
// Health checks remain detection-only.
|
|
1321
|
-
return { buyDust, sellDust, buyDustOrders, sellDustOrders };
|
|
1322
|
-
}
|
|
1323
|
-
/**
|
|
1324
|
-
* Dust check covering all partial orders, with interior-only guard.
|
|
1325
|
-
*
|
|
1326
|
-
* The top-of-window partial (closest to market) is always eligible for dust
|
|
1327
|
-
* detection since cancelling it is just the grid edge moving inward.
|
|
1328
|
-
*
|
|
1329
|
-
* Interior partials (further from market) are only eligible if they have a
|
|
1330
|
-
* duplicate price level — another active order at essentially the same price.
|
|
1331
|
-
* Cancelling such an interior partial won't leave a gap in the grid because
|
|
1332
|
-
* the sibling active order already covers that price level.
|
|
1333
|
-
*
|
|
1334
|
-
* Returns boolean flags plus the actual dust order objects so callers can act
|
|
1335
|
-
* on individual orders (e.g. DUST_CANCEL_DELAY_SEC auto-cancel).
|
|
1336
|
-
*
|
|
1337
|
-
* @param {import('./types').OrderManager} manager
|
|
1338
|
-
* @returns {Promise<import('./types').DustCheckResult>}
|
|
1339
|
-
*/
|
|
1340
|
-
static async checkWindowDust(manager) {
|
|
1341
|
-
if (!manager)
|
|
1342
|
-
return { buyDust: false, sellDust: false, buyDustOrders: [], sellDustOrders: [] };
|
|
1343
|
-
const allOrders = Array.from(manager.orders.values());
|
|
1344
|
-
const isLiveOrder = order => order &&
|
|
1345
|
-
order.orderId &&
|
|
1346
|
-
order.price != null &&
|
|
1347
|
-
(order.state === ORDER_STATES.ACTIVE || order.state === ORDER_STATES.PARTIAL);
|
|
1348
|
-
// Identify top-of-window orders (closest to market per side).
|
|
1349
|
-
const topBuyOrder = allOrders
|
|
1350
|
-
.filter(o => o.type === ORDER_TYPES.BUY && isLiveOrder(o))
|
|
1351
|
-
.sort((a, b) => b.price - a.price)[0];
|
|
1352
|
-
const topSellOrder = allOrders
|
|
1353
|
-
.filter(o => o.type === ORDER_TYPES.SELL && isLiveOrder(o))
|
|
1354
|
-
.sort((a, b) => a.price - b.price)[0];
|
|
1355
|
-
// Check if an order has a duplicate price level — an active sibling at the
|
|
1356
|
-
// same price within tolerance. If so, cancelling won't create a grid gap.
|
|
1357
|
-
// Only checks ACTIVE siblings. If two PARTIALs share a price with no active
|
|
1358
|
-
// sibling, neither qualifies and the gap is left to the rebalancer.
|
|
1359
|
-
// Uses the LARGER size of the two orders for tolerance calculation to prevent
|
|
1360
|
-
// a tiny dust order from inflating the tolerance window.
|
|
1361
|
-
const hasDuplicatePriceLevel = (order, assets) => allOrders.some(o => {
|
|
1362
|
-
if (o.id === order.id || o.type !== order.type)
|
|
1363
|
-
return false;
|
|
1364
|
-
if (o.state !== ORDER_STATES.ACTIVE || !o.orderId || o.price == null)
|
|
1365
|
-
return false;
|
|
1366
|
-
const toleranceSize = Math.max(order.size, o.size);
|
|
1367
|
-
const tolerance = calculatePriceTolerance(Math.min(order.price, o.price), toleranceSize, order.type, assets);
|
|
1368
|
-
return tolerance != null && Math.abs(o.price - order.price) <= tolerance;
|
|
1369
|
-
});
|
|
1370
|
-
const assets = manager.assets;
|
|
1371
|
-
const allPartials = allOrders.filter((o) => isLiveOrder(o) && o.state === ORDER_STATES.PARTIAL);
|
|
1372
|
-
const isTopBuy = (o) => topBuyOrder && o.id === topBuyOrder.id;
|
|
1373
|
-
const isTopSell = (o) => topSellOrder && o.id === topSellOrder.id;
|
|
1374
|
-
// Safety filter: top-of-window partials always qualify; interior partials
|
|
1375
|
-
// only qualify if they have a duplicate price level (no gap risk).
|
|
1376
|
-
const eligibleBuyPartials = allPartials.filter((o) => o.type === ORDER_TYPES.BUY && (isTopBuy(o) || hasDuplicatePriceLevel(o, assets)));
|
|
1377
|
-
const eligibleSellPartials = allPartials.filter((o) => o.type === ORDER_TYPES.SELL && (isTopSell(o) || hasDuplicatePriceLevel(o, assets)));
|
|
1378
|
-
const buyDustOrders = await Grid._getDustOrders(manager, eligibleBuyPartials, ORDER_TYPES.BUY);
|
|
1379
|
-
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');
|
|
1380
1215
|
return {
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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)
|
|
1385
1220
|
};
|
|
1386
1221
|
}
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
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)
|
|
1439
1316
|
return false;
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
const
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
return
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
const
|
|
1468
|
-
const
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
const sellViable = sellAvailable > sellMinUnit;
|
|
1476
|
-
let side = null;
|
|
1477
|
-
if (buyViable && sellViable) {
|
|
1478
|
-
// Normalize sell (assetA) to assetB units using market price so both sides
|
|
1479
|
-
// are comparable. Without this, a raw number comparison (e.g. 2192 BTS vs
|
|
1480
|
-
// 0.12 XRP) always picks BUY even when the sell side is larger in value.
|
|
1481
|
-
const marketPrice = Number(currentMarketPrice);
|
|
1482
|
-
const sellInBuyUnits = (Number.isFinite(marketPrice) && marketPrice > 0)
|
|
1483
|
-
? sellAvailable * marketPrice
|
|
1484
|
-
: sellAvailable;
|
|
1485
|
-
side = buyAvailable >= sellInBuyUnits ? ORDER_TYPES.BUY : ORDER_TYPES.SELL;
|
|
1486
|
-
}
|
|
1487
|
-
else if (buyViable) {
|
|
1488
|
-
side = ORDER_TYPES.BUY;
|
|
1489
|
-
}
|
|
1490
|
-
else if (sellViable) {
|
|
1491
|
-
side = ORDER_TYPES.SELL;
|
|
1492
|
-
}
|
|
1493
|
-
if (!side) {
|
|
1494
|
-
const committedBuy = Math.max(0, Number(manager.funds?.committed?.chain?.buy || 0));
|
|
1495
|
-
const committedSell = Math.max(0, Number(manager.funds?.committed?.chain?.sell || 0));
|
|
1496
|
-
const marketPrice = Number(currentMarketPrice);
|
|
1497
|
-
const hasValidPrice = Number.isFinite(marketPrice) && marketPrice > 0;
|
|
1498
|
-
if (committedBuy > buyMinUnit || committedSell > sellMinUnit) {
|
|
1499
|
-
if (hasValidPrice) {
|
|
1500
|
-
const buyComparable = committedBuy;
|
|
1501
|
-
const sellComparable = committedSell * marketPrice;
|
|
1502
|
-
side = buyComparable >= sellComparable ? ORDER_TYPES.BUY : ORDER_TYPES.SELL;
|
|
1503
|
-
}
|
|
1504
|
-
else if (committedBuy > buyMinUnit && committedSell <= sellMinUnit) {
|
|
1505
|
-
side = ORDER_TYPES.BUY;
|
|
1506
|
-
}
|
|
1507
|
-
else if (committedSell > sellMinUnit && committedBuy <= buyMinUnit) {
|
|
1508
|
-
side = ORDER_TYPES.SELL;
|
|
1509
|
-
}
|
|
1510
|
-
else {
|
|
1511
|
-
// Deterministic fallback when both sides hold inventory but market valuation is unavailable.
|
|
1512
|
-
side = ORDER_TYPES.BUY;
|
|
1513
|
-
}
|
|
1514
|
-
manager.logger?.log?.(`Spread correction using redistribution fallback on ${side} ` +
|
|
1515
|
-
`(free buy=${Format.formatAmount8(buyAvailable)}, free sell=${Format.formatAmount8(sellAvailable)}, ` +
|
|
1516
|
-
`price=${hasValidPrice ? Format.formatAmount8(marketPrice) : 'unavailable'})`, 'info');
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
if (!side) {
|
|
1520
|
-
manager.logger?.log?.(`Spread correction skipped: insufficient free funds and no committed inventory to redistribute ` +
|
|
1521
|
-
`(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 };
|
|
1522
1352
|
}
|
|
1523
|
-
return { side, reason: side ? `Choosing ${side}` : 'Insufficient funds or committed inventory' };
|
|
1524
|
-
}
|
|
1525
|
-
/**
|
|
1526
|
-
* Calculate the geometric ideal size for a new order being placed during spread correction.
|
|
1527
|
-
* @param {import('./types').OrderManager} manager - The manager instance.
|
|
1528
|
-
* @param {import('./types').OrderType} targetType - The type of order being placed (ORDER_TYPES.BUY or ORDER_TYPES.SELL).
|
|
1529
|
-
* @returns {Promise<number|null>} The calculated geometric size.
|
|
1530
|
-
*/
|
|
1531
|
-
static async calculateGeometricSizeForSpreadCorrection(manager, targetType) {
|
|
1532
|
-
const side = targetType === ORDER_TYPES.BUY ? 'buy' : 'sell';
|
|
1533
|
-
const slotsCount = Array.from(manager.orders.values()).filter(o => o.type === targetType).length + 1;
|
|
1534
|
-
// Use centralized sizing context (respects botFunds % allocation)
|
|
1535
|
-
const ctx = await Grid._getSizingContext(manager, side);
|
|
1536
|
-
if (!ctx || ctx.budget <= 0 || slotsCount < 1)
|
|
1537
|
-
return null;
|
|
1538
|
-
// ALLOW slotsCount === 1 to enable spread correction even if a side is completely missing
|
|
1539
|
-
const dummy = Array.from({ length: slotsCount }, () => ({ type: targetType }));
|
|
1540
1353
|
try {
|
|
1541
|
-
const
|
|
1542
|
-
if (!
|
|
1543
|
-
manager.logger?.log?.(`
|
|
1544
|
-
return
|
|
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 };
|
|
1545
1358
|
}
|
|
1546
|
-
|
|
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 };
|
|
1547
1363
|
}
|
|
1548
|
-
catch (
|
|
1549
|
-
manager.logger?.log?.(`Error
|
|
1550
|
-
return
|
|
1364
|
+
catch (err) {
|
|
1365
|
+
manager.logger?.log?.(`Error applying spread correction on-chain: ${err.message}`, 'warn');
|
|
1366
|
+
return { ordersPlaced: 0, partialsMoved: 0 };
|
|
1551
1367
|
}
|
|
1552
1368
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
const
|
|
1626
|
-
const
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
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._state.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;
|
|
1645
1578
|
}
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
const ideal = Number(idealById.get(slot.id) || 0);
|
|
1649
|
-
if (ideal > precisionEpsilon) {
|
|
1650
|
-
prioritizedTargets.push({
|
|
1651
|
-
kind: 'create',
|
|
1652
|
-
order: slot,
|
|
1653
|
-
current: 0,
|
|
1654
|
-
ideal,
|
|
1655
|
-
needed: ideal
|
|
1656
|
-
});
|
|
1579
|
+
else if (committedBuy > buyMinUnit && committedSell <= sellMinUnit) {
|
|
1580
|
+
side = ORDER_TYPES.BUY;
|
|
1657
1581
|
}
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
return { ordersToPlace: [], ordersToUpdate: [] };
|
|
1661
|
-
}
|
|
1662
|
-
const totalNeeded = prioritizedTargets.reduce((sum, t) => sum + Math.max(0, Number(t.needed || 0)), 0);
|
|
1663
|
-
let recoveredBudget = 0;
|
|
1664
|
-
const redistributionUpdates = [];
|
|
1665
|
-
if (totalNeeded > availableFund + precisionEpsilon) {
|
|
1666
|
-
let shortfall = totalNeeded - availableFund;
|
|
1667
|
-
const donors = sideSlots
|
|
1668
|
-
.filter(o => hasOnChainId(o) && (o.state === ORDER_STATES.ACTIVE || o.state === ORDER_STATES.PARTIAL))
|
|
1669
|
-
.filter(o => !edgePartial || o.id !== edgePartial.id)
|
|
1670
|
-
.sort((a, b) => railType === ORDER_TYPES.BUY ? a.price - b.price : b.price - a.price);
|
|
1671
|
-
for (const donor of donors) {
|
|
1672
|
-
if (shortfall <= precisionEpsilon)
|
|
1673
|
-
break;
|
|
1674
|
-
const donorCurrent = Number(donor.size || 0);
|
|
1675
|
-
const donorIdeal = Number(idealById.get(donor.id) || 0);
|
|
1676
|
-
const donorFloor = Math.max(minAbsoluteSize, donorIdeal);
|
|
1677
|
-
const donorReducible = Math.max(0, donorCurrent - donorFloor);
|
|
1678
|
-
if (donorReducible <= precisionEpsilon)
|
|
1679
|
-
continue;
|
|
1680
|
-
const reduction = Math.min(donorReducible, shortfall);
|
|
1681
|
-
const donorNext = donorCurrent - reduction;
|
|
1682
|
-
if (donorNext <= precisionEpsilon)
|
|
1683
|
-
continue;
|
|
1684
|
-
if (!isOrderHealthy(donorNext, railType, manager.assets, donorIdeal || donorNext))
|
|
1685
|
-
continue;
|
|
1686
|
-
redistributionUpdates.push({ partialOrder: { ...donor }, newSize: donorNext });
|
|
1687
|
-
recoveredBudget += reduction;
|
|
1688
|
-
shortfall -= reduction;
|
|
1582
|
+
else if (committedSell > sellMinUnit && committedBuy <= buyMinUnit) {
|
|
1583
|
+
side = ORDER_TYPES.SELL;
|
|
1689
1584
|
}
|
|
1690
|
-
|
|
1691
|
-
|
|
1585
|
+
else {
|
|
1586
|
+
// Deterministic fallback when both sides hold inventory but market valuation is unavailable.
|
|
1587
|
+
side = ORDER_TYPES.BUY;
|
|
1692
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');
|
|
1592
|
+
}
|
|
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
|
+
});
|
|
1693
1720
|
}
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
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)
|
|
1697
1748
|
break;
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
remainingBudget -= topUp;
|
|
1704
|
-
}
|
|
1705
|
-
continue;
|
|
1706
|
-
}
|
|
1707
|
-
const createSize = Math.min(target.ideal, remainingBudget);
|
|
1708
|
-
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)
|
|
1709
1754
|
continue;
|
|
1710
|
-
|
|
1755
|
+
const reduction = Math.min(donorReducible, shortfall);
|
|
1756
|
+
const donorNext = donorCurrent - reduction;
|
|
1757
|
+
if (donorNext <= precisionEpsilon)
|
|
1711
1758
|
continue;
|
|
1712
|
-
|
|
1713
|
-
...target.order,
|
|
1714
|
-
type: railType,
|
|
1715
|
-
size: createSize,
|
|
1716
|
-
state: ORDER_STATES.VIRTUAL
|
|
1717
|
-
});
|
|
1718
|
-
remainingBudget -= createSize;
|
|
1719
|
-
}
|
|
1720
|
-
const combinedUpdates = [...redistributionUpdates];
|
|
1721
|
-
for (const plannedUpdate of ordersToUpdate) {
|
|
1722
|
-
const id = plannedUpdate?.partialOrder?.id || plannedUpdate?.id;
|
|
1723
|
-
if (!id)
|
|
1759
|
+
if (!isOrderHealthy(donorNext, railType, manager.assets, donorIdeal || donorNext))
|
|
1724
1760
|
continue;
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
}
|
|
1729
|
-
else {
|
|
1730
|
-
combinedUpdates.push(plannedUpdate);
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
if (spreadCandidates.length < missingSlots) {
|
|
1734
|
-
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;
|
|
1735
1764
|
}
|
|
1736
|
-
if (
|
|
1737
|
-
manager.logger?.log?.(`[SPREAD-CORRECTION]
|
|
1765
|
+
if (recoveredBudget > precisionEpsilon) {
|
|
1766
|
+
manager.logger?.log?.(`[SPREAD-CORRECTION] Recovered ${Format.formatSizeByOrderType(recoveredBudget, railType, manager.assets)} on ${sideName} via redistribution`, 'info');
|
|
1738
1767
|
}
|
|
1739
|
-
|
|
1740
|
-
|
|
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);
|
|
1741
1806
|
}
|
|
1742
|
-
return { ordersToPlace, ordersToUpdate: combinedUpdates };
|
|
1743
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 };
|
|
1744
1818
|
}
|
|
1745
|
-
module.exports = Grid;
|
|
1746
1819
|
//# sourceMappingURL=grid.js.map
|