dexbot 1.5.3 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +9 -7
  3. package/analysis/README.md +1 -2
  4. package/analysis/ama_fitting/fetch_lp_candles.ts +1 -9
  5. package/analysis/ama_fitting/package.json +1 -1
  6. package/analysis/analyze_derivatives.ts +1 -1
  7. package/analysis/bot_usage/discover_bot_accounts.ts +12 -6
  8. package/analysis/resolve_source.ts +9 -2
  9. package/analysis/tradingview/README.md +29 -11
  10. package/analysis/tradingview/analyze_tradingview.ts +92 -3
  11. package/analysis/tradingview/tradingview_uplot_chart_generator.ts +1120 -182
  12. package/analysis/trend_detection/package.json +1 -1
  13. package/claw/docs/DEXBOT2_TUNING_CHEAT_SHEET.md +1 -1
  14. package/claw/modules/dexbot_profiles.ts +22 -2
  15. package/claw/package.json +1 -1
  16. package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
  17. package/claw/runtimes/openclaw-plugin/package.json +1 -1
  18. package/claw/skills/launcher-ops/references/launcher-workflow.md +1 -1
  19. package/claw/tests/test_claw_mcp_transport.ts +2 -2
  20. package/claw/tests/test_dexbot_profiles.ts +65 -0
  21. package/dist/analysis/ama_fitting/fetch_lp_candles.js +1 -9
  22. package/dist/analysis/ama_fitting/fetch_lp_candles.js.map +1 -1
  23. package/dist/analysis/analyze_derivatives.js +1 -1
  24. package/dist/analysis/bot_usage/discover_bot_accounts.js +11 -6
  25. package/dist/analysis/bot_usage/discover_bot_accounts.js.map +1 -1
  26. package/dist/analysis/bot_usage/kibana_bot_queries.d.ts +2 -0
  27. package/dist/analysis/bot_usage/kibana_bot_queries.d.ts.map +1 -1
  28. package/dist/analysis/resolve_source.d.ts +1 -0
  29. package/dist/analysis/resolve_source.d.ts.map +1 -1
  30. package/dist/analysis/resolve_source.js +6 -2
  31. package/dist/analysis/resolve_source.js.map +1 -1
  32. package/dist/analysis/tradingview/analyze_tradingview.d.ts +5 -0
  33. package/dist/analysis/tradingview/analyze_tradingview.d.ts.map +1 -1
  34. package/dist/analysis/tradingview/analyze_tradingview.js +112 -3
  35. package/dist/analysis/tradingview/analyze_tradingview.js.map +1 -1
  36. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts +5 -1
  37. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts.map +1 -1
  38. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js +1118 -182
  39. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js.map +1 -1
  40. package/dist/bot.js +1 -1
  41. package/dist/bot.js.map +1 -1
  42. package/dist/dexbot.js +4 -4
  43. package/dist/dexbot.js.map +1 -1
  44. package/dist/market_adapter/ama_signal_runner.js +2 -2
  45. package/dist/market_adapter/candle_utils.d.ts +6 -0
  46. package/dist/market_adapter/candle_utils.d.ts.map +1 -1
  47. package/dist/market_adapter/candle_utils.js +6 -0
  48. package/dist/market_adapter/candle_utils.js.map +1 -1
  49. package/dist/market_adapter/core/kibana_candles.d.ts +17 -1
  50. package/dist/market_adapter/core/kibana_candles.d.ts.map +1 -1
  51. package/dist/market_adapter/core/kibana_candles.js +101 -19
  52. package/dist/market_adapter/core/kibana_candles.js.map +1 -1
  53. package/dist/market_adapter/core/kibana_client.d.ts +2 -0
  54. package/dist/market_adapter/core/kibana_client.d.ts.map +1 -1
  55. package/dist/market_adapter/core/kibana_client.js +31 -4
  56. package/dist/market_adapter/core/kibana_client.js.map +1 -1
  57. package/dist/market_adapter/core/market_adapter_service.d.ts.map +1 -1
  58. package/dist/market_adapter/core/market_adapter_service.js +0 -6
  59. package/dist/market_adapter/core/market_adapter_service.js.map +1 -1
  60. package/dist/market_adapter/inputs/fetch_book_data.d.ts +14 -0
  61. package/dist/market_adapter/inputs/fetch_book_data.d.ts.map +1 -0
  62. package/dist/market_adapter/inputs/fetch_book_data.js +138 -0
  63. package/dist/market_adapter/inputs/fetch_book_data.js.map +1 -0
  64. package/dist/market_adapter/inputs/fetch_cex_synthetic_data.js +1 -1
  65. package/dist/market_adapter/inputs/fetch_lp_data.d.ts +16 -2
  66. package/dist/market_adapter/inputs/fetch_lp_data.d.ts.map +1 -1
  67. package/dist/market_adapter/inputs/fetch_lp_data.js +109 -281
  68. package/dist/market_adapter/inputs/fetch_lp_data.js.map +1 -1
  69. package/dist/market_adapter/inputs/kibana_feed_source.d.ts +87 -0
  70. package/dist/market_adapter/inputs/kibana_feed_source.d.ts.map +1 -0
  71. package/dist/market_adapter/inputs/kibana_feed_source.js +570 -0
  72. package/dist/market_adapter/inputs/kibana_feed_source.js.map +1 -0
  73. package/dist/market_adapter/inputs/kibana_source.d.ts.map +1 -1
  74. package/dist/market_adapter/inputs/kibana_source.js +10 -5
  75. package/dist/market_adapter/inputs/kibana_source.js.map +1 -1
  76. package/dist/market_adapter/inputs/window_cache.d.ts +166 -0
  77. package/dist/market_adapter/inputs/window_cache.d.ts.map +1 -0
  78. package/dist/market_adapter/inputs/window_cache.js +500 -0
  79. package/dist/market_adapter/inputs/window_cache.js.map +1 -0
  80. package/dist/market_adapter/interval_utils.d.ts +8 -1
  81. package/dist/market_adapter/interval_utils.d.ts.map +1 -1
  82. package/dist/market_adapter/interval_utils.js +14 -1
  83. package/dist/market_adapter/interval_utils.js.map +1 -1
  84. package/dist/modules/account_bots.d.ts +1 -0
  85. package/dist/modules/account_bots.d.ts.map +1 -1
  86. package/dist/modules/account_bots.js +21 -5
  87. package/dist/modules/account_bots.js.map +1 -1
  88. package/dist/modules/account_orders.d.ts +25 -4
  89. package/dist/modules/account_orders.d.ts.map +1 -1
  90. package/dist/modules/account_orders.js +67 -4
  91. package/dist/modules/account_orders.js.map +1 -1
  92. package/dist/modules/bitshares-native/chain_client.d.ts +2 -0
  93. package/dist/modules/bitshares-native/chain_client.d.ts.map +1 -1
  94. package/dist/modules/bitshares-native/chain_client.js +3 -1
  95. package/dist/modules/bitshares-native/chain_client.js.map +1 -1
  96. package/dist/modules/bitshares-native/subscriptions.d.ts.map +1 -1
  97. package/dist/modules/bitshares-native/subscriptions.js +13 -3
  98. package/dist/modules/bitshares-native/subscriptions.js.map +1 -1
  99. package/dist/modules/bitshares-native/transport.d.ts +2 -0
  100. package/dist/modules/bitshares-native/transport.d.ts.map +1 -1
  101. package/dist/modules/bitshares-native/transport.js +122 -28
  102. package/dist/modules/bitshares-native/transport.js.map +1 -1
  103. package/dist/modules/bitshares_client.d.ts.map +1 -1
  104. package/dist/modules/bitshares_client.js +59 -8
  105. package/dist/modules/bitshares_client.js.map +1 -1
  106. package/dist/modules/bot_settings.d.ts.map +1 -1
  107. package/dist/modules/bot_settings.js +11 -0
  108. package/dist/modules/bot_settings.js.map +1 -1
  109. package/dist/modules/constants.d.ts +18 -0
  110. package/dist/modules/constants.d.ts.map +1 -1
  111. package/dist/modules/constants.js +90 -4
  112. package/dist/modules/constants.js.map +1 -1
  113. package/dist/modules/dexbot_class.d.ts +11 -0
  114. package/dist/modules/dexbot_class.d.ts.map +1 -1
  115. package/dist/modules/dexbot_class.js +92 -4
  116. package/dist/modules/dexbot_class.js.map +1 -1
  117. package/dist/modules/dexbot_cow_runtime.d.ts +26 -1
  118. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  119. package/dist/modules/dexbot_cow_runtime.js +157 -28
  120. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  121. package/dist/modules/dexbot_fill_runtime.d.ts +66 -1
  122. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  123. package/dist/modules/dexbot_fill_runtime.js +295 -10
  124. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  125. package/dist/modules/dexbot_maintenance_runtime.d.ts +102 -12
  126. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  127. package/dist/modules/dexbot_maintenance_runtime.js +644 -48
  128. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  129. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  130. package/dist/modules/dexbot_startup_runtime.js +13 -0
  131. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  132. package/dist/modules/dexbot_state_recovery.d.ts +15 -1
  133. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  134. package/dist/modules/dexbot_state_recovery.js +52 -8
  135. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  136. package/dist/modules/fund_registry.d.ts +1 -1
  137. package/dist/modules/fund_registry.js +1 -1
  138. package/dist/modules/market_adapter_whitelist.js +1 -1
  139. package/dist/modules/market_adapter_whitelist.js.map +1 -1
  140. package/dist/modules/node_connect_policy.d.ts +52 -0
  141. package/dist/modules/node_connect_policy.d.ts.map +1 -0
  142. package/dist/modules/node_connect_policy.js +35 -0
  143. package/dist/modules/node_connect_policy.js.map +1 -0
  144. package/dist/modules/node_manager.d.ts +32 -0
  145. package/dist/modules/node_manager.d.ts.map +1 -1
  146. package/dist/modules/node_manager.js +76 -9
  147. package/dist/modules/node_manager.js.map +1 -1
  148. package/dist/modules/order/accounting.d.ts +30 -0
  149. package/dist/modules/order/accounting.d.ts.map +1 -1
  150. package/dist/modules/order/accounting.js +176 -3
  151. package/dist/modules/order/accounting.js.map +1 -1
  152. package/dist/modules/order/export.d.ts.map +1 -1
  153. package/dist/modules/order/export.js +1 -0
  154. package/dist/modules/order/export.js.map +1 -1
  155. package/dist/modules/order/grid.d.ts.map +1 -1
  156. package/dist/modules/order/grid.js +189 -25
  157. package/dist/modules/order/grid.js.map +1 -1
  158. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  159. package/dist/modules/order/grid_reconcile.js +5 -3
  160. package/dist/modules/order/grid_reconcile.js.map +1 -1
  161. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  162. package/dist/modules/order/grid_reconcile_internal.js +216 -9
  163. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  164. package/dist/modules/order/logger.js +1 -1
  165. package/dist/modules/order/logger.js.map +1 -1
  166. package/dist/modules/order/manager.d.ts +77 -2
  167. package/dist/modules/order/manager.d.ts.map +1 -1
  168. package/dist/modules/order/manager.js +283 -14
  169. package/dist/modules/order/manager.js.map +1 -1
  170. package/dist/modules/order/strategy.d.ts +3 -0
  171. package/dist/modules/order/strategy.d.ts.map +1 -1
  172. package/dist/modules/order/strategy.js +79 -7
  173. package/dist/modules/order/strategy.js.map +1 -1
  174. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  175. package/dist/modules/order/sync_engine.js +16 -1
  176. package/dist/modules/order/sync_engine.js.map +1 -1
  177. package/dist/modules/order/utils/math.d.ts +16 -2
  178. package/dist/modules/order/utils/math.d.ts.map +1 -1
  179. package/dist/modules/order/utils/math.js +30 -3
  180. package/dist/modules/order/utils/math.js.map +1 -1
  181. package/dist/modules/order/utils/order.d.ts +258 -13
  182. package/dist/modules/order/utils/order.d.ts.map +1 -1
  183. package/dist/modules/order/utils/order.js +666 -13
  184. package/dist/modules/order/utils/order.js.map +1 -1
  185. package/dist/modules/order/utils/system.d.ts +36 -0
  186. package/dist/modules/order/utils/system.d.ts.map +1 -1
  187. package/dist/modules/order/utils/system.js +102 -6
  188. package/dist/modules/order/utils/system.js.map +1 -1
  189. package/dist/modules/order/utils/validate.d.ts.map +1 -1
  190. package/dist/modules/order/utils/validate.js +6 -2
  191. package/dist/modules/order/utils/validate.js.map +1 -1
  192. package/dist/modules/paths.d.ts +2 -0
  193. package/dist/modules/paths.d.ts.map +1 -1
  194. package/dist/modules/paths.js +1 -0
  195. package/dist/modules/paths.js.map +1 -1
  196. package/dist/modules/runtime_settings.d.ts +13 -0
  197. package/dist/modules/runtime_settings.d.ts.map +1 -1
  198. package/dist/modules/runtime_settings.js +20 -0
  199. package/dist/modules/runtime_settings.js.map +1 -1
  200. package/dist/modules/utils/errors.d.ts +16 -0
  201. package/dist/modules/utils/errors.d.ts.map +1 -1
  202. package/dist/modules/utils/errors.js +31 -0
  203. package/dist/modules/utils/errors.js.map +1 -1
  204. package/dist/pm2.js +7 -7
  205. package/dist/scripts/analyze-credit.d.ts.map +1 -1
  206. package/dist/scripts/analyze-credit.js +13 -3
  207. package/dist/scripts/analyze-credit.js.map +1 -1
  208. package/dist/scripts/analyze-orders.js +2 -2
  209. package/dist/scripts/analyze-orders.js.map +1 -1
  210. package/dist/scripts/diagnose-kibana-candles.d.ts.map +1 -1
  211. package/dist/scripts/diagnose-kibana-candles.js +0 -1
  212. package/dist/scripts/diagnose-kibana-candles.js.map +1 -1
  213. package/dist/scripts/generate_market_adapter_whitelist.d.ts.map +1 -1
  214. package/dist/scripts/generate_market_adapter_whitelist.js +3 -2
  215. package/dist/scripts/generate_market_adapter_whitelist.js.map +1 -1
  216. package/dist/scripts/test-credit-renewal.js +1 -1
  217. package/dist/scripts/test-credit-renewal.js.map +1 -1
  218. package/dist/scripts/tv.d.ts +24 -2
  219. package/dist/scripts/tv.d.ts.map +1 -1
  220. package/dist/scripts/tv.js +190 -65
  221. package/dist/scripts/tv.js.map +1 -1
  222. package/docs/BITSHARES_ONBOARDING.md +19 -4
  223. package/docs/COW_INVARIANTS.md +18 -1
  224. package/docs/DEXBOT_COMPARISON.md +3 -3
  225. package/docs/EVOLUTION.md +9 -7
  226. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +21 -9
  227. package/docs/GRID_RECALCULATION.md +13 -13
  228. package/docs/GRID_RECONCILE.md +12 -2
  229. package/docs/MPA_CREDIT_USAGE.md +2 -2
  230. package/docs/README.md +5 -6
  231. package/docs/architecture.md +0 -58
  232. package/docs/developer_guide.md +2 -1
  233. package/docs/docker.md +1 -1
  234. package/market_adapter/README.md +17 -10
  235. package/modules/README.md +1 -1
  236. package/package.json +1 -1
  237. package/scripts/README.md +9 -7
  238. package/tests/README.md +2 -2
  239. package/analysis/results/ama_sweep_results_lp_pool_133_1h.json +0 -2455
  240. package/analysis/results/bot_fitting_results_lp_pool_133_1h.json +0 -218
  241. package/analysis/tradingview/h-bts_tradingview.html +0 -1570
  242. package/analysis/tradingview/t-bts_tradingview.html +0 -1570
