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
@@ -12,6 +12,7 @@ import { path } from './path_api.js';
12
12
  import * as chainOrders from './chain_orders.js';
13
13
  import * as grid from './order/grid.js';
14
14
  import { ORDER_STATES, ORDER_TYPES, TIMING, BTS_PRECISION, NATIVE_CLIENT } from './constants.js';
15
+ import { BOT_LIVE_CONFIG_KEYS } from './runtime_settings.js';
15
16
  import { acquireIfNotHeld } from './order/async_lock.js';
16
17
  const { readOpenOrdersGuarded } = chainOrders;
17
18
  import { PATHS } from './paths.js';
@@ -57,7 +58,12 @@ function updateDynamicGridSnapshotSync(...args) { return require('../market_adap
57
58
  // would be circular; at call time both modules are fully loaded.
58
59
  function scheduleFillConsumerRestartFn(...args) { return require('./dexbot_fill_runtime').scheduleFillConsumerRestart(...args); }
59
60
  function reconcileGridOrders(...args) { return require('./order/grid_reconcile').reconcileGridOrders(...args); }
61
+ function resolveReserveCount(...args) { return require('./order/utils/order').resolveReserveCount(...args); }
62
+ function reserveEdgeIdSet(...args) { return require('./order/utils/order').reserveEdgeIdSet(...args); }
63
+ function liveWindowIdSet(...args) { return require('./order/utils/order').liveWindowIdSet(...args); }
64
+ function resolveLiveReserveEdgeAnchorPrice(...args) { return require('./order/utils/order').resolveLiveReserveEdgeAnchorPrice(...args); }
60
65
  function formatUnmatchedChainOrder(...args) { return require('./order/utils/order').formatUnmatchedChainOrder(...args); }
66
+ function isNonBlockingUnmatchedOrder(...args) { return require('./order/utils/order').isNonBlockingUnmatchedOrder(...args); }
61
67
  function getSideBudget(...args) { return require('./order/utils/order').getSideBudget(...args); }
62
68
  function getActiveOrdersTotal(config) { return require('./order/utils/order').getActiveOrdersTotal(config); }
63
69
  function correctAllPriceMismatches(...args) { return require('./order/utils/order').correctAllPriceMismatches(...args); }
@@ -147,6 +153,369 @@ function runtimeConfigNeedsMarketAdapter(snapshot, config) {
147
153
  }
148
154
  return usesAmaGridPrice(config);
149
155
  }
156
+ /**
157
+ * Live bot-config allowlist (BOT_LIVE_CONFIG_KEYS) is defined once in
158
+ * runtime_settings.ts (shared with the `dexbot bot` editor hint) and
159
+ * statically imported above. Everything outside it is hint-only:
160
+ * geometry needs `dexbot reset <bot>`, identity needs a restart.
161
+ */
162
+ /**
163
+ * Top-level entry keys excluded from the live bot-config fingerprint.
164
+ * botKey/botIndex are runtime-assigned, accountId is auto-saved next to
165
+ * preferredAccount after the first chain resolution (not a user edit).
166
+ */
167
+ const BOT_CONFIG_FINGERPRINT_IGNORED_KEYS = Object.freeze([
168
+ 'botKey',
169
+ 'botIndex',
170
+ 'accountId',
171
+ ]);
172
+ /**
173
+ * Deterministic JSON stringify with recursively sorted object keys.
174
+ * Key order in bots.json must not count as a config change.
175
+ * @param {any} value - Value to stringify
176
+ * @returns {string} Stable string representation
177
+ */
178
+ function stableStringifyForBotConfig(value) {
179
+ if (value === null || value === undefined)
180
+ return 'null';
181
+ if (Array.isArray(value)) {
182
+ return `[${value.map((v) => stableStringifyForBotConfig(v)).join(',')}]`;
183
+ }
184
+ if (typeof value === 'object') {
185
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
186
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringifyForBotConfig(value[k])}`).join(',')}}`;
187
+ }
188
+ const serialized = JSON.stringify(value);
189
+ return serialized === undefined ? 'null' : serialized;
190
+ }
191
+ /**
192
+ * Normalize a raw bots.json entry for fingerprinting: drop volatile keys.
193
+ * @param {any} entry - Raw bot entry from bots.json
194
+ * @returns {any} Normalized plain object ({} when entry is missing)
195
+ */
196
+ function normalizeBotEntryForFingerprint(entry) {
197
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
198
+ return {};
199
+ const out = {};
200
+ for (const key of Object.keys(entry)) {
201
+ if (BOT_CONFIG_FINGERPRINT_IGNORED_KEYS.includes(key))
202
+ continue;
203
+ out[key] = entry[key];
204
+ }
205
+ return out;
206
+ }
207
+ /**
208
+ * Build the full-entry fingerprint for one bot (stable across key order and
209
+ * comment/whitespace edits, since those never reach the parsed entry).
210
+ * @param {any} entry - Raw bot entry from bots.json
211
+ * @returns {string} Fingerprint ('' when entry is missing)
212
+ */
213
+ function buildBotConfigFingerprint(entry) {
214
+ if (!entry || typeof entry !== 'object')
215
+ return '';
216
+ return stableStringifyForBotConfig(normalizeBotEntryForFingerprint(entry));
217
+ }
218
+ /**
219
+ * Plain-data deep clone (bots.json entries are JSON-parsed, always
220
+ * serializable; the fallback only guards exotic test doubles).
221
+ * Single helper replacing the previously triplicated inline clones.
222
+ * @param {any} value - Value to clone
223
+ * @returns {any} Deep clone (or the original when unserializable)
224
+ */
225
+ function cloneJsonValue(value) {
226
+ if (value === undefined)
227
+ return undefined;
228
+ try {
229
+ return JSON.parse(JSON.stringify(value));
230
+ }
231
+ catch {
232
+ return value;
233
+ }
234
+ }
235
+ /**
236
+ * Merge the given entry keys into the live runtime (bot.config +
237
+ * manager.config) and refresh the derived weight base when included.
238
+ * Shared by the live bot-config check (allowlisted keys) — the full
239
+ * grid-resync path uses replaceBotConfigFromEntryPreservingRuntime below.
240
+ * Synchronous in-memory merge only: no chain I/O, no fill-lock (assignments
241
+ * are atomic; maintenance / targeted-reconcile ticks heal shortfalls/excess).
242
+ * @param {any} bot - DEXBot instance
243
+ * @param {any} entry - Raw bot entry from bots.json
244
+ * @param {string[]} keys - Entry keys to merge
245
+ * @returns {string[]} Merged key names
246
+ */
247
+ function mergeBotConfigKeysIntoRuntime(bot, entry, keys) {
248
+ const applied = [];
249
+ if (!bot?.config || !entry || typeof entry !== 'object')
250
+ return applied;
251
+ for (const key of keys) {
252
+ // A deleted key applies as null (mirrors the former ?? null diff
253
+ // semantics) so the running config can never keep a stale value.
254
+ // Cloned twice: bot.config and manager.config must never alias the
255
+ // same nested object, or a future in-place mutation of one would
256
+ // silently corrupt the other.
257
+ const cloned = cloneJsonValue(entry[key] ?? null);
258
+ bot.config[key] = cloned;
259
+ if (bot.manager?.config) {
260
+ bot.manager.config[key] = cloneJsonValue(entry[key] ?? null);
261
+ }
262
+ if (key === 'weightDistribution') {
263
+ bot._baseWeightDistribution = cloned && typeof cloned === 'object' ? { ...cloned } : cloned;
264
+ }
265
+ applied.push(key);
266
+ }
267
+ return applied;
268
+ }
269
+ /**
270
+ * Full config replace preserving runtime identity (botKey/botIndex) and the
271
+ * constructor-resolved runtime settings (timing, gridLimits, feeParams,
272
+ * etc.). Shared single implementation for performGridResync (previously an
273
+ * inline block duplicating the merge/weights/refresh steps).
274
+ * @param {any} bot - DEXBot instance
275
+ * @param {any} updatedBot - Raw bot entry from bots.json
276
+ * @param {string} contextLabel - Label for the weights-refresh log context
277
+ */
278
+ function replaceBotConfigFromEntryPreservingRuntime(bot, updatedBot, contextLabel) {
279
+ const oldKey = bot.config.botKey;
280
+ const oldIndex = bot.config.botIndex;
281
+ // Preserve runtime-only properties set by the constructor
282
+ // (timing, gridLimits, feeParams, etc.) that are not present
283
+ // in the raw profile from bots.json.
284
+ const runtimeProps = {};
285
+ for (const key of getRuntimeSettingsKeys()) {
286
+ if (bot.config[key] !== undefined) {
287
+ runtimeProps[key] = bot.config[key];
288
+ }
289
+ }
290
+ bot._log(`Reloaded configuration for bot '${bot.config.name}'`);
291
+ bot.config = { ...updatedBot, botKey: oldKey, botIndex: oldIndex, ...runtimeProps };
292
+ if (bot.manager?.config) {
293
+ bot.manager.config = { ...bot.manager.config, ...bot.config };
294
+ }
295
+ // bot.config is a new object now: re-point the credit runtime (its getter
296
+ // reads this.config) so the resync path can't leave it on the stale copy.
297
+ // The live-merge path mutates in place and never needs this.
298
+ if (bot._creditRuntime) {
299
+ bot._creditRuntime.config = bot.config;
300
+ }
301
+ bot._baseWeightDistribution = cloneWeightDistribution(updatedBot.weightDistribution, bot._baseWeightDistribution);
302
+ refreshDynamicWeightDistribution(bot, contextLabel);
303
+ }
304
+ /**
305
+ * Structural gate for live debtPolicy merges (mirrors the credit runtime's
306
+ * isEnabled gate plus the startup validator's required string fields in
307
+ * bot_settings.ts). Full numeric validation stays where it belongs:
308
+ * the `dexbot bot` editor at save time and per-offer at runtime.
309
+ * null/undefined (policy removal) is safe — it takes the disable path.
310
+ * @param {any} value - New debtPolicy value from bots.json
311
+ * @returns {boolean} True when safe to merge live
312
+ */
313
+ function isDebtPolicyLiveApplySafe(value) {
314
+ if (value === null || value === undefined)
315
+ return true;
316
+ if (typeof value !== 'object' || Array.isArray(value))
317
+ return false;
318
+ const lending = value.lending;
319
+ if (!Array.isArray(lending) || lending.length === 0)
320
+ return false;
321
+ return lending.every((item) => item && typeof item === 'object'
322
+ && typeof item.type === 'string' && item.type.length > 0
323
+ && typeof item.asset === 'string' && item.asset.length > 0
324
+ && typeof item.collateralAsset === 'string' && item.collateralAsset.length > 0);
325
+ }
326
+ /**
327
+ * Reconcile the credit runtime after a live debtPolicy merge: (re)create +
328
+ * load state when newly enabled, rebind the watchdog interval to the
329
+ * current runtime, stop the interval when disabled. loadState is a no-op on
330
+ * an already-loaded runtime and both maintenance entry points bail while
331
+ * disabled or in-flight, so this is safe on any tick (bot-level seams
332
+ * guarded for stripped test doubles).
333
+ * @param {any} bot - DEXBot instance
334
+ */
335
+ async function reconcileCreditRuntimeAfterPolicyChange(bot) {
336
+ try {
337
+ if (typeof bot._setupCreditRuntime === 'function') {
338
+ await bot._setupCreditRuntime();
339
+ }
340
+ }
341
+ catch (err) {
342
+ bot._warn?.(`Credit runtime setup failed after debtPolicy change: ${getErrorMessage(err)}`);
343
+ return;
344
+ }
345
+ try {
346
+ if (bot._creditRuntime) {
347
+ if (typeof bot._setupCreditWatchdogInterval === 'function') {
348
+ bot._setupCreditWatchdogInterval();
349
+ }
350
+ }
351
+ else if (typeof bot._stopCreditWatchdogInterval === 'function') {
352
+ bot._stopCreditWatchdogInterval();
353
+ }
354
+ }
355
+ catch (err) {
356
+ bot._warn?.(`Credit watchdog reconcile failed after debtPolicy change: ${getErrorMessage(err)}`);
357
+ }
358
+ }
359
+ /**
360
+ * Diff two normalized entries in a single pass over the key union.
361
+ * Replaces the former twin-loop diffLiveBotConfig +
362
+ * detectNonLiveBotConfigChanges pair.
363
+ * @param {any} oldNormalized - Previously seen normalized entry
364
+ * @param {any} newNormalized - New normalized entry
365
+ * @returns {{liveChanges: Array<{key: string, oldValue: any, newValue: any}>, otherKeys: string[]}} Split changes
366
+ */
367
+ function diffBotConfigEntries(oldNormalized, newNormalized) {
368
+ const prev = oldNormalized && typeof oldNormalized === 'object' ? oldNormalized : {};
369
+ const next = newNormalized && typeof newNormalized === 'object' ? newNormalized : {};
370
+ const liveChanges = [];
371
+ const otherKeys = [];
372
+ for (const key of new Set([...Object.keys(prev), ...Object.keys(next)])) {
373
+ const before = stableStringifyForBotConfig(prev[key] ?? null);
374
+ const after = stableStringifyForBotConfig(next[key] ?? null);
375
+ if (before === after)
376
+ continue;
377
+ if (BOT_LIVE_CONFIG_KEYS.includes(key)) {
378
+ liveChanges.push({ key, oldValue: prev[key] ?? null, newValue: next[key] ?? null });
379
+ }
380
+ else {
381
+ otherKeys.push(key);
382
+ }
383
+ }
384
+ return { liveChanges, otherKeys };
385
+ }
386
+ /**
387
+ * Check bots.json for this bot's entry and live-apply allowlisted keys
388
+ * (BOT_LIVE_CONFIG_KEYS; debtPolicy additionally reconciles the credit
389
+ * runtime, malformed debtPolicy is hinted instead of merged).
390
+ * Best-effort and idempotent: synchronous in-memory merge only, no chain
391
+ * I/O, no fill-lock (assignments are atomic; the existing maintenance /
392
+ * targeted-reconcile ticks heal shortfalls/excess on the next cycle).
393
+ * Geometry changes are NOT applied (need `dexbot reset <bot>`), identity
394
+ * changes are NOT applied (need restart) — on the steady-state path both
395
+ * produce a one-time hint per fingerprint (the fingerprint advances past
396
+ * hinted keys, malformed debtPolicy included, so a persistently-broken file
397
+ * warns once until the file changes again). The first-check baseline only
398
+ * converges the race-safe keys and stays silent on the rest: a geometry
399
+ * edit landing in the process-load → first-tick window is absorbed into
400
+ * the baseline with no hint. Corrupt/unreadable files and missing entries
401
+ * never touch the stored fingerprint (except the missing-entry hint
402
+ * throttle) so the next tick re-evaluates.
403
+ * @param {any} bot - DEXBot instance
404
+ * @param {string} [context='bots-config poll'] - Context label for logging
405
+ * @param {any} [preloadedSnapshot=null] - Reuse an already-loaded snapshot
406
+ * @returns {Promise<any>} Result with applied/liveChanges/otherKeys or skipped
407
+ */
408
+ async function checkAndApplyBotConfigChanges(bot, context = 'bots-config poll', preloadedSnapshot = null) {
409
+ try {
410
+ if (!bot || !bot.config) {
411
+ return { skipped: true, reason: 'missing-config' };
412
+ }
413
+ const snapshot = preloadedSnapshot
414
+ ?? (typeof bot._loadBotsConfigSnapshot === 'function'
415
+ ? await bot._loadBotsConfigSnapshot()
416
+ : loadBotsConfigSnapshot());
417
+ if (!snapshot || snapshot.corrupt || snapshot.readError) {
418
+ return {
419
+ skipped: true,
420
+ reason: snapshot?.corrupt ? 'corrupt-config' : snapshot?.readError ? 'unreadable-config' : 'missing-snapshot',
421
+ };
422
+ }
423
+ const entry = findSnapshotBotForRuntimeConfig(snapshot, bot.config);
424
+ const botName = String(bot.config?.name || bot.config?.botKey || 'bot');
425
+ if (!entry) {
426
+ const missingMarker = `missing:${String(bot.config?.botKey || bot.config?.name || '')}`;
427
+ if (bot._lastBotConfigHintFingerprint !== missingMarker) {
428
+ bot._lastBotConfigHintFingerprint = missingMarker;
429
+ bot._warn?.(`bots.json no longer contains an active entry for '${botName}' during ${context}; ` +
430
+ `roster changes need a stop/start.`);
431
+ }
432
+ return { skipped: true, reason: 'bot-not-found' };
433
+ }
434
+ const normalized = normalizeBotEntryForFingerprint(entry);
435
+ const fingerprint = stableStringifyForBotConfig(normalized);
436
+ if (bot._appliedBotConfigFingerprint === null || bot._appliedBotConfigFingerprint === undefined) {
437
+ bot._appliedBotConfigFingerprint = fingerprint;
438
+ bot._appliedBotConfigEntry = normalized;
439
+ // Startup race: the file may have changed between process config
440
+ // load and this first check. Converge the race-safe keys so the
441
+ // running bot can never sit on a value the file no longer
442
+ // contains. weightDistribution is excluded —
443
+ // refreshDynamicWeightDistribution owns the live value at runtime.
444
+ const racePrev = {};
445
+ for (const key of BOT_LIVE_CONFIG_KEYS) {
446
+ if (key === 'weightDistribution')
447
+ continue;
448
+ racePrev[key] = bot.config?.[key];
449
+ }
450
+ const raceDiff = diffBotConfigEntries(racePrev, normalized);
451
+ // Same invariant as the steady-state path: a malformed debtPolicy
452
+ // never reaches the runtime (warn once; the next save re-evaluates).
453
+ const raceLive = raceDiff.liveChanges.filter((c) => c.key !== 'debtPolicy' || isDebtPolicyLiveApplySafe(c.newValue));
454
+ if (raceLive.length !== raceDiff.liveChanges.length) {
455
+ bot._warn?.(`bots.json debtPolicy for '${botName}' failed validation at startup check; ` +
456
+ `keeping startup policy — fix the shape or run 'dexbot reset ${botName}'.`);
457
+ }
458
+ const raceApplied = mergeBotConfigKeysIntoRuntime(bot, entry, raceLive.map((c) => c.key));
459
+ if (raceApplied.includes('debtPolicy')) {
460
+ await reconcileCreditRuntimeAfterPolicyChange(bot);
461
+ }
462
+ if (raceApplied.length > 0) {
463
+ bot._log?.(`Picked up bots.json changes for '${botName}' at startup check (no restart needed): ${raceApplied.join(', ')}.`, 'info');
464
+ }
465
+ return { applied: raceApplied.length > 0, reason: 'baseline', fingerprint, liveChanges: raceApplied };
466
+ }
467
+ if (fingerprint === bot._appliedBotConfigFingerprint) {
468
+ return { applied: false, reason: 'unchanged', fingerprint };
469
+ }
470
+ const { liveChanges: rawLiveChanges, otherKeys: rawOtherKeys } = diffBotConfigEntries(bot._appliedBotConfigEntry, normalized);
471
+ // Malformed debtPolicy never reaches the runtime: hint reset/restart
472
+ // instead of merging garbage the credit cycle would choke on.
473
+ const liveChanges = rawLiveChanges.filter((c) => c.key !== 'debtPolicy' || isDebtPolicyLiveApplySafe(c.newValue));
474
+ const otherKeys = [...rawOtherKeys];
475
+ for (const c of rawLiveChanges) {
476
+ if (c.key === 'debtPolicy' && !isDebtPolicyLiveApplySafe(c.newValue) && !otherKeys.includes('debtPolicy')) {
477
+ otherKeys.push('debtPolicy');
478
+ }
479
+ }
480
+ const appliedKeys = mergeBotConfigKeysIntoRuntime(bot, entry, liveChanges.map((c) => c.key));
481
+ if (appliedKeys.includes('debtPolicy')) {
482
+ await reconcileCreditRuntimeAfterPolicyChange(bot);
483
+ }
484
+ if (appliedKeys.includes('weightDistribution')) {
485
+ // Same as the resync path: the file value is the base, the live
486
+ // effective weights (when whitelisted/ready) take precedence in
487
+ // bot/manager config immediately instead of next tick.
488
+ refreshDynamicWeightDistribution(bot, context);
489
+ }
490
+ bot._appliedBotConfigFingerprint = fingerprint;
491
+ bot._appliedBotConfigEntry = normalized;
492
+ if (liveChanges.length > 0) {
493
+ const summary = liveChanges
494
+ .map((c) => `${c.key} ${stableStringifyForBotConfig(c.oldValue)}->${stableStringifyForBotConfig(c.newValue)}`)
495
+ .join('; ');
496
+ bot._log?.(`Applied bots.json changes for '${botName}' live during ${context} (no restart needed): ${summary}. ` +
497
+ `Targeted maintenance will place missing / cancel excess orders.`, 'info');
498
+ }
499
+ if (otherKeys.length > 0) {
500
+ const shown = otherKeys.slice(0, 8).join(', ');
501
+ const suffix = otherKeys.length > 8 ? ` (+${otherKeys.length - 8} more)` : '';
502
+ bot._warn?.(`bots.json changed ${shown}${suffix} for '${botName}' during ${context}; ` +
503
+ `these were NOT applied live — run 'dexbot reset ${botName}' for grid geometry ` +
504
+ `or restart for market/account identity.`);
505
+ }
506
+ if (liveChanges.length === 0 && otherKeys.length === 0) {
507
+ bot._log?.(`Detected bots.json bookkeeping change for '${botName}' during ${context}; no tradable setting changed.`, 'debug');
508
+ }
509
+ return { applied: liveChanges.length > 0, liveChanges: liveChanges.map((c) => c.key), otherKeys, fingerprint };
510
+ }
511
+ catch (err) {
512
+ try {
513
+ bot?._warn?.(`Bot-config live check failed during ${context}: ${getErrorMessage(err)}`);
514
+ }
515
+ catch { /* logging must never break the tick */ }
516
+ return { skipped: true, reason: 'error', error: getErrorMessage(err) };
517
+ }
518
+ }
150
519
  function countLiveGridOrders(manager, type) {
151
520
  if (!manager)
152
521
  return 0;
@@ -156,7 +525,86 @@ function countLiveGridOrders(manager, type) {
156
525
  }
157
526
  function getTargetActiveOrders(config, side) {
158
527
  const configured = Number(config?.activeOrders?.[side]);
159
- return Math.max(0, Number.isFinite(configured) ? configured : 1);
528
+ // Reserve ladder rests live on-chain: countLiveGridOrders sees window +
529
+ // edge orders, so the shortfall target must include reserves too —
530
+ // otherwise live reserves mask window shortfalls and a filled/cancelled
531
+ // reserve order never triggers targeted reconciliation.
532
+ const reserves = resolveReserveCount(config, side);
533
+ return Math.max(0, Number.isFinite(configured) ? configured : 1) + reserves;
534
+ }
535
+ /**
536
+ * Count live on-chain reserve orders for one side (issue #27 follow-up).
537
+ *
538
+ * The window shortfall check above compares live window+reserves against
539
+ * window+reserves, so a pre-existing window surplus masks a reserve deficit
540
+ * (e.g. target 6+2=8 vs 12 live after reserveOrders 0→2: no shortfall, no
541
+ * sync, zero reserve placements). Reserves get their own independent reason
542
+ * below — the mirror of the masking case getTargetActiveOrders handles.
543
+ *
544
+ * Classification reuses the single source of truth (reserveEdgeIdSet +
545
+ * resolveLiveReserveEdgeAnchorPrice, same anchor the placement pickers use)
546
+ * over the full master grid, intersected with live ACTIVE/PARTIAL orderIds
547
+ * (same live definition as countLiveGridOrders). The edge pick excludes the
548
+ * window (liveWindowIdSet — the same exclusion every placement picker
549
+ * applies): window + edge are additive in targets/fees/hold-back, but
550
+ * without the exclusion a window that reaches the grid edge (keep-low
551
+ * window = bottom slots = floor edge) makes the edge pick land on window
552
+ * members and the count reads N/N with zero dedicated reserves, so the
553
+ * deficit never fires (issue #27 follow-up).
554
+ * @param {any} manager - OrderManager
555
+ * @param {any} config - Bot configuration (reserve count source)
556
+ * @param {any} type - ORDER_TYPES.BUY or ORDER_TYPES.SELL
557
+ * @returns {number|null} Live reserve count, or null when unclassifiable
558
+ */
559
+ function countLiveReserveOrders(manager, config, type) {
560
+ try {
561
+ const side = type === ORDER_TYPES.SELL ? 'sell' : 'buy';
562
+ const required = resolveReserveCount(config, side);
563
+ if (!(required > 0))
564
+ return 0;
565
+ if (!manager)
566
+ return null;
567
+ // Full master grid only: classifying from live-only indexed lookups
568
+ // would pick the "edge" of the live subset, not the grid edge, and
569
+ // mis-fire on stripped test doubles. Fail closed (null) without it.
570
+ if (!manager.orders || typeof manager.orders.values !== 'function')
571
+ return null;
572
+ const allSlots = Array.from(manager.orders.values());
573
+ if (allSlots.length === 0)
574
+ return null;
575
+ const anchor = resolveLiveReserveEdgeAnchorPrice(manager, side);
576
+ // Fail open on unknown window geometry (null): without a boundary the
577
+ // pickers cannot place reserves either, so keep the previous
578
+ // (exclusion-free) classification instead of guessing.
579
+ const windowIds = liveWindowIdSet(manager, type);
580
+ const reserveIds = reserveEdgeIdSet(allSlots, config, type, anchor, windowIds);
581
+ if (!reserveIds || reserveIds.size === 0)
582
+ return 0;
583
+ const liveIds = new Set();
584
+ if (typeof manager.getOrdersByTypeAndState === 'function') {
585
+ for (const state of [ORDER_STATES.ACTIVE, ORDER_STATES.PARTIAL]) {
586
+ for (const o of manager.getOrdersByTypeAndState(type, state) || []) {
587
+ if (o?.id != null && o?.orderId)
588
+ liveIds.add(String(o.id));
589
+ }
590
+ }
591
+ }
592
+ else {
593
+ for (const s of allSlots) {
594
+ if (s?.id != null && s?.orderId)
595
+ liveIds.add(String(s.id));
596
+ }
597
+ }
598
+ let live = 0;
599
+ for (const id of reserveIds) {
600
+ if (liveIds.has(String(id)))
601
+ live++;
602
+ }
603
+ return live;
604
+ }
605
+ catch {
606
+ return null;
607
+ }
160
608
  }
161
609
  function _hasBudgetForSide(manager, config, side) {
162
610
  try {
@@ -192,6 +640,21 @@ function getTargetedSyncReason(bot) {
192
640
  shortfalls.push(`sell ${liveSell}/${targetSell}`);
193
641
  }
194
642
  }
643
+ // Reserve deficit is independent of window surplus: a bot carrying extra
644
+ // rail/stranded orders can sit above target while its reserve edge is
645
+ // empty (live-applied reserveOrders 0→N, filled/cancelled reserve).
646
+ // Without this, placement waits for fills or the 4h fetch (issue #27).
647
+ for (const [type, side] of [[ORDER_TYPES.BUY, 'buy'], [ORDER_TYPES.SELL, 'sell']]) {
648
+ const required = resolveReserveCount(bot.config, side);
649
+ if (!(required > 0))
650
+ continue;
651
+ const liveReserves = countLiveReserveOrders(bot.manager, bot.config, type);
652
+ if (liveReserves !== null && liveReserves < required) {
653
+ if (_hasBudgetForSide(bot.manager, bot.config, side)) {
654
+ shortfalls.push(`${side} reserves ${liveReserves}/${required}`);
655
+ }
656
+ }
657
+ }
195
658
  const drift = bot.manager.checkFundDriftAfterFills?.();
196
659
  if (drift && drift.isValid === false) {
197
660
  return { reason: `fund drift: ${drift.reason}`, targetBuy, targetSell, liveBuy, liveSell, drift };
@@ -357,13 +820,35 @@ async function syncMarketAdapterOnPeriodicConfigCheck(bot, context = 'periodic')
357
820
  if (typeof bot._syncMarketAdapterHook === 'function') {
358
821
  return await bot._syncMarketAdapterHook(context);
359
822
  }
823
+ // Single bots.json read shared by the live bot-config check and the
824
+ // adapter drive below (one read per tick, not two). Throwing reads
825
+ // (transient I/O) behave like corrupt: skip everything, keep stored
826
+ // fingerprints, retry next tick.
827
+ let snapshot = null;
828
+ try {
829
+ snapshot = typeof bot._loadBotsConfigSnapshot === 'function'
830
+ ? await bot._loadBotsConfigSnapshot()
831
+ : loadBotsConfigSnapshot();
832
+ }
833
+ catch (err) {
834
+ bot._warn(`Ignoring unreadable bots.json during ${context}; keeping previous market adapter state.`);
835
+ return { skipped: true, reason: 'unreadable-config' };
836
+ }
837
+ // Live bot-config check runs on EVERY path (including wrapper-owned):
838
+ // no adapter driving, best-effort. It live-applies allowlisted keys
839
+ // (BOT_LIVE_CONFIG_KEYS) and hints
840
+ // reset/restart for everything else (Issue #27 follow-up).
841
+ try {
842
+ await checkAndApplyBotConfigChanges(bot, context, snapshot);
843
+ }
844
+ catch { /* checkAndApply never throws; belt-and-suspenders */ }
360
845
  // Centralized ownership: in monolithic mode the unlock wrapper watchdog
361
- // (shared 1min tick) is the sole market-adapter spawner. A per-bot sync here would
362
- // re-read bots.json and re-drive the adapter N times (once per bot) and
363
- // fight the wrapper over the adapter child, so bots skip it entirely and
364
- // act as pure adapter-output consumers. Wrapper-less modes (dexbot test
365
- // one-shot, isolated supervisor, PM2) keep the in-bot fallback below.
366
- // Checked before any file I/O so the skip also removes the duplicate read.
846
+ // (shared 1min tick) is the sole market-adapter spawner. A per-bot drive here would
847
+ // re-drive the adapter N times (once per bot) and fight the wrapper over
848
+ // the adapter child, so bots skip the adapter drive entirely and act as
849
+ // pure adapter-output consumers. The live bot-config check above still
850
+ // ran. Wrapper-less modes (dexbot test one-shot, isolated supervisor,
851
+ // PM2) keep the in-bot fallback below.
367
852
  if (!isPm2Runtime() && isWrapperAdapterOwner()) {
368
853
  return { skipped: true, reason: 'wrapper-owned' };
369
854
  }
@@ -372,9 +857,6 @@ async function syncMarketAdapterOnPeriodicConfigCheck(bot, context = 'periodic')
372
857
  }
373
858
  bot._marketAdapterWatchdogInFlight = true;
374
859
  try {
375
- const snapshot = typeof bot._loadBotsConfigSnapshot === 'function'
376
- ? await bot._loadBotsConfigSnapshot()
377
- : loadBotsConfigSnapshot();
378
860
  // Corrupt/unreadable bots.json must not move the adapter: previously
379
861
  // the parse/read throw aborted the check and left the adapter alone.
380
862
  // Keep that behavior by skipping the sync without touching the stored
@@ -744,22 +1226,7 @@ function performGridResync(bot, options = {}) {
744
1226
  const myName = self.config.name;
745
1227
  const updatedBot = allBotsConfig.find((b) => isSameBotName(b.name, myName));
746
1228
  if (updatedBot) {
747
- self._log(`Reloaded configuration for bot '${myName}'`);
748
- const oldKey = self.config.botKey;
749
- const oldIndex = self.config.botIndex;
750
- // Preserve runtime-only properties set by the constructor
751
- // (timing, gridLimits, feeParams, etc.) that are not present
752
- // in the raw profile from bots.json.
753
- const runtimeProps = {};
754
- for (const key of getRuntimeSettingsKeys()) {
755
- if (self.config[key] !== undefined) {
756
- runtimeProps[key] = self.config[key];
757
- }
758
- }
759
- self.config = { ...updatedBot, botKey: oldKey, botIndex: oldIndex, ...runtimeProps };
760
- self.manager.config = { ...self.manager.config, ...self.config };
761
- self._baseWeightDistribution = cloneWeightDistribution(updatedBot.weightDistribution, self._baseWeightDistribution);
762
- refreshDynamicWeightDistribution(self, 'grid resync');
1229
+ replaceBotConfigFromEntryPreservingRuntime(self, updatedBot, 'grid resync');
763
1230
  }
764
1231
  }
765
1232
  catch (e) {
@@ -977,6 +1444,29 @@ async function performPeriodicGridChecks(bot) {
977
1444
  else {
978
1445
  await runGridMaintenance(bot, 'periodic');
979
1446
  }
1447
+ logDeferredHoldSummary(bot);
1448
+ }
1449
+ /**
1450
+ * Summarize deliberate deferred holds once per count change. The per-order
1451
+ * sync logs report each hold when it first appears; this surfaces the running
1452
+ * total (funds stay locked until an operator clears them) without re-logging
1453
+ * the same count every tick.
1454
+ *
1455
+ * @param {import('./dexbot_class.js').DEXBot} bot
1456
+ */
1457
+ function logDeferredHoldSummary(bot) {
1458
+ const unmatched = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
1459
+ ? bot.manager._lastUnmatchedChainOrders
1460
+ : [];
1461
+ const held = unmatched.filter((u) => isNonBlockingUnmatchedOrder(u)).length;
1462
+ if (held === 0) {
1463
+ bot._lastHeldChainOrderCount = 0;
1464
+ return;
1465
+ }
1466
+ if (held === bot._lastHeldChainOrderCount)
1467
+ return;
1468
+ bot._lastHeldChainOrderCount = held;
1469
+ bot._log?.(`[HOLD] ${held} deferred chain order(s) held outside the active pipeline (funds stay locked until cleared)`, 'warn');
980
1470
  }
981
1471
  /**
982
1472
  * Check if the continuous open-orders sync loop is enabled.
@@ -1217,17 +1707,15 @@ function stopBlockchainFetchInterval(bot) {
1217
1707
  }
1218
1708
  }
1219
1709
  /**
1220
- * Set up the periodic bots.json fingerprint poll interval.
1221
- * Fallback config-change detector for wrapper-less modes (dexbot test
1222
- * one-shot, isolated supervisor, PM2): decoupled from the heavy blockchain
1223
- * fetch interval (default 240min) so config changes are visible within
1224
- * BOTS_CONFIG_POLL_INTERVAL_MS (default 1min, shared with the wrapper
1225
- * watchdog interval).
1226
- * In monolithic mode the unlock wrapper watchdog already polls bots.json
1227
- * on the same shared interval as the sole adapter owner, so no per-bot poll
1228
- * is created here.
1229
- * Uses the shared fingerprint via syncMarketAdapterOnPeriodicConfigCheck so
1230
- * the market adapter start/stop logic stays in one place.
1710
+ * Set up the periodic bots.json poll interval.
1711
+ * Runs in ALL modes (including wrapper-owned monolithic): the tick drives
1712
+ * syncMarketAdapterOnPeriodicConfigCheck, which always runs the live
1713
+ * bot-config check (allowlisted keys applied without restart, Issue #27
1714
+ * follow-up) and additionally drives the market adapter only in
1715
+ * wrapper-less modes (dexbot test one-shot, isolated supervisor, PM2).
1716
+ * Decoupled from the heavy blockchain fetch interval (default 240min) so
1717
+ * config changes are visible within BOTS_CONFIG_POLL_INTERVAL_MS
1718
+ * (default 1min, shared with the wrapper watchdog interval).
1231
1719
  * @param {import('./dexbot_class.js').DEXBot} bot
1232
1720
  */
1233
1721
  function setupBotsConfigPollInterval(bot) {
@@ -1237,13 +1725,14 @@ function setupBotsConfigPollInterval(bot) {
1237
1725
  intervalMs = Number(TIMING.BOTS_CONFIG_POLL_INTERVAL_MS);
1238
1726
  }
1239
1727
  if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
1240
- bot._log(`Bots-config poll interval disabled (value: ${intervalMs}). Fingerprint changes will only be seen on blockchain fetch ticks.`);
1241
- return;
1242
- }
1243
- if (!isPm2Runtime() && isWrapperAdapterOwner()) {
1244
- bot._log('Bots-config poll disabled (market adapter owned by unlock wrapper).');
1728
+ bot._log(`Bots-config poll interval disabled (value: ${intervalMs}). Adapter and live bot-config changes will only be seen on blockchain fetch ticks.`);
1245
1729
  return;
1246
1730
  }
1731
+ // NOTE: no wrapper-owned early return here on purpose. The adapter drive
1732
+ // inside syncMarketAdapterOnPeriodicConfigCheck still skips when the
1733
+ // wrapper owns it, but the live bot-config check at the top of that
1734
+ // function must run for every bot (otherwise monolithic bots would never
1735
+ // pick up bots.json edits until the 240min blockchain fetch tick).
1247
1736
  // Ensure the fingerprint exists before the first interval fires.
1248
1737
  // setupBlockchainFetchInterval already fires syncMarketAdapterOnPeriodicConfigCheck
1249
1738
  // at startup, so this is a fallback for bots that never run the blockchain interval
@@ -1280,7 +1769,7 @@ function setupBotsConfigPollInterval(bot) {
1280
1769
  if (typeof bot._botsConfigPollInterval.unref === 'function') {
1281
1770
  bot._botsConfigPollInterval.unref();
1282
1771
  }
1283
- bot._log(`Started bots-config poll interval: every ${Math.round(intervalMs / 1000)}s (fingerprint check)`);
1772
+ bot._log(`Started bots-config poll interval: every ${Math.round(intervalMs / 1000)}s (adapter fingerprint + live bot-config check)`);
1284
1773
  }
1285
1774
  /**
1286
1775
  * Stop the periodic bots.json fingerprint poll interval.
@@ -1584,7 +2073,9 @@ async function executeMaintenanceLogic(bot, context) {
1584
2073
  if (divergence.needsUpdate) {
1585
2074
  const hasRmsDivergence = !!(divergence.buy.rms || divergence.sell.rms);
1586
2075
  if (divergence.buy.ratio || divergence.sell.ratio) {
1587
- bot._log(`Grid update triggered by funds during ${context} (buy: ${divergence.buy.ratio}, sell: ${divergence.sell.ratio})`);
2076
+ const buyDir = divergence.buy.shrink ? '/shrink' : '';
2077
+ const sellDir = divergence.sell.shrink ? '/shrink' : '';
2078
+ bot._log(`Grid update triggered by funds during ${context} (buy: ${divergence.buy.ratio}${buyDir}, sell: ${divergence.sell.ratio}${sellDir})`);
1588
2079
  }
1589
2080
  if (hasRmsDivergence) {
1590
2081
  bot._log(`Grid update triggered by structural divergence during ${context}: buy=${Format.formatPrice6(divergence.buy.metric)}, sell=${Format.formatPrice6(divergence.sell.metric)}`);
@@ -2032,6 +2523,7 @@ function drainFillQueueAfterPipelineClear(bot) {
2032
2523
  if (!bot._incomingFillQueue || bot._incomingFillQueue.length === 0)
2033
2524
  return;
2034
2525
  bot._log(`[FILL-QUEUE] Pipeline cleared; draining ${bot._incomingFillQueue.length} deferred fill(s).`, 'info');
2526
+ bot._deferredFillsPending = true;
2035
2527
  scheduleFillConsumerRestartFn(bot, chainOrders);
2036
2528
  }
2037
2529
  catch { }
@@ -2239,6 +2731,14 @@ function markGridActivity(bot, reason = 'activity') {
2239
2731
  */
2240
2732
  function getMetrics(bot) {
2241
2733
  bot.manager?._cleanExpiredLocks?.();
2734
+ const unmatched = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
2735
+ ? bot.manager._lastUnmatchedChainOrders
2736
+ : [];
2737
+ // Deliberate holds (reason suffixed `-deferred`) never block the pipeline,
2738
+ // but they DO lock funds until an operator clears them. Surface the split
2739
+ // so the permanent holds are visible in status/metrics instead of only in
2740
+ // per-order sync logs.
2741
+ const heldUnmatched = unmatched.filter((u) => isNonBlockingUnmatchedOrder(u));
2242
2742
  return {
2243
2743
  ...bot._metrics,
2244
2744
  queueDepth: bot._incomingFillQueue.length,
@@ -2246,7 +2746,10 @@ function getMetrics(bot) {
2246
2746
  divergenceLockActive: bot.manager?._divergenceLock?.isLocked() || false,
2247
2747
  shadowLocksActive: bot.manager?.shadowOrderIds?.size || 0,
2248
2748
  recoveryExhaustedAt: bot.manager?._recoveryExhaustedAt || null,
2249
- recentFillsTracked: bot._recentlyProcessedFills.size
2749
+ recentFillsTracked: bot._recentlyProcessedFills.size,
2750
+ unmatchedChainOrders: unmatched.length,
2751
+ heldChainOrders: heldUnmatched.length,
2752
+ blockingChainOrders: unmatched.length - heldUnmatched.length
2250
2753
  };
2251
2754
  }
2252
2755
  /**
@@ -2321,10 +2824,12 @@ async function syncOpenOrdersAndProcessFillsImpl(bot, tag) {
2321
2824
  return { syncResult: null, aborted: true, hasUnmatched: -1, openOrders: null };
2322
2825
  }
2323
2826
  }
2324
- export { loadBotsConfigSnapshot, isWrapperAdapterOwner, refreshDynamicWeightDistribution, performGridResync, updateBotGridResetMetadata, handlePendingTriggerReset, setupTriggerFileDetection, performPeriodicGridChecks, isOpenOrdersSyncLoopEnabled, startOpenOrdersSyncLoop, stopOpenOrdersSyncLoop, setupBlockchainFetchInterval, stopBlockchainFetchInterval, setupBotsConfigPollInterval, stopBotsConfigPollInterval, executeMaintenanceLogic, cancelDustOrders, isOrderDoesNotExistError, runGridMaintenance, stopMarketAdapterPm2, releaseMarketAdapterRuntime, syncMarketAdapterOnPeriodicConfigCheck, findSnapshotBotForRuntimeConfig, runtimeConfigNeedsMarketAdapter, usesAmaGridPrice, checkBtsBalanceAndAcquire, acquireBts, runDustHealthCheck, setupDustHealthCheckInterval, requestGridReset, wireStructuralGridResyncRequest, getPipelineSignals, markGridActivity, getMetrics, syncOpenOrdersAndProcessFills };
2827
+ export { loadBotsConfigSnapshot, isWrapperAdapterOwner, checkAndApplyBotConfigChanges, buildBotConfigFingerprint, refreshDynamicWeightDistribution, performGridResync, updateBotGridResetMetadata, handlePendingTriggerReset, setupTriggerFileDetection, performPeriodicGridChecks, isOpenOrdersSyncLoopEnabled, startOpenOrdersSyncLoop, stopOpenOrdersSyncLoop, setupBlockchainFetchInterval, stopBlockchainFetchInterval, setupBotsConfigPollInterval, stopBotsConfigPollInterval, executeMaintenanceLogic, getTargetedSyncReason, countLiveReserveOrders, maybeRunTargetedDriftReconciliation, cancelDustOrders, isOrderDoesNotExistError, runGridMaintenance, stopMarketAdapterPm2, releaseMarketAdapterRuntime, syncMarketAdapterOnPeriodicConfigCheck, findSnapshotBotForRuntimeConfig, runtimeConfigNeedsMarketAdapter, usesAmaGridPrice, checkBtsBalanceAndAcquire, acquireBts, runDustHealthCheck, setupDustHealthCheckInterval, requestGridReset, wireStructuralGridResyncRequest, getPipelineSignals, markGridActivity, getMetrics, syncOpenOrdersAndProcessFills };
2325
2828
  export default {
2326
2829
  loadBotsConfigSnapshot,
2327
2830
  isWrapperAdapterOwner,
2831
+ checkAndApplyBotConfigChanges,
2832
+ buildBotConfigFingerprint,
2328
2833
  refreshDynamicWeightDistribution,
2329
2834
  performGridResync,
2330
2835
  updateBotGridResetMetadata,
@@ -2339,6 +2844,9 @@ export default {
2339
2844
  setupBotsConfigPollInterval,
2340
2845
  stopBotsConfigPollInterval,
2341
2846
  executeMaintenanceLogic,
2847
+ getTargetedSyncReason,
2848
+ countLiveReserveOrders,
2849
+ maybeRunTargetedDriftReconciliation,
2342
2850
  cancelDustOrders,
2343
2851
  isOrderDoesNotExistError,
2344
2852
  runGridMaintenance,