dexbot 1.5.3 → 1.6.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.
Files changed (231) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +4 -3
  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 +3 -3
  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 +13 -0
  110. package/dist/modules/constants.d.ts.map +1 -1
  111. package/dist/modules/constants.js +68 -4
  112. package/dist/modules/constants.js.map +1 -1
  113. package/dist/modules/dexbot_class.d.ts +8 -0
  114. package/dist/modules/dexbot_class.d.ts.map +1 -1
  115. package/dist/modules/dexbot_class.js +57 -2
  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 +41 -1
  122. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  123. package/dist/modules/dexbot_fill_runtime.js +135 -5
  124. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  125. package/dist/modules/dexbot_maintenance_runtime.d.ts +79 -12
  126. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  127. package/dist/modules/dexbot_maintenance_runtime.js +554 -46
  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 +36 -7
  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/node_connect_policy.d.ts +52 -0
  139. package/dist/modules/node_connect_policy.d.ts.map +1 -0
  140. package/dist/modules/node_connect_policy.js +35 -0
  141. package/dist/modules/node_connect_policy.js.map +1 -0
  142. package/dist/modules/node_manager.d.ts +32 -0
  143. package/dist/modules/node_manager.d.ts.map +1 -1
  144. package/dist/modules/node_manager.js +76 -9
  145. package/dist/modules/node_manager.js.map +1 -1
  146. package/dist/modules/order/accounting.d.ts +30 -0
  147. package/dist/modules/order/accounting.d.ts.map +1 -1
  148. package/dist/modules/order/accounting.js +176 -3
  149. package/dist/modules/order/accounting.js.map +1 -1
  150. package/dist/modules/order/export.d.ts.map +1 -1
  151. package/dist/modules/order/export.js +1 -0
  152. package/dist/modules/order/export.js.map +1 -1
  153. package/dist/modules/order/grid.d.ts.map +1 -1
  154. package/dist/modules/order/grid.js +143 -15
  155. package/dist/modules/order/grid.js.map +1 -1
  156. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  157. package/dist/modules/order/grid_reconcile.js +5 -3
  158. package/dist/modules/order/grid_reconcile.js.map +1 -1
  159. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  160. package/dist/modules/order/grid_reconcile_internal.js +182 -6
  161. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  162. package/dist/modules/order/manager.d.ts +64 -2
  163. package/dist/modules/order/manager.d.ts.map +1 -1
  164. package/dist/modules/order/manager.js +231 -14
  165. package/dist/modules/order/manager.js.map +1 -1
  166. package/dist/modules/order/strategy.d.ts +3 -0
  167. package/dist/modules/order/strategy.d.ts.map +1 -1
  168. package/dist/modules/order/strategy.js +79 -7
  169. package/dist/modules/order/strategy.js.map +1 -1
  170. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  171. package/dist/modules/order/sync_engine.js +16 -1
  172. package/dist/modules/order/sync_engine.js.map +1 -1
  173. package/dist/modules/order/utils/math.d.ts +11 -1
  174. package/dist/modules/order/utils/math.d.ts.map +1 -1
  175. package/dist/modules/order/utils/math.js +24 -1
  176. package/dist/modules/order/utils/math.js.map +1 -1
  177. package/dist/modules/order/utils/order.d.ts +258 -13
  178. package/dist/modules/order/utils/order.d.ts.map +1 -1
  179. package/dist/modules/order/utils/order.js +662 -12
  180. package/dist/modules/order/utils/order.js.map +1 -1
  181. package/dist/modules/order/utils/system.d.ts +36 -0
  182. package/dist/modules/order/utils/system.d.ts.map +1 -1
  183. package/dist/modules/order/utils/system.js +102 -6
  184. package/dist/modules/order/utils/system.js.map +1 -1
  185. package/dist/modules/order/utils/validate.d.ts.map +1 -1
  186. package/dist/modules/order/utils/validate.js +6 -2
  187. package/dist/modules/order/utils/validate.js.map +1 -1
  188. package/dist/modules/paths.d.ts +2 -0
  189. package/dist/modules/paths.d.ts.map +1 -1
  190. package/dist/modules/paths.js +1 -0
  191. package/dist/modules/paths.js.map +1 -1
  192. package/dist/modules/runtime_settings.d.ts +13 -0
  193. package/dist/modules/runtime_settings.d.ts.map +1 -1
  194. package/dist/modules/runtime_settings.js +20 -0
  195. package/dist/modules/runtime_settings.js.map +1 -1
  196. package/dist/modules/utils/errors.d.ts +16 -0
  197. package/dist/modules/utils/errors.d.ts.map +1 -1
  198. package/dist/modules/utils/errors.js +31 -0
  199. package/dist/modules/utils/errors.js.map +1 -1
  200. package/dist/pm2.js +7 -7
  201. package/dist/scripts/analyze-credit.d.ts.map +1 -1
  202. package/dist/scripts/analyze-credit.js +13 -3
  203. package/dist/scripts/analyze-credit.js.map +1 -1
  204. package/dist/scripts/analyze-orders.js +2 -2
  205. package/dist/scripts/analyze-orders.js.map +1 -1
  206. package/dist/scripts/diagnose-kibana-candles.d.ts.map +1 -1
  207. package/dist/scripts/diagnose-kibana-candles.js +0 -1
  208. package/dist/scripts/diagnose-kibana-candles.js.map +1 -1
  209. package/dist/scripts/test-credit-renewal.js +1 -1
  210. package/dist/scripts/test-credit-renewal.js.map +1 -1
  211. package/dist/scripts/tv.d.ts +24 -2
  212. package/dist/scripts/tv.d.ts.map +1 -1
  213. package/dist/scripts/tv.js +190 -65
  214. package/dist/scripts/tv.js.map +1 -1
  215. package/docs/BITSHARES_ONBOARDING.md +9 -2
  216. package/docs/COW_INVARIANTS.md +18 -1
  217. package/docs/DEXBOT_COMPARISON.md +3 -3
  218. package/docs/EVOLUTION.md +8 -7
  219. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +21 -9
  220. package/docs/GRID_RECALCULATION.md +5 -6
  221. package/docs/GRID_RECONCILE.md +12 -2
  222. package/docs/MPA_CREDIT_USAGE.md +2 -2
  223. package/docs/README.md +5 -6
  224. package/docs/architecture.md +0 -58
  225. package/docs/developer_guide.md +2 -1
  226. package/docs/docker.md +1 -1
  227. package/market_adapter/README.md +4 -0
  228. package/modules/README.md +1 -1
  229. package/package.json +1 -1
  230. package/scripts/README.md +5 -3
  231. package/tests/README.md +2 -2