@@ -60,10 +60,14 @@
60
60
  * - buildDelta(masterGrid, workingGrid) - Build delta actions between grids
61
61
  * - getOrderSize(order) - Extract order size with fallback
62
62
  *
63
- * SECTION 10: STRATEGY CALCULATIONS (3 functions)
64
- * - deriveTargetBoundary(fills, currentBoundaryIdx, allSlots, config, gapSlots, crossChunkBudget) - Derive boundary from fills (returns { boundaryIdx, remainingBudget })
65
- * - getSideBudget(side, funds, config, totalTarget) - Calculate side budget after fees
66
- * - calculateBudgetedSizes(slots, side, budget, weightDist, incrementPercent, assets) - Calculate budgeted sizes
63
+ * SECTION 10: STRATEGY CALCULATIONS (7 functions)
64
+ * - resolveReserveCount(config, side) - Clamped per-side reserve count (>=0 int, 0 disables)
65
+ * - resolveReserveOrders(config) - Total reserves buy+sell (fee/count totals)
66
+ * - resolveLiveReserveEdgeAnchorPrice(manager, side) - Live-grid edge anchor (ladder/rail extreme first, config bound last; null when unresolved)
67
+ * - resolveReserveEdgeAnchorPrice(config, side) - Config-bound anchor fallback (buy→minPrice, sell→maxPrice; null when unresolvable)
68
+ * - compareReserveEdge(a, b, edge, anchorPrice) - Shared anchored edge comparator (single ordering source)
69
+ * - reserveEdgeIdSet(allSlots, config, orderType, anchorPrice?) - Edge reserve id set (config count; shares the picker ordering)
70
+ * - selectReserveEdgeSlots(sortedAsc, count, excludeIds, edge, anchorPrice?) - Shared position picker (both edges anchor toward their bound)
67
71
  *