@@ -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;
@@ -1755,23 +1790,156 @@ function buildDelta(masterGrid, workingGrid, options = {}) {
1755
1790
  function isShiftEligibleFill(fill) {
1756
1791
  return fill?.isPartial !== true || fill?.isDelayedRotationTrigger === true;
1757
1792
  }
1758
- function deriveTargetBoundary(fills, currentBoundaryIdx, allSlots, config, gapSlots, crossChunkBudget) {
1793
+ function deriveTargetBoundary(fills, currentBoundaryIdx, allSlots, config, gapSlots, crossChunkBudget, pendingCrawls, edgeAnchors) {
1759
1794
  let newBoundaryIdx = currentBoundaryIdx;
1760
- // Initial recovery if boundary is undefined
1795
+ // Recovery when the committed boundary is unknown (GRID-LOAD rejected a
1796
+ // poisoned snapshot, re-derivation failed, and no fill has re-anchored
1797
+ // since). Anchor tiers are position signals, weakest last. What must
1798
+ // never happen is fabricating a rail-edge boundary from an unresolved
1799
+ // config mode string: startPrice "pool" NaN-matches every price
1800
+ // comparison, resolving to the rail top (Sep-10: base 213, ceiling 211,
1801
+ // then 209 after 4 buy crawls — teleporting the buy rail 113 slots).
1802
+ let recovered = false;
1803
+ let anchoredFromFills = false;
1761
1804
  if (newBoundaryIdx === undefined || newBoundaryIdx === null) {
1762
- const referencePrice = config.startPrice;
1805
+ // Tier 1 — live fills: gap-side extreme (highest buy / lowest sell,
1806
+ // midpoint when both sides filled). Any fill price, eligible or
1807
+ // dust, is real market position and beats every config guess.
1808
+ let topBuy = -Infinity;
1809
+ let botSell = Infinity;
1810
+ for (const fill of fills ?? []) {
1811
+ const p = Number(fill?.price);
1812
+ if (!Number.isFinite(p))
1813
+ continue;
1814
+ if (fill?.type === ORDER_TYPES.BUY && p > topBuy)
1815
+ topBuy = p;
1816
+ if (fill?.type === ORDER_TYPES.SELL && p < botSell)
1817
+ botSell = p;
1818
+ }
1819
+ let referencePrice = null;
1820
+ if (topBuy > -Infinity && botSell < Infinity) {
1821
+ referencePrice = (topBuy + botSell) / 2;
1822
+ anchoredFromFills = true;
1823
+ }
1824
+ else if (topBuy > -Infinity) {
1825
+ referencePrice = topBuy;
1826
+ anchoredFromFills = true;
1827
+ }
1828
+ else if (botSell < Infinity) {
1829
+ referencePrice = botSell;
1830
+ anchoredFromFills = true;
1831
+ }
1832
+ // Tier 2 — explicit numeric config center.
1833
+ if (referencePrice === null) {
1834
+ const direct = Number(config?.startPrice);
1835
+ if (Number.isFinite(direct))
1836
+ referencePrice = direct;
1837
+ }
1838
+ // Tier 3 — frozen genesis center (forwarded by the strategy when
1839
+ // config.startPrice is an unresolved mode string).
1840
+ if (referencePrice === null) {
1841
+ const genesis = Number(config?.genesisStartPrice);
1842
+ if (Number.isFinite(genesis))
1843
+ referencePrice = genesis;
1844
+ }
1845
+ // Stale-center guard: a Tier-2 numeric config center or a Tier-3
1846
+ // genesis center that falls outside the live rail would clamp
1847
+ // calculateIdealBoundary onto an edge slot — the same rail-edge
1848
+ // fabrication the mode-string fix prevents, just from a stale numeric
1849
+ // value. Drop such a reference so the bounded Tier-4 rail center is
1850
+ // used instead. Tier-1 fill anchors are exempt: a real (possibly
1851
+ // out-of-grid) fill price is live market position and wins everywhere.
1852
+ if (!anchoredFromFills && referencePrice !== null && Array.isArray(allSlots) && allSlots.length > 0) {
1853
+ let railMin = Infinity;
1854
+ let railMax = -Infinity;
1855
+ for (const s of allSlots) {
1856
+ const p = Number(s?.price);
1857
+ if (!Number.isFinite(p))
1858
+ continue;
1859
+ if (p < railMin)
1860
+ railMin = p;
1861
+ if (p > railMax)
1862
+ railMax = p;
1863
+ }
1864
+ if ((Number.isFinite(railMin) && referencePrice < railMin)
1865
+ || (Number.isFinite(railMax) && referencePrice > railMax)) {
1866
+ orderLogger.debug(`deriveTargetBoundary: recovery center ${referencePrice} outside live rail ` +
1867
+ `[${railMin}, ${railMax}]; using bounded rail center instead of pinning an edge`);
1868
+ referencePrice = null;
1869
+ }
1870
+ }
1871
+ // Tier 4 — rail center: bounded and wrong by at most half the rail,
1872
+ // never a rail-edge fabrication. The next fill batch re-anchors
1873
+ // from live prices via Tier 1.
1874
+ if (referencePrice === null && Array.isArray(allSlots) && allSlots.length > 0) {
1875
+ const gap = Number.isFinite(Number(gapSlots)) && Number(gapSlots) >= 0 ? Math.floor(Number(gapSlots)) : 0;
1876
+ const centerIdx = Math.max(0, Math.floor((allSlots.length - 1 - gap) / 2));
1877
+ const centerPrice = Number(allSlots[centerIdx]?.price);
1878
+ if (Number.isFinite(centerPrice))
1879
+ referencePrice = centerPrice;
1880
+ }
1881
+ if (referencePrice === null) {
1882
+ const fallbackCap = Math.max(Math.floor((config?.activeOrders?.sell ?? 1) / 2), Math.floor((config?.activeOrders?.buy ?? 1) / 2), 1);
1883
+ const effectiveBudget = crossChunkBudget ?? fallbackCap;
1884
+ return { boundaryIdx: null, remainingBudget: effectiveBudget };
1885
+ }
1763
1886
  newBoundaryIdx = calculateIdealBoundary(allSlots, referencePrice, gapSlots);
1887
+ if (!Number.isFinite(newBoundaryIdx) || newBoundaryIdx < 0) {
1888
+ // Empty slot list with a Tier 1-3 reference: no honest index
1889
+ // exists (calculateIdealBoundary returns -1). Stay null rather
1890
+ // than letting the clamp below fabricate a slot-0 boundary.
1891
+ const fallbackCap = Math.max(Math.floor((config?.activeOrders?.sell ?? 1) / 2), Math.floor((config?.activeOrders?.buy ?? 1) / 2), 1);
1892
+ return { boundaryIdx: null, remainingBudget: crossChunkBudget ?? fallbackCap };
1893
+ }
1894
+ recovered = true;
1764
1895
  }
1765
- // Apply shift from fills with rate-limiting
1896
+ // Apply shift from fills with rate-limiting (reserve fills excluded: static insurance).
1766
1897
  let netShift = 0;
1898
+ // Reserve ladder: fills from edge-pinned reserve slots never crawl the
1899
+ // boundary — they are static fat-finger insurance, not market movement.
1900
+ // `edgeAnchors` (resolveLiveReserveEdgeAnchorPrice) is the same anchor the
1901
+ // placement sites use; without it the classification falls back to the
1902
+ // config-bound anchor, which can disagree with the slots actually placed.
1903
+ const reserveBuyIds = reserveEdgeIdSet(allSlots, config, ORDER_TYPES.BUY, edgeAnchors?.buy ?? null);
1904
+ const reserveSellIds = reserveEdgeIdSet(allSlots, config, ORDER_TYPES.SELL, edgeAnchors?.sell ?? null);
1905
+ // Pending crawls: fills recorded by earlier batches whose derivation
1906
+ // never committed (refused broadcast, P4 abort, or pre-restart loss —
1907
+ // the Sep-10 case: 4 fills consumed under a null boundary, crawl lost,
1908
+ // restart refilled the holes same-side). Entries for slots in the
1909
+ // CURRENT batch are excluded — those fills crawl below as usual;
1910
+ // anything older is still owed and shifts here. Reserve-slot entries
1911
+ // never crawl (static insurance), same as live fills.
1912
+ const currentSlotIds = new Set((fills ?? []).map((f) => f?.id).filter((id) => typeof id === 'string' && id.length > 0));
1913
+ const owedPending = (pendingCrawls ?? []).filter((e) => e
1914
+ && typeof e.slotId === 'string' && e.slotId.length > 0
1915
+ && !currentSlotIds.has(e.slotId)
1916
+ && (e.side === ORDER_TYPES.BUY || e.side === ORDER_TYPES.SELL)
1917
+ && !(e.side === ORDER_TYPES.BUY && reserveBuyIds && reserveBuyIds.has(e.slotId))
1918
+ && !(e.side === ORDER_TYPES.SELL && reserveSellIds && reserveSellIds.has(e.slotId)));
1767
1919
  for (const fill of fills) {
1768
1920
  if (!isShiftEligibleFill(fill))
1769
1921
  continue;
1922
+ if (fill && fill.type === ORDER_TYPES.BUY && reserveBuyIds && reserveBuyIds.has(fill.id))
1923
+ continue;
1924
+ if (fill && fill.type === ORDER_TYPES.SELL && reserveSellIds && reserveSellIds.has(fill.id))
1925
+ continue;
1770
1926
  if (fill.type === ORDER_TYPES.SELL)
1771
1927
  netShift++;
1772
1928
  else if (fill.type === ORDER_TYPES.BUY)
1773
1929
  netShift--;
1774
1930
  }
1931
+ if (!anchoredFromFills) {
1932
+ // Owed deltas from earlier uncommitted batches shift on top of the
1933
+ // current fills. Skipped under an absolute fill anchor: the anchor
1934
+ // positions from live market prices, which already reflect all
1935
+ // consumed fills — shifting again would double-count.
1936
+ for (const e of owedPending) {
1937
+ if (e.side === ORDER_TYPES.SELL)
1938
+ netShift++;
1939
+ else
1940
+ netShift--;
1941
+ }
1942
+ }
1775
1943
  // Cap cumulative shift to prevent overreaction from burst fills.
1776
1944
  // Uses a cross-chunk budget managed by the caller — each chunk
1777
1945
  // consumes from the same pool so the total across all chunks
@@ -1780,6 +1948,16 @@ function deriveTargetBoundary(fills, currentBoundaryIdx, allSlots, config, gapSl
1780
1948
  const fallbackCap = Math.max(Math.floor((config.activeOrders?.sell ?? 1) / 2), Math.floor((config.activeOrders?.buy ?? 1) / 2), 1);
1781
1949
  const effectiveBudget = crossChunkBudget ?? fallbackCap;
1782
1950
  const cap = Math.min(Math.abs(effectiveBudget), fallbackCap);
1951
+ if (recovered && anchoredFromFills) {
1952
+ // The anchor already contains this batch's fill information —
1953
+ // crawling would double-count the same fills (Sep-10 batch 1:
1954
+ // dust-sell anchor 97 plus a +1 crawl would have moved it to 98).
1955
+ // The next batch crawls normally from the anchored boundary.
1956
+ return {
1957
+ boundaryIdx: Math.max(0, Math.min((allSlots.length - gapSlots - 1) >= 0 ? (allSlots.length - gapSlots - 1) : (allSlots.length - 1), newBoundaryIdx)),
1958
+ remainingBudget: effectiveBudget,
1959
+ };
1960
+ }
1783
1961
  if (Math.abs(netShift) > cap) {
1784
1962
  netShift = Math.sign(netShift) * cap;
1785
1963
  }
@@ -1799,16 +1977,488 @@ function deriveTargetBoundary(fills, currentBoundaryIdx, allSlots, config, gapSl
1799
1977
  remainingBudget,
1800
1978
  };
1801
1979
  }
1980
+ /**
1981
+ * Apply recorded-but-uncommitted fill crawls to the committed boundary.
1982
+ * Fills are recorded at intake (strategy) and consumed by derivation on
1983
+ * commit — but a refused broadcast, a plan abort, or a restart in between
1984
+ * leaves their crawl owed and the boundary stale. The next derivation
1985
+ * incorporates them in-run (pendingCrawls param); this consumes them onto
1986
+ * a restored boundary at startup, before reconcile refills holes.
1987
+ *
1988
+ * Safety: entries are relative deltas, so they apply only onto a FINITE
1989
+ * restored boundary (a null boundary is re-anchored absolutely from live
1990
+ * fill prices instead — subsuming every owed delta). The candidate is
1991
+ * validated placed-order-aware like GRID-LOAD; on failure the entries are
1992
+ * dropped rather than stranding live orders. Commits clear the record, so
1993
+ * entries present here predate every commit since recording — always owed.
1994
+ *
1995
+ * @param {any} manager - OrderManager (boundaryIdx, orders, config restored)
1996
+ * @returns {{applied: boolean, from?: number, to?: number, count?: number, reason?: string}}
1997
+ */
1998
+ export function consumePendingFillCrawls(manager) {
1999
+ const pending = Array.isArray(manager?._pendingFillCrawls) ? manager._pendingFillCrawls : [];
2000
+ if (pending.length === 0)
2001
+ return { applied: false };
2002
+ // Clearing marks the grid dirty so the cleared record reaches disk on
2003
+ // the next flush — including drop paths (unsafe/null/no-op), whose
2004
+ // decisions re-derive identically but whose stale disk entries would
2005
+ // otherwise linger until an unrelated write.
2006
+ const clear = () => {
2007
+ manager._pendingFillCrawls = [];
2008
+ if (typeof manager?._markGridDirty === 'function') {
2009
+ try {
2010
+ manager._markGridDirty();
2011
+ }
2012
+ catch { /* best-effort */ }
2013
+ }
2014
+ };
2015
+ // NB: Number(null) === 0 — check null/undefined explicitly, or a
2016
+ // boundary-less manager would "apply" onto slot 0.
2017
+ if (manager?.boundaryIdx === null || manager?.boundaryIdx === undefined) {
2018
+ clear();
2019
+ return { applied: false, reason: 'null-boundary' };
2020
+ }
2021
+ const boundary = Number(manager?.boundaryIdx);
2022
+ if (!Number.isFinite(boundary)) {
2023
+ clear();
2024
+ return { applied: false, reason: 'null-boundary' };
2025
+ }
2026
+ const config = manager?.config ?? {};
2027
+ const slots = Array.from(manager?.orders instanceof Map ? manager.orders.values() : []);
2028
+ // Classify with the SAME live anchors the strategy derivation uses, or the
2029
+ // two disagree: the config-bound fallback is null for mode-string/relative
2030
+ // bounds, so a restart would rank a stale below-rail slot as a reserve and
2031
+ // silently drop a crawl the live run recorded as ordinary market movement.
2032
+ const reserveBuyIds = reserveEdgeIdSet(slots, config, ORDER_TYPES.BUY, resolveLiveReserveEdgeAnchorPrice(manager, 'buy'));
2033
+ const reserveSellIds = reserveEdgeIdSet(slots, config, ORDER_TYPES.SELL, resolveLiveReserveEdgeAnchorPrice(manager, 'sell'));
2034
+ let netShift = 0;
2035
+ let count = 0;
2036
+ for (const e of pending) {
2037
+ if (!e || typeof e.slotId !== 'string' || e.slotId.length === 0)
2038
+ continue;
2039
+ if (e.side !== ORDER_TYPES.BUY && e.side !== ORDER_TYPES.SELL)
2040
+ continue;
2041
+ if (e.side === ORDER_TYPES.BUY && reserveBuyIds && reserveBuyIds.has(e.slotId))
2042
+ continue;
2043
+ if (e.side === ORDER_TYPES.SELL && reserveSellIds && reserveSellIds.has(e.slotId))
2044
+ continue;
2045
+ netShift += e.side === ORDER_TYPES.SELL ? 1 : -1;
2046
+ count++;
2047
+ }
2048
+ if (count === 0) {
2049
+ clear();
2050
+ return { applied: false, reason: 'nothing-owed' };
2051
+ }
2052
+ const fallbackCap = Math.max(Math.floor((config?.activeOrders?.sell ?? 1) / 2), Math.floor((config?.activeOrders?.buy ?? 1) / 2), 1);
2053
+ if (Math.abs(netShift) > fallbackCap)
2054
+ netShift = Math.sign(netShift) * fallbackCap;
2055
+ let gapSlots = Number(manager?._gapSlots);
2056
+ if (!Number.isFinite(gapSlots)) {
2057
+ try {
2058
+ gapSlots = MathUtils.calculateGapSlots(config?.incrementPercent, config?.targetSpreadPercent, config?.gridLimits);
2059
+ }
2060
+ catch {
2061
+ gapSlots = 0;
2062
+ }
2063
+ }
2064
+ const ceiling = (slots.length - gapSlots - 1) >= 0 ? (slots.length - gapSlots - 1) : (slots.length - 1);
2065
+ const candidate = Math.max(0, Math.min(ceiling, boundary + netShift));
2066
+ if (candidate === boundary) {
2067
+ clear();
2068
+ return { applied: false, reason: 'no-op' };
2069
+ }
2070
+ let check = { ok: true };
2071
+ try {
2072
+ check = MathUtils.validatePersistedBoundary(candidate, slots, gapSlots);
2073
+ }
2074
+ catch (err) {
2075
+ check = { ok: false, reason: 'validator-threw', detail: String(err?.message ?? err) };
2076
+ }
2077
+ if (!check || check.ok !== true) {
2078
+ clear();
2079
+ return { applied: false, reason: `unsafe: ${check?.reason ?? 'unknown'}${check?.detail ? ` ${check.detail}` : ''}` };
2080
+ }
2081
+ try {
2082
+ manager._restoreBoundary(candidate);
2083
+ }
2084
+ catch {
2085
+ return { applied: false, reason: 'restore-failed' };
2086
+ }
2087
+ clear();
2088
+ return { applied: true, from: boundary, to: candidate, count };
2089
+ }
2090
+ /**
2091
+ * Per-side reserve count (edge-pinned fat-finger insurance orders).
2092
+ * Buy reserves pin at the grid floor, sell reserves at the grid ceiling.
2093
+ * Non-finite/non-integer/negative values disable (0).
2094
+ *
2095
+ * @param {Object} config - Bot configuration
2096
+ * @param {string} side - 'buy' or 'sell'
2097
+ * @returns {number} Reserve count for the side (>= 0 integer)
2098
+ */
2099
+ function resolveReserveCount(config, side) {
2100
+ const key = side === 'sell' ? 'sell' : 'buy';
2101
+ const raw = Number(config?.reserveOrders?.[key] ?? 0);
2102
+ if (!Number.isInteger(raw) || raw < 0)
2103
+ return 0;
2104
+ return raw;
2105
+ }
2106
+ /**
2107
+ * Total reserve count across both sides (fee/count totals).
2108
+ *
2109
+ * @param {Object} config - Bot configuration
2110
+ * @returns {number} Total reserves (buy + sell)
2111
+ */
2112
+ function resolveReserveOrders(config) {
2113
+ return resolveReserveCount(config, 'buy') + resolveReserveCount(config, 'sell');
2114
+ }
2115
+ /**
2116
+ * Edge-pinned reserve id set for one side, or null when disabled.
2117
+ * Type/price-filtered, then ordered by the SAME edge order the placement
2118
+ * pickers use (compareReserveEdge via selectReserveEdgeSlots) — single source
2119
+ * of truth, so the no-crawl classification can never drift from placement.
2120
+ * Anchor: explicit live-grid edge when supplied, otherwise the config-bound
2121
+ * fallback (unresolved -> plain rank). Shelf/manual ids (non-slot-N, e.g.
2122
+ * fork-kept deep-* orders below the rail) are never reserves: they would
2123
+ * otherwise win the cheapest-first rank and poison the deficit check while
2124
+ * the shelf is live (issue #27 follow-up). No-op upstream (grids only mint
2125
+ * slot-N).
2126
+ *
2127
+ * @param {Array<Object>} allSlots - All grid slots (need id/price/type)
2128
+ * @param {Object} config - Bot configuration (reserve count source)
2129
+ * @param {string} orderType - ORDER_TYPES.BUY (floor) or SELL (ceiling)
2130
+ * @param {number|null} [anchorPrice] - Explicit edge anchor (live grid edge);
2131
+ * callers that picked slots must pass the SAME anchor so no-crawl
2132
+ * classification matches placement. NB: null/undefined falls back to the
2133
+ * config-bound anchor (unresolved -> plain rank) — unlike
2134
+ * selectReserveEdgeSlots, where null alone means plain rank.
2135
+ * @param {Set<string>|null} [excludeIds] - Windowed ids to skip (same set the
2136
+ * placement pickers exclude). Window + edge are additive in every target
2137
+ * (order counts, fees, hold-back), so a window that reaches the grid edge
2138
+ * (e.g. a keep-low window sitting on the floor) must not let the edge pick
2139
+ * land on window members — otherwise counting reads N/N with zero
2140
+ * dedicated reserves and the deficit never fires (issue #27 follow-up).
2141
+ * @returns {Set<string>|null} Edge slot ids, or null when side disabled
2142
+ */
2143
+ function reserveEdgeIdSet(allSlots, config, orderType, anchorPrice = null, excludeIds = null) {
2144
+ const isSell = orderType === ORDER_TYPES.SELL;
2145
+ const side = isSell ? 'sell' : 'buy';
2146
+ // Filter by the canonical side type, not the caller's token: the previous
2147
+ // per-side resolvers did the same, so a non-canonical token keeps the
2148
+ // floor behavior instead of silently matching nothing.
2149
+ const type = isSell ? ORDER_TYPES.SELL : ORDER_TYPES.BUY;
2150
+ const n = resolveReserveCount(config, side);
2151
+ if (n <= 0)
2152
+ return null;
2153
+ const ids = new Set();
2154
+ if (!Array.isArray(allSlots))
2155
+ return ids;
2156
+ // NB: Number(null) === 0 is finite — null/undefined must mean "no anchor".
2157
+ const anchor = anchorPrice == null ? resolveReserveEdgeAnchorPrice(config, side) : Number(anchorPrice);
2158
+ const ascending = allSlots
2159
+ .filter((s) => s && s.id != null && s.price != null && s.type === type && parseSlotIndex(s.id) !== null)
2160
+ .sort((a, b) => Number(a.price) - Number(b.price));
2161
+ for (const s of selectReserveEdgeSlots(ascending, n, excludeIds, isSell ? 'ceiling' : 'floor', anchor)) {
2162
+ ids.add(s.id);
2163
+ }
2164
+ return ids;
2165
+ }
2166
+ /**
2167
+ * Window member ids for one side, mirroring the window every placement
2168
+ * picker excludes from its reserve pick: in-rail slots of the side (geometry
2169
+ * via resolveGapBand/isSlotInRail — the same source the reconcile pickers
2170
+ * use), ordered closest to market first (buys: highest price first; sells:
2171
+ * lowest first), sliced to the configured activeOrders count. The slice runs
2172
+ * over the FULL master rail, not just live orders — window membership is
2173
+ * geometric (a virtual hole inside the window still blocks the reserve pick
2174
+ * there), so live-only slices would misclassify live reserves as window
2175
+ * members whenever the window itself is under-filled.
2176
+ *
2177
+ * Returns null when the boundary geometry is unknown: the pickers cannot
2178
+ * place reserves without it either, so callers fail open (no exclusion)
2179
+ * and keep their previous classification instead of guessing.
2180
+ *
2181
+ * @param {any} manager - OrderManager (orders Map, config, boundaryIdx)
2182
+ * @param {string} orderType - ORDER_TYPES.BUY or ORDER_TYPES.SELL
2183
+ * @returns {Set<string>|null} Window slot ids, or null when geometry unknown
2184
+ */
2185
+ function liveWindowIdSet(manager, orderType) {
2186
+ try {
2187
+ const isSell = orderType === ORDER_TYPES.SELL;
2188
+ const type = isSell ? ORDER_TYPES.SELL : ORDER_TYPES.BUY;
2189
+ const side = isSell ? 'sell' : 'buy';
2190
+ const count = Math.max(0, Math.floor(Number(manager?.config?.activeOrders?.[side])) || 0);
2191
+ if (!(count > 0) || !manager?.orders || typeof manager.orders.values !== 'function')
2192
+ return new Set();
2193
+ const resolved = MathUtils.resolveGapBand(manager);
2194
+ if (resolved?.boundaryIdx == null || resolved?.sellStartIdx == null)
2195
+ return null;
2196
+ const inRail = (o) => MathUtils.isSlotInRail(resolved.boundaryIdx, resolved.gapSlots, type, o);
2197
+ // Same type filter as the window pickers with known geometry: the
2198
+ // side's concrete type plus SPREAD placeholders (normalized empties
2199
+ // sitting in this side's rail).
2200
+ const typeFilter = (o) => o && o.id != null && o.price != null && (o.type === type || o.type === ORDER_TYPES.SPREAD);
2201
+ const ids = Array.from(manager.orders.values())
2202
+ .filter(typeFilter)
2203
+ .filter(inRail)
2204
+ .sort((a, b) => isSell ? Number(a.price) - Number(b.price) : Number(b.price) - Number(a.price))
2205
+ .slice(0, count)
2206
+ .map((o) => String(o.id));
2207
+ return new Set(ids);
2208
+ }
2209
+ catch {
2210
+ return null;
2211
+ }
2212
+ }
2213
+ /**
2214
+ * Refill-slot wire for the COW boundary hold (single source for both plan
2215
+ * producers: the fill-driven COW engine and the divergence fold).
2216
+ *
2217
+ * The hold keeps the committed boundary when a listed refill is guard-skipped
2218
+ * at broadcast — the refill is what justified the plan's boundary shift, so
2219
+ * committing the shift without it would strand an empty rail slot past the
2220
+ * new boundary. The wire must therefore list only placements that justify the
2221
+ * shift:
2222
+ *
2223
+ * - CREATE ids of the plan (the slots a fold did not convert into an
2224
+ * UPDATE), minus
2225
+ * - reserve-ladder ids. Reserves are static edge insurance; their fills
2226
+ * never crawl (deriveTargetBoundary filters them), so a guard-skipped
2227
+ * reserve must not pin geometry either. Without this exclusion a reserve
2228
+ * CREATE skipped at the wrong moment (e.g. a floor BUY above the last-fill
2229
+ * pivot while the market dumps below the grid) would hold the boundary for
2230
+ * a cycle although nothing was stranded.
2231
+ *
2232
+ * Absent/disabled reserves or an empty action list yield the plain CREATE ids,
2233
+ * so callers that never configured reserves keep the previous behavior.
2234
+ *
2235
+ * @param {Array<Object>} actions - Optimized COW actions
2236
+ * @param {Object} [options]
2237
+ * @param {Object} [options.config] - Bot configuration (reserve count source)
2238
+ * @param {Iterable<Object>} [options.slots] - Master slots (reserve classification)
2239
+ * @param {{buy?: number|null, sell?: number|null}} [options.edgeAnchors] - Live
2240
+ * edge anchors (same pair the strategy classifies reserve fills against)
2241
+ * @returns {string[]} Refill slot ids (CREATE ids minus reserve edge ids)
2242
+ */
2243
+ function collectRefillSlotIds(actions, options = {}) {
2244
+ const { config = null, slots = null, edgeAnchors = null } = options;
2245
+ const out = [];
2246
+ if (!Array.isArray(actions))
2247
+ return out;
2248
+ const createIds = actions
2249
+ .filter((a) => a?.type === COW_ACTIONS.CREATE && typeof a?.id === 'string' && a.id.length > 0)
2250
+ .map((a) => a.id);
2251
+ if (createIds.length === 0)
2252
+ return out;
2253
+ let reserveIds = null;
2254
+ if (slots && config) {
2255
+ try {
2256
+ // Accept a Map (master grid), an array of slots, or any iterable of
2257
+ // slot objects. Map entries are [id, slot] pairs, so `.values()` is
2258
+ // required — Array.from(map) would hand reserveEdgeIdSet pairs.
2259
+ const allSlots = Array.isArray(slots)
2260
+ ? slots
2261
+ : (typeof slots?.values === 'function'
2262
+ ? Array.from(slots.values())
2263
+ : Array.from(slots));
2264
+ const buyIds = reserveEdgeIdSet(allSlots, config, ORDER_TYPES.BUY, edgeAnchors?.buy ?? null);
2265
+ const sellIds = reserveEdgeIdSet(allSlots, config, ORDER_TYPES.SELL, edgeAnchors?.sell ?? null);
2266
+ if (buyIds || sellIds)
2267
+ reserveIds = new Set([...(buyIds ?? []), ...(sellIds ?? [])]);
2268
+ }
2269
+ catch {
2270
+ reserveIds = null;
2271
+ }
2272
+ }
2273
+ for (const id of createIds) {
2274
+ // Fail-open on classification errors: an id we cannot prove is a reserve
2275
+ // stays in the wire, so the hold keeps its previous (conservative) reach.
2276
+ if (reserveIds && reserveIds.has(id))
2277
+ continue;
2278
+ out.push(id);
2279
+ }
2280
+ return out;
2281
+ }
2282
+ /**
2283
+ * Resolved bound anchor for reserve edges from CONFIG alone.
2284
+ * BUY floor anchors toward minPrice (dip-insurance end), SELL ceiling toward
2285
+ * maxPrice (spike-insurance end). Resolves numeric and "Nx" relative forms
2286
+ * via resolveConfiguredPriceBound (startPrice-referenced); falls back to the
2287
+ * raw numeric bound. Returns null when unresolvable.
2288
+ *
2289
+ * Known limit: this is the statically resolved config bound, not the
2290
+ * gridPrice/AMA-referenced live rail bound. Placement call sites should use
2291
+ * resolveLiveReserveEdgeAnchorPrice(manager, side), which prefers the live
2292
+ * grid geometry and only falls back to this function.
2293
+ *
2294
+ * @param {Object} config - Bot configuration
2295
+ * @param {string} side - 'buy' or 'sell'
2296
+ * @returns {number|null} Finite anchor price, or null
2297
+ */
2298
+ function resolveReserveEdgeAnchorPrice(config, side) {
2299
+ const isSell = side === 'sell';
2300
+ const bound = isSell ? config?.maxPrice : config?.minPrice;
2301
+ const mode = isSell ? 'max' : 'min';
2302
+ try {
2303
+ const anchor = resolveConfiguredPriceBound(bound, Number.NaN, Number(config?.startPrice), mode);
2304
+ if (Number.isFinite(anchor))
2305
+ return anchor;
2306
+ }
2307
+ catch (e) { /* fall through to raw bound */ }
2308
+ const raw = Number(bound);
2309
+ return Number.isFinite(raw) ? raw : null;
2310
+ }
2311
+ /**
2312
+ * Live-grid reserve edge anchor (single source for edge placement).
2313
+ *
2314
+ * The anchor must come from the geometry the bot is actually trading, never
2315
+ * from a config value that can be a mode string ("pool"/"book"), a relative
2316
+ * multiplier, or a stale bound. Tiers, strongest first:
2317
+ *
2318
+ * 1. Genesis ladder extreme — `_genesis.priceLevels` is the exact ladder the
2319
+ * loaded grid was built from: sorted ascending, index-aligned with
2320
+ * `slot-<idx>` (assertSlotPriceInvariant), refreshed by initializeGrid,
2321
+ * persisted with the grid, and unaffected by the raw-profile re-merge a
2322
+ * resync performs. Slot 0 is always on the buy rail and the last level
2323
+ * always on the sell rail, so the ladder extremes are the live rail
2324
+ * bounds.
2325
+ * 2. Live in-rail extreme of the master grid — geometry-only rail
2326
+ * membership (resolveGapBand + isSlotInRail, the same predicate the
2327
+ * selectors use) for snapshots without a genesis.
2328
+ * 3. Config bound (resolveReserveEdgeAnchorPrice) — the previous behavior,
2329
+ * kept as the last resolved tier.
2330
+ * 4. null — callers keep the legacy rank-based selection.
2331
+ *
2332
+ * @param {Object} manager - OrderManager (needs _genesis, orders, boundary)
2333
+ * @param {string} side - 'buy' or 'sell'
2334
+ * @returns {number|null} Finite anchor price, or null
2335
+ */
2336
+ function resolveLiveReserveEdgeAnchorPrice(manager, side) {
2337
+ const isSell = side === 'sell';
2338
+ // Tier 1 — the ladder the loaded grid was generated from.
2339
+ const levels = manager?._genesis?.priceLevels;
2340
+ if (Array.isArray(levels) && levels.length > 0) {
2341
+ const extreme = Number(isSell ? levels[levels.length - 1] : levels[0]);
2342
+ if (Number.isFinite(extreme) && extreme > 0)
2343
+ return extreme;
2344
+ }
2345
+ // Tier 2 — live in-rail extreme of the master grid.
2346
+ const sideType = isSell ? ORDER_TYPES.SELL : ORDER_TYPES.BUY;
2347
+ if (manager?.orders && typeof manager.orders.values === 'function') {
2348
+ let best = null;
2349
+ try {
2350
+ const band = MathUtils.resolveGapBand(manager);
2351
+ for (const entry of manager.orders.values()) {
2352
+ if (!entry || typeof entry !== 'object')
2353
+ continue;
2354
+ if (!('type' in entry) || !('price' in entry))
2355
+ continue;
2356
+ if (entry.type !== sideType)
2357
+ continue;
2358
+ // Shelf/manual ids (e.g. fork-kept deep-* orders below the rail)
2359
+ // are never rail geometry: isSlotInRail is fail-open for
2360
+ // unparseable ids, so without this gate a cheap shelf order drags
2361
+ // the anchor down to itself and then qualifies as the reserve edge
2362
+ // (issue #27 follow-up). No-op upstream (grids only mint slot-N).
2363
+ if (parseSlotIndex(entry?.id) === null)
2364
+ continue;
2365
+ if (!MathUtils.isSlotInRail(band.boundaryIdx, band.gapSlots, sideType, entry))
2366
+ continue;
2367
+ const price = Number(entry.price);
2368
+ if (!Number.isFinite(price) || price <= 0)
2369
+ continue;
2370
+ if (best === null || (isSell ? price > best : price < best))
2371
+ best = price;
2372
+ }
2373
+ }
2374
+ catch (e) {
2375
+ best = null;
2376
+ }
2377
+ if (best !== null)
2378
+ return best;
2379
+ }
2380
+ // Tier 3 — configured/resolved bound.
2381
+ return resolveReserveEdgeAnchorPrice(manager?.config, side);
2382
+ }
2383
+ /**
2384
+ * Shared anchored edge comparator for reserve selection (single source).
2385
+ * With a finite anchor: in-bound slots first, nearest the anchor first
2386
+ * (floor ascending, ceiling descending); stale out-of-bound slots last,
2387
+ * still nearest the anchor first. Without one: plain rank fallback
2388
+ * (floor rank-lowest, ceiling rank-highest).
2389
+ *
2390
+ * @param {Object} a - Slot/order (needs price)
2391
+ * @param {Object} b - Slot/order (needs price)
2392
+ * @param {string} edge - 'floor' or 'ceiling'
2393
+ * @param {number|null} anchorPrice - Resolved bound anchor (null = rank fallback)
2394
+ * @returns {number} Comparator result for Array.prototype.sort
2395
+ */
2396
+ function compareReserveEdge(a, b, edge, anchorPrice) {
2397
+ const ceil = edge === 'ceiling';
2398
+ // NB: Number(null) === 0 is finite — null/undefined must mean "no anchor".
2399
+ const anchor = anchorPrice == null ? Number.NaN : Number(anchorPrice);
2400
+ if (!Number.isFinite(anchor)) {
2401
+ return ceil ? Number(b.price) - Number(a.price) : Number(a.price) - Number(b.price);
2402
+ }
2403
+ const pa = Number(a?.price);
2404
+ const pb = Number(b?.price);
2405
+ const aIn = ceil ? pa <= anchor : pa >= anchor;
2406
+ const bIn = ceil ? pb <= anchor : pb >= anchor;
2407
+ if (aIn !== bIn)
2408
+ return aIn ? -1 : 1;
2409
+ if (aIn)
2410
+ return ceil ? pb - pa : pa - pb;
2411
+ return ceil ? pa - pb : pb - pa;
2412
+ }
2413
+ /**
2414
+ * Central edge selector: take reserve slots from a price-ascending list,
2415
+ * skipping already-windowed ids. Both edges anchor at the live grid's own
2416
+ * edge (resolveLiveReserveEdgeAnchorPrice) when finite — floor: nearest
2417
+ * at/above the live floor first, slots below it rank last; ceiling: nearest
2418
+ * at/below the live ceiling first, slots above it rank last. Anchoring to the
2419
+ * live edge keeps the reserve on genuine live-rail slots when the grid still
2420
+ * carries leftovers from an older bound or the configured bound disagrees
2421
+ * with the geometry being traded. Callers pre-filter rail/type and apply
2422
+ * their own size gates; this only picks positions. A null anchor degrades to
2423
+ * plain rank (floor: lowest first; ceiling: highest first).
2424
+ *
2425
+ * @param {Array<Object>} sortedAsc - Slots sorted by price ascending
2426
+ * @param {number} count - Reserve count
2427
+ * @param {Set<string>|null} excludeIds - Windowed ids to skip
2428
+ * @param {string} edge - 'floor' or 'ceiling'
2429
+ * @param {number|null} [anchorPrice] - Live edge anchor for the side (null = rank-based)
2430
+ * @returns {Array<Object>} Reserve slots (ascending for floor, descending for ceiling)
2431
+ */
2432
+ function selectReserveEdgeSlots(sortedAsc, count, excludeIds, edge, anchorPrice = null) {
2433
+ const n = Math.max(0, Math.floor(Number(count) || 0));
2434
+ if (n <= 0 || !Array.isArray(sortedAsc))
2435
+ return [];
2436
+ const avail = sortedAsc.filter((s) => s && s.id != null && (!excludeIds || !excludeIds.has(s.id)));
2437
+ // NB: Number(null) === 0 is finite — null/undefined must mean "no anchor".
2438
+ const anchor = anchorPrice == null ? Number.NaN : Number(anchorPrice);
2439
+ if (!Number.isFinite(anchor)) {
2440
+ // No anchor: plain rank fallback (avail arrives ascending) —
2441
+ // floor rank-lowest, ceiling rank-highest.
2442
+ return edge === 'ceiling' ? avail.slice(-n).reverse() : avail.slice(0, n);
2443
+ }
2444
+ // Anchored: shared comparator — in-bound slots nearest the bound first,
2445
+ // stale out-of-bound slots last.
2446
+ return avail
2447
+ .sort((x, y) => compareReserveEdge(x, y, edge, anchor))
2448
+ .slice(0, n);
2449
+ }
1802
2450
  /**
1803
2451
  * Total target order count across both sides (used for BTS fee calculation).
1804
2452
  * Single source of truth so every budget derivation sizes identically.
2453
+ * Includes per-side reserves: they rest live on-chain and pay creation fees.
1805
2454
  *
1806
2455
  * @param {Object} config - Bot configuration
1807
2456
  * @returns {number} Total target order count
1808
2457
  */
1809
2458
  function getActiveOrdersTotal(config) {
1810
2459
  return Math.max(0, config?.activeOrders?.buy ?? 1) +
1811
- Math.max(0, config?.activeOrders?.sell ?? 1);
2460
+ Math.max(0, config?.activeOrders?.sell ?? 1) +
2461
+ resolveReserveOrders(config);
1812
2462
  }
1813
2463
  /**
1814
2464
  * Calculate side budget after BTS fee deduction.
@@ -2035,7 +2685,7 @@ function collectKnownOnChainOrderIds(mgr, placedResults, placedContexts, extraCr
2035
2685
  }
2036
2686
  }
2037
2687
  // Existing chain ids referenced by non-create op contexts (cancel /
2038
- // rotation / size-update). Pre-existing orders whose absence is expected
2688
+ // rotation / size-update) are already live: they belong to the master set
2039
2689
  // (cancels/fills in this batch), so they join the by-id set but never
2040
2690
  // the lagging-create guard.
2041
2691
  if (Array.isArray(placedContexts)) {
@@ -2058,6 +2708,6 @@ function collectKnownOnChainOrderIds(mgr, placedResults, placedContexts, extraCr
2058
2708
  const all = new Set([...masterIds, ...createIds]);
2059
2709
  return { masterIds: [...masterIds], createIds: [...createIds], all: [...all] };
2060
2710
  }
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 };
2711
+ 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 };
2712
+ export { resolveReserveEdgeAnchorPrice, resolveLiveReserveEdgeAnchorPrice, compareReserveEdge, collectRefillSlotIds };
2063
2713
  //# sourceMappingURL=order.js.map