68
72
  * ===============================================================================
69
73
  */
@@ -1245,6 +1249,26 @@ function formatUnmatchedChainOrder(order) {
1245
1249
  parts.push(`candidates=${order.candidateDiagnostics}`);
1246
1250
  return parts.join(' ');
1247
1251
  }
1252
+ /**
1253
+ * Whether an unmatched chain-order entry is a deliberate hold that must NOT
1254
+ * block CREATEs or snapshot recovery.
1255
+ *
1256
+ * Deferred entries (`reason` suffixed `-deferred`) are permanently
1257
+ * non-adoptable and non-cancellable: an out-of-grid hold sits outside the
1258
+ * frozen rail, and a boundary-unknown hold is re-evaluated once the boundary
1259
+ * commits. Treating one as a blocker freezes the whole grid (a single
1260
+ * dip-protection hold would stop every CREATE) and forces a full reset on
1261
+ * recovery. Classification is by the shared `-deferred` suffix, not an exact
1262
+ * reason string, so a new defer reason cannot silently regress into a
1263
+ * permanent blocker.
1264
+ *
1265
+ * @param {Object} order - Unmatched chain order entry.
1266
+ * @returns {boolean} True when the entry is a non-blocking deferred hold.
1267
+ */
1268
+ function isNonBlockingUnmatchedOrder(order) {
1269
+ const reason = order?.reason;
1270
+ return typeof reason === 'string' && reason.endsWith('-deferred');
1271
+ }
1248
1272
  /**
1249
1273
  * Check if order is on blockchain (ACTIVE or PARTIAL state).
1250
1274
  *
@@ -1416,6 +1440,13 @@ function checkSizesBeforeMinimum(sizes, minSize, precision) {
1416
1440
  * Calculate ideal grid boundary based on reference price.
1417
1441
  * Places boundary near reference price with gap spacing in mind.
1418
1442
  *
1443
+ * A non-numeric reference (e.g. the unresolved "pool"/"book" mode strings)
1444
+ * makes every `price >= reference` comparison false, which used to resolve
1445
+ * `splitIdx` to `allSlots.length` and fabricate a top-of-rail boundary —
1446
+ * the degenerate all-buy geometry behind the 02:03 slot-77→slot-192 (+58%)
1447
+ * teleport plan. Fail toward rail-center instead; callers with a real
1448
+ * anchor (genesis startPrice, live center) override before calling.
1449
+ *
1419
1450
  * @param {Array<Object>} allSlots - All grid slots sorted by price
1420
1451
  * @param {number} referencePrice - Reference/anchor price
1421
1452
  * @param {number} gapSlots - Number of gap slots between buy and sell
@@ -1424,6 +1455,10 @@ function checkSizesBeforeMinimum(sizes, minSize, precision) {
1424
1455
  function calculateIdealBoundary(allSlots, referencePrice, gapSlots) {
1425
1456
  if (!allSlots || allSlots.length === 0)
1426
1457
  return -1;
1458
+ if (!Number.isFinite(Number(referencePrice))) {
1459
+ const gap = Number.isFinite(Number(gapSlots)) && Number(gapSlots) >= 0 ? Math.floor(Number(gapSlots)) : 0;
1460
+ return Math.max(0, Math.floor((allSlots.length - 1 - gap) / 2));
1461
+ }
1427
1462
  let splitIdx = allSlots.findIndex((s) => s.price >= referencePrice);
1428
1463
  if (splitIdx === -1)
1429
1464
  splitIdx = allSlots.length;
@@ -1507,7 +1542,10 @@ function assignGridRoles(allSlots, boundaryIdx, gapSlots, ORDER_TYPES, _ORDER_ST
1507
1542
  * @returns {number} Excess steps (0 if in-spread, >0 if out-of-spread)
1508
1543
  */
1509
1544
  function shouldFlagOutOfSpread(currentSpread, nominalSpread, toleranceSteps, buyCount, sellCount, incrementPercent = 0.5) {
1510
- if (buyCount === 0 || sellCount === 0) {
1545
+ // Non-finite spread (one-sided book, zero best-buy) with placed orders on
1546
+ // both sides is pathological — treat like the empty side: flag the nominal
1547
+ // gap count, never propagate Infinity as an "extra slots" count.
1548
+ if (buyCount === 0 || sellCount === 0 || !Number.isFinite(Number(currentSpread))) {
1511
1549
  const step = 1 + (incrementPercent / 100);
1512
1550
  const gap = Math.ceil(Math.log(1 + (nominalSpread / 100)) / Math.log(step));
1513
1551
  return Math.max(1, gap);
@@ -1755,23 +1793,156 @@ function buildDelta(masterGrid, workingGrid, options = {}) {
1755
1793
  function isShiftEligibleFill(fill) {
1756
1794
  return fill?.isPartial !== true || fill?.isDelayedRotationTrigger === true;
1757
1795
  }
1758
- function deriveTargetBoundary(fills, currentBoundaryIdx, allSlots, config, gapSlots, crossChunkBudget) {
1796
+ function deriveTargetBoundary(fills, currentBoundaryIdx, allSlots, config, gapSlots, crossChunkBudget, pendingCrawls, edgeAnchors) {
1759
1797
  let newBoundaryIdx = currentBoundaryIdx;
1760
- // Initial recovery if boundary is undefined
1798
+ // Recovery when the committed boundary is unknown (GRID-LOAD rejected a
1799
+ // poisoned snapshot, re-derivation failed, and no fill has re-anchored
1800
+ // since). Anchor tiers are position signals, weakest last. What must
1801
+ // never happen is fabricating a rail-edge boundary from an unresolved
1802
+ // config mode string: startPrice "pool" NaN-matches every price
1803
+ // comparison, resolving to the rail top (Sep-10: base 213, ceiling 211,
1804
+ // then 209 after 4 buy crawls — teleporting the buy rail 113 slots).
1805
+ let recovered = false;
1806
+ let anchoredFromFills = false;
1761
1807
  if (newBoundaryIdx === undefined || newBoundaryIdx === null) {
1762
- const referencePrice = config.startPrice;
1808
+ // Tier 1 — live fills: gap-side extreme (highest buy / lowest sell,
1809
+ // midpoint when both sides filled). Any fill price, eligible or
1810
+ // dust, is real market position and beats every config guess.
1811
+ let topBuy = -Infinity;
1812
+ let botSell = Infinity;
1813
+ for (const fill of fills ?? []) {
1814
+ const p = Number(fill?.price);
1815
+ if (!Number.isFinite(p))
1816
+ continue;
1817
+ if (fill?.type === ORDER_TYPES.BUY && p > topBuy)
1818
+ topBuy = p;
1819
+ if (fill?.type === ORDER_TYPES.SELL && p < botSell)
1820
+ botSell = p;
1821
+ }
1822
+ let referencePrice = null;
1823
+ if (topBuy > -Infinity && botSell < Infinity) {
1824
+ referencePrice = (topBuy + botSell) / 2;
1825
+ anchoredFromFills = true;
1826
+ }
1827
+ else if (topBuy > -Infinity) {
1828
+ referencePrice = topBuy;
1829
+ anchoredFromFills = true;
1830
+ }
1831
+ else if (botSell < Infinity) {
1832
+ referencePrice = botSell;
1833
+ anchoredFromFills = true;
1834
+ }
1835
+ // Tier 2 — explicit numeric config center.
1836
+ if (referencePrice === null) {
1837
+ const direct = Number(config?.startPrice);
1838
+ if (Number.isFinite(direct))
1839
+ referencePrice = direct;
1840
+ }
1841
+ // Tier 3 — frozen genesis center (forwarded by the strategy when
1842
+ // config.startPrice is an unresolved mode string).
1843
+ if (referencePrice === null) {
1844
+ const genesis = Number(config?.genesisStartPrice);
1845
+ if (Number.isFinite(genesis))
1846
+ referencePrice = genesis;
1847
+ }
1848
+ // Stale-center guard: a Tier-2 numeric config center or a Tier-3
1849
+ // genesis center that falls outside the live rail would clamp
1850
+ // calculateIdealBoundary onto an edge slot — the same rail-edge
1851
+ // fabrication the mode-string fix prevents, just from a stale numeric
1852
+ // value. Drop such a reference so the bounded Tier-4 rail center is
1853
+ // used instead. Tier-1 fill anchors are exempt: a real (possibly
1854
+ // out-of-grid) fill price is live market position and wins everywhere.
1855
+ if (!anchoredFromFills && referencePrice !== null && Array.isArray(allSlots) && allSlots.length > 0) {
1856
+ let railMin = Infinity;
1857
+ let railMax = -Infinity;
1858
+ for (const s of allSlots) {
1859
+ const p = Number(s?.price);
1860
+ if (!Number.isFinite(p))
1861
+ continue;
1862
+ if (p < railMin)
1863
+ railMin = p;
1864
+ if (p > railMax)
1865
+ railMax = p;
1866
+ }
1867
+ if ((Number.isFinite(railMin) && referencePrice < railMin)
1868
+ || (Number.isFinite(railMax) && referencePrice > railMax)) {
1869
+ orderLogger.debug(`deriveTargetBoundary: recovery center ${referencePrice} outside live rail ` +
1870
+ `[${railMin}, ${railMax}]; using bounded rail center instead of pinning an edge`);
1871
+ referencePrice = null;
1872
+ }
1873
+ }
1874
+ // Tier 4 — rail center: bounded and wrong by at most half the rail,
1875
+ // never a rail-edge fabrication. The next fill batch re-anchors
1876
+ // from live prices via Tier 1.
1877
+ if (referencePrice === null && Array.isArray(allSlots) && allSlots.length > 0) {
1878
+ const gap = Number.isFinite(Number(gapSlots)) && Number(gapSlots) >= 0 ? Math.floor(Number(gapSlots)) : 0;
1879
+ const centerIdx = Math.max(0, Math.floor((allSlots.length - 1 - gap) / 2));
1880
+ const centerPrice = Number(allSlots[centerIdx]?.price);
1881
+ if (Number.isFinite(centerPrice))
1882
+ referencePrice = centerPrice;
1883
+ }
1884
+ if (referencePrice === null) {
1885
+ const fallbackCap = Math.max(Math.floor((config?.activeOrders?.sell ?? 1) / 2), Math.floor((config?.activeOrders?.buy ?? 1) / 2), 1);
1886
+ const effectiveBudget = crossChunkBudget ?? fallbackCap;
1887
+ return { boundaryIdx: null, remainingBudget: effectiveBudget };
1888
+ }
1763
1889
  newBoundaryIdx = calculateIdealBoundary(allSlots, referencePrice, gapSlots);
1890
+ if (!Number.isFinite(newBoundaryIdx) || newBoundaryIdx < 0) {
1891
+ // Empty slot list with a Tier 1-3 reference: no honest index
1892
+ // exists (calculateIdealBoundary returns -1). Stay null rather
1893
+ // than letting the clamp below fabricate a slot-0 boundary.
1894
+ const fallbackCap = Math.max(Math.floor((config?.activeOrders?.sell ?? 1) / 2), Math.floor((config?.activeOrders?.buy ?? 1) / 2), 1);
1895
+ return { boundaryIdx: null, remainingBudget: crossChunkBudget ?? fallbackCap };
1896
+ }
1897
+ recovered = true;
1764
1898
  }
1765
- // Apply shift from fills with rate-limiting
1899
+ // Apply shift from fills with rate-limiting (reserve fills excluded: static insurance).
1766
1900
  let netShift = 0;
1901
+ // Reserve ladder: fills from edge-pinned reserve slots never crawl the
1902
+ // boundary — they are static fat-finger insurance, not market movement.
1903
+ // `edgeAnchors` (resolveLiveReserveEdgeAnchorPrice) is the same anchor the
1904
+ // placement sites use; without it the classification falls back to the
1905
+ // config-bound anchor, which can disagree with the slots actually placed.
1906
+ const reserveBuyIds = reserveEdgeIdSet(allSlots, config, ORDER_TYPES.BUY, edgeAnchors?.buy ?? null);
1907
+ const reserveSellIds = reserveEdgeIdSet(allSlots, config, ORDER_TYPES.SELL, edgeAnchors?.sell ?? null);
1908
+ // Pending crawls: fills recorded by earlier batches whose derivation
1909
+ // never committed (refused broadcast, P4 abort, or pre-restart loss —
1910
+ // the Sep-10 case: 4 fills consumed under a null boundary, crawl lost,
1911
+ // restart refilled the holes same-side). Entries for slots in the
1912
+ // CURRENT batch are excluded — those fills crawl below as usual;
1913
+ // anything older is still owed and shifts here. Reserve-slot entries
1914
+ // never crawl (static insurance), same as live fills.
1915
+ const currentSlotIds = new Set((fills ?? []).map((f) => f?.id).filter((id) => typeof id === 'string' && id.length > 0));
1916
+ const owedPending = (pendingCrawls ?? []).filter((e) => e
1917
+ && typeof e.slotId === 'string' && e.slotId.length > 0
1918
+ && !currentSlotIds.has(e.slotId)
1919
+ && (e.side === ORDER_TYPES.BUY || e.side === ORDER_TYPES.SELL)
1920
+ && !(e.side === ORDER_TYPES.BUY && reserveBuyIds && reserveBuyIds.has(e.slotId))
1921
+ && !(e.side === ORDER_TYPES.SELL && reserveSellIds && reserveSellIds.has(e.slotId)));
1767
1922
  for (const fill of fills) {
1768
1923
  if (!isShiftEligibleFill(fill))
1769
1924
  continue;
1925
+ if (fill && fill.type === ORDER_TYPES.BUY && reserveBuyIds && reserveBuyIds.has(fill.id))
1926
+ continue;
1927
+ if (fill && fill.type === ORDER_TYPES.SELL && reserveSellIds && reserveSellIds.has(fill.id))
1928
+ continue;
1770
1929
  if (fill.type === ORDER_TYPES.SELL)
1771
1930
  netShift++;
1772
1931
  else if (fill.type === ORDER_TYPES.BUY)
1773
1932
  netShift--;
1774
1933
  }
1934
+ if (!anchoredFromFills) {
1935
+ // Owed deltas from earlier uncommitted batches shift on top of the
1936
+ // current fills. Skipped under an absolute fill anchor: the anchor
1937
+ // positions from live market prices, which already reflect all
1938
+ // consumed fills — shifting again would double-count.
1939
+ for (const e of owedPending) {
1940
+ if (e.side === ORDER_TYPES.SELL)
1941
+ netShift++;
1942
+ else
1943
+ netShift--;
1944
+ }
1945
+ }
1775
1946
  // Cap cumulative shift to prevent overreaction from burst fills.
1776
1947
  // Uses a cross-chunk budget managed by the caller — each chunk
1777
1948
  // consumes from the same pool so the total across all chunks
@@ -1780,6 +1951,16 @@ function deriveTargetBoundary(fills, currentBoundaryIdx, allSlots, config, gapSl
1780
1951
  const fallbackCap = Math.max(Math.floor((config.activeOrders?.sell ?? 1) / 2), Math.floor((config.activeOrders?.buy ?? 1) / 2), 1);
1781
1952
  const effectiveBudget = crossChunkBudget ?? fallbackCap;
1782
1953
  const cap = Math.min(Math.abs(effectiveBudget), fallbackCap);
1954
+ if (recovered && anchoredFromFills) {
1955
+ // The anchor already contains this batch's fill information —
1956
+ // crawling would double-count the same fills (Sep-10 batch 1:
1957
+ // dust-sell anchor 97 plus a +1 crawl would have moved it to 98).
1958
+ // The next batch crawls normally from the anchored boundary.
1959
+ return {
1960
+ boundaryIdx: Math.max(0, Math.min((allSlots.length - gapSlots - 1) >= 0 ? (allSlots.length - gapSlots - 1) : (allSlots.length - 1), newBoundaryIdx)),
1961
+ remainingBudget: effectiveBudget,
1962
+ };
1963
+ }
1783
1964
  if (Math.abs(netShift) > cap) {
1784
1965
  netShift = Math.sign(netShift) * cap;
1785
1966
  }
@@ -1799,16 +1980,488 @@ function deriveTargetBoundary(fills, currentBoundaryIdx, allSlots, config, gapSl
1799
1980
  remainingBudget,
1800
1981
  };
1801
1982
  }
1983
+ /**
1984
+ * Apply recorded-but-uncommitted fill crawls to the committed boundary.
1985
+ * Fills are recorded at intake (strategy) and consumed by derivation on
1986
+ * commit — but a refused broadcast, a plan abort, or a restart in between
1987
+ * leaves their crawl owed and the boundary stale. The next derivation
1988
+ * incorporates them in-run (pendingCrawls param); this consumes them onto
1989
+ * a restored boundary at startup, before reconcile refills holes.
1990
+ *
1991
+ * Safety: entries are relative deltas, so they apply only onto a FINITE
1992
+ * restored boundary (a null boundary is re-anchored absolutely from live
1993
+ * fill prices instead — subsuming every owed delta). The candidate is
1994
+ * validated placed-order-aware like GRID-LOAD; on failure the entries are
1995
+ * dropped rather than stranding live orders. Commits clear the record, so
1996
+ * entries present here predate every commit since recording — always owed.
1997
+ *
1998
+ * @param {any} manager - OrderManager (boundaryIdx, orders, config restored)
1999
+ * @returns {{applied: boolean, from?: number, to?: number, count?: number, reason?: string}}
2000
+ */
2001
+ export function consumePendingFillCrawls(manager) {
2002
+ const pending = Array.isArray(manager?._pendingFillCrawls) ? manager._pendingFillCrawls : [];
2003
+ if (pending.length === 0)
2004
+ return { applied: false };
2005
+ // Clearing marks the grid dirty so the cleared record reaches disk on
2006
+ // the next flush — including drop paths (unsafe/null/no-op), whose
2007
+ // decisions re-derive identically but whose stale disk entries would
2008
+ // otherwise linger until an unrelated write.
2009
+ const clear = () => {
2010
+ manager._pendingFillCrawls = [];
2011
+ if (typeof manager?._markGridDirty === 'function') {
2012
+ try {
2013
+ manager._markGridDirty();
2014
+ }
2015
+ catch { /* best-effort */ }
2016
+ }
2017
+ };
2018
+ // NB: Number(null) === 0 — check null/undefined explicitly, or a
2019
+ // boundary-less manager would "apply" onto slot 0.
2020
+ if (manager?.boundaryIdx === null || manager?.boundaryIdx === undefined) {
2021
+ clear();
2022
+ return { applied: false, reason: 'null-boundary' };
2023
+ }
2024
+ const boundary = Number(manager?.boundaryIdx);
2025
+ if (!Number.isFinite(boundary)) {
2026
+ clear();
2027
+ return { applied: false, reason: 'null-boundary' };
2028
+ }
2029
+ const config = manager?.config ?? {};
2030
+ const slots = Array.from(manager?.orders instanceof Map ? manager.orders.values() : []);
2031
+ // Classify with the SAME live anchors the strategy derivation uses, or the
2032
+ // two disagree: the config-bound fallback is null for mode-string/relative
2033
+ // bounds, so a restart would rank a stale below-rail slot as a reserve and
2034
+ // silently drop a crawl the live run recorded as ordinary market movement.
2035
+ const reserveBuyIds = reserveEdgeIdSet(slots, config, ORDER_TYPES.BUY, resolveLiveReserveEdgeAnchorPrice(manager, 'buy'));
2036
+ const reserveSellIds = reserveEdgeIdSet(slots, config, ORDER_TYPES.SELL, resolveLiveReserveEdgeAnchorPrice(manager, 'sell'));
2037
+ let netShift = 0;
2038
+ let count = 0;
2039
+ for (const e of pending) {
2040
+ if (!e || typeof e.slotId !== 'string' || e.slotId.length === 0)
2041
+ continue;
2042
+ if (e.side !== ORDER_TYPES.BUY && e.side !== ORDER_TYPES.SELL)
2043
+ continue;
2044
+ if (e.side === ORDER_TYPES.BUY && reserveBuyIds && reserveBuyIds.has(e.slotId))
2045
+ continue;
2046
+ if (e.side === ORDER_TYPES.SELL && reserveSellIds && reserveSellIds.has(e.slotId))
2047
+ continue;
2048
+ netShift += e.side === ORDER_TYPES.SELL ? 1 : -1;
2049
+ count++;
2050
+ }
2051
+ if (count === 0) {
2052
+ clear();
2053
+ return { applied: false, reason: 'nothing-owed' };
2054
+ }
2055
+ const fallbackCap = Math.max(Math.floor((config?.activeOrders?.sell ?? 1) / 2), Math.floor((config?.activeOrders?.buy ?? 1) / 2), 1);
2056
+ if (Math.abs(netShift) > fallbackCap)
2057
+ netShift = Math.sign(netShift) * fallbackCap;
2058
+ let gapSlots = Number(manager?._gapSlots);
2059
+ if (!Number.isFinite(gapSlots)) {
2060
+ try {
2061
+ gapSlots = MathUtils.calculateGapSlots(config?.incrementPercent, config?.targetSpreadPercent, config?.gridLimits);
2062
+ }
2063
+ catch {
2064
+ gapSlots = 0;
2065
+ }
2066
+ }
2067
+ const ceiling = (slots.length - gapSlots - 1) >= 0 ? (slots.length - gapSlots - 1) : (slots.length - 1);
2068
+ const candidate = Math.max(0, Math.min(ceiling, boundary + netShift));
2069
+ if (candidate === boundary) {
2070
+ clear();
2071
+ return { applied: false, reason: 'no-op' };
2072
+ }
2073
+ let check = { ok: true };
2074
+ try {
2075
+ check = MathUtils.validatePersistedBoundary(candidate, slots, gapSlots);
2076
+ }
2077
+ catch (err) {
2078
+ check = { ok: false, reason: 'validator-threw', detail: String(err?.message ?? err) };
2079
+ }
2080
+ if (!check || check.ok !== true) {
2081
+ clear();
2082
+ return { applied: false, reason: `unsafe: ${check?.reason ?? 'unknown'}${check?.detail ? ` ${check.detail}` : ''}` };
2083
+ }
2084
+ try {
2085
+ manager._restoreBoundary(candidate);
2086
+ }
2087
+ catch {
2088
+ return { applied: false, reason: 'restore-failed' };
2089
+ }
2090
+ clear();
2091
+ return { applied: true, from: boundary, to: candidate, count };
2092
+ }
2093
+ /**
2094
+ * Per-side reserve count (edge-pinned fat-finger insurance orders).
2095
+ * Buy reserves pin at the grid floor, sell reserves at the grid ceiling.
2096
+ * Non-finite/non-integer/negative values disable (0).
2097
+ *
2098
+ * @param {Object} config - Bot configuration
2099
+ * @param {string} side - 'buy' or 'sell'
2100
+ * @returns {number} Reserve count for the side (>= 0 integer)
2101
+ */
2102
+ function resolveReserveCount(config, side) {
2103
+ const key = side === 'sell' ? 'sell' : 'buy';
2104
+ const raw = Number(config?.reserveOrders?.[key] ?? 0);
2105
+ if (!Number.isInteger(raw) || raw < 0)
2106
+ return 0;
2107
+ return raw;
2108
+ }
2109
+ /**
2110
+ * Total reserve count across both sides (fee/count totals).
2111
+ *
2112
+ * @param {Object} config - Bot configuration
2113
+ * @returns {number} Total reserves (buy + sell)
2114
+ */
2115
+ function resolveReserveOrders(config) {
2116
+ return resolveReserveCount(config, 'buy') + resolveReserveCount(config, 'sell');
2117
+ }
2118
+ /**
2119
+ * Edge-pinned reserve id set for one side, or null when disabled.
2120
+ * Type/price-filtered, then ordered by the SAME edge order the placement
2121
+ * pickers use (compareReserveEdge via selectReserveEdgeSlots) — single source
2122
+ * of truth, so the no-crawl classification can never drift from placement.
2123
+ * Anchor: explicit live-grid edge when supplied, otherwise the config-bound
2124
+ * fallback (unresolved -> plain rank). Shelf/manual ids (non-slot-N, e.g.
2125
+ * fork-kept deep-* orders below the rail) are never reserves: they would
2126
+ * otherwise win the cheapest-first rank and poison the deficit check while
2127
+ * the shelf is live (issue #27 follow-up). No-op upstream (grids only mint
2128
+ * slot-N).
2129
+ *
2130
+ * @param {Array<Object>} allSlots - All grid slots (need id/price/type)
2131
+ * @param {Object} config - Bot configuration (reserve count source)
2132
+ * @param {string} orderType - ORDER_TYPES.BUY (floor) or SELL (ceiling)
2133
+ * @param {number|null} [anchorPrice] - Explicit edge anchor (live grid edge);
2134
+ * callers that picked slots must pass the SAME anchor so no-crawl
2135
+ * classification matches placement. NB: null/undefined falls back to the
2136
+ * config-bound anchor (unresolved -> plain rank) — unlike
2137
+ * selectReserveEdgeSlots, where null alone means plain rank.
2138
+ * @param {Set<string>|null} [excludeIds] - Windowed ids to skip (same set the
2139
+ * placement pickers exclude). Window + edge are additive in every target
2140
+ * (order counts, fees, hold-back), so a window that reaches the grid edge
2141
+ * (e.g. a keep-low window sitting on the floor) must not let the edge pick
2142
+ * land on window members — otherwise counting reads N/N with zero
2143
+ * dedicated reserves and the deficit never fires (issue #27 follow-up).
2144
+ * @returns {Set<string>|null} Edge slot ids, or null when side disabled
2145
+ */
2146
+ function reserveEdgeIdSet(allSlots, config, orderType, anchorPrice = null, excludeIds = null) {
2147
+ const isSell = orderType === ORDER_TYPES.SELL;
2148
+ const side = isSell ? 'sell' : 'buy';
2149
+ // Filter by the canonical side type, not the caller's token: the previous
2150
+ // per-side resolvers did the same, so a non-canonical token keeps the
2151
+ // floor behavior instead of silently matching nothing.
2152
+ const type = isSell ? ORDER_TYPES.SELL : ORDER_TYPES.BUY;
2153
+ const n = resolveReserveCount(config, side);
2154
+ if (n <= 0)
2155
+ return null;
2156
+ const ids = new Set();
2157
+ if (!Array.isArray(allSlots))
2158
+ return ids;
2159
+ // NB: Number(null) === 0 is finite — null/undefined must mean "no anchor".
2160
+ const anchor = anchorPrice == null ? resolveReserveEdgeAnchorPrice(config, side) : Number(anchorPrice);
2161
+ const ascending = allSlots
2162
+ .filter((s) => s && s.id != null && s.price != null && s.type === type && parseSlotIndex(s.id) !== null)
2163
+ .sort((a, b) => Number(a.price) - Number(b.price));
2164
+ for (const s of selectReserveEdgeSlots(ascending, n, excludeIds, isSell ? 'ceiling' : 'floor', anchor)) {
2165
+ ids.add(s.id);
2166
+ }
2167
+ return ids;
2168
+ }
2169
+ /**
2170
+ * Window member ids for one side, mirroring the window every placement
2171
+ * picker excludes from its reserve pick: in-rail slots of the side (geometry
2172
+ * via resolveGapBand/isSlotInRail — the same source the reconcile pickers
2173
+ * use), ordered closest to market first (buys: highest price first; sells:
2174
+ * lowest first), sliced to the configured activeOrders count. The slice runs
2175
+ * over the FULL master rail, not just live orders — window membership is
2176
+ * geometric (a virtual hole inside the window still blocks the reserve pick
2177
+ * there), so live-only slices would misclassify live reserves as window
2178
+ * members whenever the window itself is under-filled.
2179
+ *
2180
+ * Returns null when the boundary geometry is unknown: the pickers cannot
2181
+ * place reserves without it either, so callers fail open (no exclusion)
2182
+ * and keep their previous classification instead of guessing.
2183
+ *
2184
+ * @param {any} manager - OrderManager (orders Map, config, boundaryIdx)
2185
+ * @param {string} orderType - ORDER_TYPES.BUY or ORDER_TYPES.SELL
2186
+ * @returns {Set<string>|null} Window slot ids, or null when geometry unknown
2187
+ */
2188
+ function liveWindowIdSet(manager, orderType) {
2189
+ try {
2190
+ const isSell = orderType === ORDER_TYPES.SELL;
2191
+ const type = isSell ? ORDER_TYPES.SELL : ORDER_TYPES.BUY;
2192
+ const side = isSell ? 'sell' : 'buy';
2193
+ const count = Math.max(0, Math.floor(Number(manager?.config?.activeOrders?.[side])) || 0);
2194
+ if (!(count > 0) || !manager?.orders || typeof manager.orders.values !== 'function')
2195
+ return new Set();
2196
+ const resolved = MathUtils.resolveGapBand(manager);
2197
+ if (resolved?.boundaryIdx == null || resolved?.sellStartIdx == null)
2198
+ return null;
2199
+ const inRail = (o) => MathUtils.isSlotInRail(resolved.boundaryIdx, resolved.gapSlots, type, o);
2200
+ // Same type filter as the window pickers with known geometry: the
2201
+ // side's concrete type plus SPREAD placeholders (normalized empties
2202
+ // sitting in this side's rail).
2203
+ const typeFilter = (o) => o && o.id != null && o.price != null && (o.type === type || o.type === ORDER_TYPES.SPREAD);
2204
+ const ids = Array.from(manager.orders.values())
2205
+ .filter(typeFilter)
2206
+ .filter(inRail)
2207
+ .sort((a, b) => isSell ? Number(a.price) - Number(b.price) : Number(b.price) - Number(a.price))
2208
+ .slice(0, count)
2209
+ .map((o) => String(o.id));
2210
+ return new Set(ids);
2211
+ }
2212
+ catch {
2213
+ return null;
2214
+ }
2215
+ }
2216
+ /**
2217
+ * Refill-slot wire for the COW boundary hold (single source for both plan
2218
+ * producers: the fill-driven COW engine and the divergence fold).
2219
+ *
2220
+ * The hold keeps the committed boundary when a listed refill is guard-skipped
2221
+ * at broadcast — the refill is what justified the plan's boundary shift, so
2222
+ * committing the shift without it would strand an empty rail slot past the
2223
+ * new boundary. The wire must therefore list only placements that justify the
2224
+ * shift:
2225
+ *
2226
+ * - CREATE ids of the plan (the slots a fold did not convert into an
2227
+ * UPDATE), minus
2228
+ * - reserve-ladder ids. Reserves are static edge insurance; their fills
2229
+ * never crawl (deriveTargetBoundary filters them), so a guard-skipped
2230
+ * reserve must not pin geometry either. Without this exclusion a reserve
2231
+ * CREATE skipped at the wrong moment (e.g. a floor BUY above the last-fill
2232
+ * pivot while the market dumps below the grid) would hold the boundary for
2233
+ * a cycle although nothing was stranded.
2234
+ *
2235
+ * Absent/disabled reserves or an empty action list yield the plain CREATE ids,
2236
+ * so callers that never configured reserves keep the previous behavior.
2237
+ *
2238
+ * @param {Array<Object>} actions - Optimized COW actions
2239
+ * @param {Object} [options]
2240
+ * @param {Object} [options.config] - Bot configuration (reserve count source)
2241
+ * @param {Iterable<Object>} [options.slots] - Master slots (reserve classification)
2242
+ * @param {{buy?: number|null, sell?: number|null}} [options.edgeAnchors] - Live
2243
+ * edge anchors (same pair the strategy classifies reserve fills against)
2244
+ * @returns {string[]} Refill slot ids (CREATE ids minus reserve edge ids)
2245
+ */
2246
+ function collectRefillSlotIds(actions, options = {}) {
2247
+ const { config = null, slots = null, edgeAnchors = null } = options;
2248
+ const out = [];
2249
+ if (!Array.isArray(actions))
2250
+ return out;
2251
+ const createIds = actions
2252
+ .filter((a) => a?.type === COW_ACTIONS.CREATE && typeof a?.id === 'string' && a.id.length > 0)
2253
+ .map((a) => a.id);
2254
+ if (createIds.length === 0)
2255
+ return out;
2256
+ let reserveIds = null;
2257
+ if (slots && config) {
2258
+ try {
2259
+ // Accept a Map (master grid), an array of slots, or any iterable of
2260
+ // slot objects. Map entries are [id, slot] pairs, so `.values()` is
2261
+ // required — Array.from(map) would hand reserveEdgeIdSet pairs.
2262
+ const allSlots = Array.isArray(slots)
2263
+ ? slots
2264
+ : (typeof slots?.values === 'function'
2265
+ ? Array.from(slots.values())
2266
+ : Array.from(slots));
2267
+ const buyIds = reserveEdgeIdSet(allSlots, config, ORDER_TYPES.BUY, edgeAnchors?.buy ?? null);
2268
+ const sellIds = reserveEdgeIdSet(allSlots, config, ORDER_TYPES.SELL, edgeAnchors?.sell ?? null);
2269
+ if (buyIds || sellIds)
2270
+ reserveIds = new Set([...(buyIds ?? []), ...(sellIds ?? [])]);
2271
+ }
2272
+ catch {
2273
+ reserveIds = null;
2274
+ }
2275
+ }
2276
+ for (const id of createIds) {
2277
+ // Fail-open on classification errors: an id we cannot prove is a reserve
2278
+ // stays in the wire, so the hold keeps its previous (conservative) reach.
2279
+ if (reserveIds && reserveIds.has(id))
2280
+ continue;
2281
+ out.push(id);
2282
+ }
2283
+ return out;
2284
+ }
2285
+ /**
2286
+ * Resolved bound anchor for reserve edges from CONFIG alone.
2287
+ * BUY floor anchors toward minPrice (dip-insurance end), SELL ceiling toward
2288
+ * maxPrice (spike-insurance end). Resolves numeric and "Nx" relative forms
2289
+ * via resolveConfiguredPriceBound (startPrice-referenced); falls back to the
2290
+ * raw numeric bound. Returns null when unresolvable.
2291
+ *
2292
+ * Known limit: this is the statically resolved config bound, not the
2293
+ * gridPrice/AMA-referenced live rail bound. Placement call sites should use
2294
+ * resolveLiveReserveEdgeAnchorPrice(manager, side), which prefers the live
2295
+ * grid geometry and only falls back to this function.
2296
+ *
2297
+ * @param {Object} config - Bot configuration
2298
+ * @param {string} side - 'buy' or 'sell'
2299
+ * @returns {number|null} Finite anchor price, or null
2300
+ */
2301
+ function resolveReserveEdgeAnchorPrice(config, side) {
2302
+ const isSell = side === 'sell';
2303
+ const bound = isSell ? config?.maxPrice : config?.minPrice;
2304
+ const mode = isSell ? 'max' : 'min';
2305
+ try {
2306
+ const anchor = resolveConfiguredPriceBound(bound, Number.NaN, Number(config?.startPrice), mode);
2307
+ if (Number.isFinite(anchor))
2308
+ return anchor;
2309
+ }
2310
+ catch (e) { /* fall through to raw bound */ }
2311
+ const raw = Number(bound);
2312
+ return Number.isFinite(raw) ? raw : null;
2313
+ }
2314
+ /**
2315
+ * Live-grid reserve edge anchor (single source for edge placement).
2316
+ *
2317
+ * The anchor must come from the geometry the bot is actually trading, never
2318
+ * from a config value that can be a mode string ("pool"/"book"), a relative
2319
+ * multiplier, or a stale bound. Tiers, strongest first:
2320
+ *
2321
+ * 1. Genesis ladder extreme — `_genesis.priceLevels` is the exact ladder the
2322
+ * loaded grid was built from: sorted ascending, index-aligned with
2323
+ * `slot-<idx>` (assertSlotPriceInvariant), refreshed by initializeGrid,
2324
+ * persisted with the grid, and unaffected by the raw-profile re-merge a
2325
+ * resync performs. Slot 0 is always on the buy rail and the last level
2326
+ * always on the sell rail, so the ladder extremes are the live rail
2327
+ * bounds.
2328
+ * 2. Live in-rail extreme of the master grid — geometry-only rail
2329
+ * membership (resolveGapBand + isSlotInRail, the same predicate the
2330
+ * selectors use) for snapshots without a genesis.
2331
+ * 3. Config bound (resolveReserveEdgeAnchorPrice) — the previous behavior,
2332
+ * kept as the last resolved tier.
2333
+ * 4. null — callers keep the legacy rank-based selection.
2334
+ *
2335
+ * @param {Object} manager - OrderManager (needs _genesis, orders, boundary)
2336
+ * @param {string} side - 'buy' or 'sell'
2337
+ * @returns {number|null} Finite anchor price, or null
2338
+ */
2339
+ function resolveLiveReserveEdgeAnchorPrice(manager, side) {
2340
+ const isSell = side === 'sell';
2341
+ // Tier 1 — the ladder the loaded grid was generated from.
2342
+ const levels = manager?._genesis?.priceLevels;
2343
+ if (Array.isArray(levels) && levels.length > 0) {
2344
+ const extreme = Number(isSell ? levels[levels.length - 1] : levels[0]);
2345
+ if (Number.isFinite(extreme) && extreme > 0)
2346
+ return extreme;
2347
+ }
2348
+ // Tier 2 — live in-rail extreme of the master grid.
2349
+ const sideType = isSell ? ORDER_TYPES.SELL : ORDER_TYPES.BUY;
2350
+ if (manager?.orders && typeof manager.orders.values === 'function') {
2351
+ let best = null;
2352
+ try {
2353
+ const band = MathUtils.resolveGapBand(manager);
2354
+ for (const entry of manager.orders.values()) {
2355
+ if (!entry || typeof entry !== 'object')
2356
+ continue;
2357
+ if (!('type' in entry) || !('price' in entry))
2358
+ continue;
2359
+ if (entry.type !== sideType)
2360
+ continue;
2361
+ // Shelf/manual ids (e.g. fork-kept deep-* orders below the rail)
2362
+ // are never rail geometry: isSlotInRail is fail-open for
2363
+ // unparseable ids, so without this gate a cheap shelf order drags
2364
+ // the anchor down to itself and then qualifies as the reserve edge
2365
+ // (issue #27 follow-up). No-op upstream (grids only mint slot-N).
2366
+ if (parseSlotIndex(entry?.id) === null)
2367
+ continue;
2368
+ if (!MathUtils.isSlotInRail(band.boundaryIdx, band.gapSlots, sideType, entry))
2369
+ continue;
2370
+ const price = Number(entry.price);
2371
+ if (!Number.isFinite(price) || price <= 0)
2372
+ continue;
2373
+ if (best === null || (isSell ? price > best : price < best))
2374
+ best = price;
2375
+ }
2376
+ }
2377
+ catch (e) {
2378
+ best = null;
2379
+ }
2380
+ if (best !== null)
2381
+ return best;
2382
+ }
2383
+ // Tier 3 — configured/resolved bound.
2384
+ return resolveReserveEdgeAnchorPrice(manager?.config, side);
2385
+ }
2386
+ /**
2387
+ * Shared anchored edge comparator for reserve selection (single source).
2388
+ * With a finite anchor: in-bound slots first, nearest the anchor first
2389
+ * (floor ascending, ceiling descending); stale out-of-bound slots last,
2390
+ * still nearest the anchor first. Without one: plain rank fallback
2391
+ * (floor rank-lowest, ceiling rank-highest).
2392
+ *
2393
+ * @param {Object} a - Slot/order (needs price)
2394
+ * @param {Object} b - Slot/order (needs price)
2395
+ * @param {string} edge - 'floor' or 'ceiling'
2396
+ * @param {number|null} anchorPrice - Resolved bound anchor (null = rank fallback)
2397
+ * @returns {number} Comparator result for Array.prototype.sort
2398
+ */
2399
+ function compareReserveEdge(a, b, edge, anchorPrice) {
2400
+ const ceil = edge === 'ceiling';
2401
+ // NB: Number(null) === 0 is finite — null/undefined must mean "no anchor".
2402
+ const anchor = anchorPrice == null ? Number.NaN : Number(anchorPrice);
2403
+ if (!Number.isFinite(anchor)) {
2404
+ return ceil ? Number(b.price) - Number(a.price) : Number(a.price) - Number(b.price);
2405
+ }
2406
+ const pa = Number(a?.price);
2407
+ const pb = Number(b?.price);
2408
+ const aIn = ceil ? pa <= anchor : pa >= anchor;
2409
+ const bIn = ceil ? pb <= anchor : pb >= anchor;
2410
+ if (aIn !== bIn)
2411
+ return aIn ? -1 : 1;
2412
+ if (aIn)
2413
+ return ceil ? pb - pa : pa - pb;
2414
+ return ceil ? pa - pb : pb - pa;
2415
+ }
2416
+ /**
2417
+ * Central edge selector: take reserve slots from a price-ascending list,
2418
+ * skipping already-windowed ids. Both edges anchor at the live grid's own
2419
+ * edge (resolveLiveReserveEdgeAnchorPrice) when finite — floor: nearest
2420
+ * at/above the live floor first, slots below it rank last; ceiling: nearest
2421
+ * at/below the live ceiling first, slots above it rank last. Anchoring to the
2422
+ * live edge keeps the reserve on genuine live-rail slots when the grid still
2423
+ * carries leftovers from an older bound or the configured bound disagrees
2424
+ * with the geometry being traded. Callers pre-filter rail/type and apply
2425
+ * their own size gates; this only picks positions. A null anchor degrades to
2426
+ * plain rank (floor: lowest first; ceiling: highest first).
2427
+ *
2428
+ * @param {Array<Object>} sortedAsc - Slots sorted by price ascending
2429
+ * @param {number} count - Reserve count
2430
+ * @param {Set<string>|null} excludeIds - Windowed ids to skip
2431
+ * @param {string} edge - 'floor' or 'ceiling'
2432
+ * @param {number|null} [anchorPrice] - Live edge anchor for the side (null = rank-based)
2433
+ * @returns {Array<Object>} Reserve slots (ascending for floor, descending for ceiling)
2434
+ */
2435
+ function selectReserveEdgeSlots(sortedAsc, count, excludeIds, edge, anchorPrice = null) {
2436
+ const n = Math.max(0, Math.floor(Number(count) || 0));
2437
+ if (n <= 0 || !Array.isArray(sortedAsc))
2438
+ return [];
2439
+ const avail = sortedAsc.filter((s) => s && s.id != null && (!excludeIds || !excludeIds.has(s.id)));
2440
+ // NB: Number(null) === 0 is finite — null/undefined must mean "no anchor".
2441
+ const anchor = anchorPrice == null ? Number.NaN : Number(anchorPrice);
2442
+ if (!Number.isFinite(anchor)) {
2443
+ // No anchor: plain rank fallback (avail arrives ascending) —
2444
+ // floor rank-lowest, ceiling rank-highest.
2445
+ return edge === 'ceiling' ? avail.slice(-n).reverse() : avail.slice(0, n);
2446
+ }
2447
+ // Anchored: shared comparator — in-bound slots nearest the bound first,
2448
+ // stale out-of-bound slots last.
2449
+ return avail
2450
+ .sort((x, y) => compareReserveEdge(x, y, edge, anchor))
2451
+ .slice(0, n);
2452
+ }
1802
2453
  /**
1803
2454
  * Total target order count across both sides (used for BTS fee calculation).
1804
2455
  * Single source of truth so every budget derivation sizes identically.
2456
+ * Includes per-side reserves: they rest live on-chain and pay creation fees.
1805
2457
  *
1806
2458
  * @param {Object} config - Bot configuration
1807
2459
  * @returns {number} Total target order count
1808
2460
  */
1809
2461
  function getActiveOrdersTotal(config) {
1810
2462
  return Math.max(0, config?.activeOrders?.buy ?? 1) +
1811
- Math.max(0, config?.activeOrders?.sell ?? 1);
2463
+ Math.max(0, config?.activeOrders?.sell ?? 1) +
2464
+ resolveReserveOrders(config);
1812
2465
  }
1813
2466
  /**
1814
2467
  * Calculate side budget after BTS fee deduction.
@@ -2035,7 +2688,7 @@ function collectKnownOnChainOrderIds(mgr, placedResults, placedContexts, extraCr
2035
2688
  }
2036
2689
  }
2037
2690
  // Existing chain ids referenced by non-create op contexts (cancel /
2038
- // rotation / size-update). Pre-existing orders whose absence is expected
2691
+ // rotation / size-update) are already live: they belong to the master set
2039
2692
  // (cancels/fills in this batch), so they join the by-id set but never
2040
2693
  // the lagging-create guard.
2041
2694
  if (Array.isArray(placedContexts)) {
@@ -2058,6 +2711,6 @@ function collectKnownOnChainOrderIds(mgr, placedResults, placedContexts, extraCr
2058
2711
  const all = new Set([...masterIds, ...createIds]);
2059
2712
  return { masterIds: [...masterIds], createIds: [...createIds], all: [...all] };
2060
2713
  }
2061
- // ================================================================================
2062
- export { parseChainOrder, findMatchingGridOrderByOpenOrder, applyChainSizeToGridOrder, buildFillKey, correctOrderPriceOnChain, correctAllPriceMismatches, buildCreateOrderArgs, getOrderTypeFromUpdatedFlags, resolveConfiguredPriceBound, virtualizeOrder, convertToSpreadPlaceholder, toRailHolePlaceholder, geometryTypeForSlotIndex, detectGapEvacuationCandidates, updateGapEvacuationStreaks, resolveSpreadOrderSide, chainOrderMatchesSlot, chainOrderMatchesSlotWithTolerance, crossingCandidateChainId, isCrossingCheckCandidate, buildCrossingCheckCandidates, parseSlotIndex, filterOrdersByType, buildOutsideInPairGroups, extractBatchOperationResults, formatUnmatchedChainOrder, isOrderOnChain, isOrderVirtual, hasOnChainId, isOrderPlaced, isPhantomOrder, isSlotAvailable, isEmptyGridSlot, isOrderHealthy, checkSizeThreshold, checkSizesBeforeMinimum, calculateIdealBoundary, assignGridRoles, resolveOnChainRetypeType, shouldFlagOutOfSpread, buildIndexes, validateIndexes, ordersEqual, buildDelta, getOrderSize, deriveTargetBoundary, isShiftEligibleFill, getActiveOrdersTotal, getSideBudget, calculateBudgetedSizes, buildCreateOpFingerprint, isOrderGoneErrorMessage, recordDuplicateOrphanDetection, clearDuplicateOrphanDetection, duplicateOrphanLogInfo, chainOrderUnchangedFromCache, detectCrossedBookPlan, collectKnownOnChainOrderIds };
2714
+ export { parseChainOrder, findMatchingGridOrderByOpenOrder, applyChainSizeToGridOrder, buildFillKey, correctOrderPriceOnChain, correctAllPriceMismatches, buildCreateOrderArgs, getOrderTypeFromUpdatedFlags, resolveConfiguredPriceBound, virtualizeOrder, convertToSpreadPlaceholder, toRailHolePlaceholder, geometryTypeForSlotIndex, detectGapEvacuationCandidates, updateGapEvacuationStreaks, resolveSpreadOrderSide, chainOrderMatchesSlot, chainOrderMatchesSlotWithTolerance, crossingCandidateChainId, isCrossingCheckCandidate, buildCrossingCheckCandidates, parseSlotIndex, filterOrdersByType, buildOutsideInPairGroups, extractBatchOperationResults, formatUnmatchedChainOrder, isNonBlockingUnmatchedOrder, isOrderOnChain, isOrderVirtual, hasOnChainId, isOrderPlaced, isPhantomOrder, isSlotAvailable, isEmptyGridSlot, isOrderHealthy, checkSizeThreshold, checkSizesBeforeMinimum, calculateIdealBoundary, assignGridRoles, resolveOnChainRetypeType, shouldFlagOutOfSpread, buildIndexes, validateIndexes, ordersEqual, buildDelta, deriveTargetBoundary, isShiftEligibleFill, resolveReserveCount, resolveReserveOrders, selectReserveEdgeSlots, getActiveOrdersTotal, getSideBudget, calculateBudgetedSizes, buildCreateOpFingerprint, isOrderGoneErrorMessage, recordDuplicateOrphanDetection, clearDuplicateOrphanDetection, duplicateOrphanLogInfo, chainOrderUnchangedFromCache, detectCrossedBookPlan, collectKnownOnChainOrderIds, reserveEdgeIdSet, liveWindowIdSet };
2715
+ export { resolveReserveEdgeAnchorPrice, resolveLiveReserveEdgeAnchorPrice, compareReserveEdge, collectRefillSlotIds };
2063
2716
  //# sourceMappingURL=order.js.map