dexbot 1.5.3 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +9 -7
  3. package/analysis/README.md +1 -2
  4. package/analysis/ama_fitting/fetch_lp_candles.ts +1 -9
  5. package/analysis/ama_fitting/package.json +1 -1
  6. package/analysis/analyze_derivatives.ts +1 -1
  7. package/analysis/bot_usage/discover_bot_accounts.ts +12 -6
  8. package/analysis/resolve_source.ts +9 -2
  9. package/analysis/tradingview/README.md +29 -11
  10. package/analysis/tradingview/analyze_tradingview.ts +92 -3
  11. package/analysis/tradingview/tradingview_uplot_chart_generator.ts +1120 -182
  12. package/analysis/trend_detection/package.json +1 -1
  13. package/claw/docs/DEXBOT2_TUNING_CHEAT_SHEET.md +1 -1
  14. package/claw/modules/dexbot_profiles.ts +22 -2
  15. package/claw/package.json +1 -1
  16. package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
  17. package/claw/runtimes/openclaw-plugin/package.json +1 -1
  18. package/claw/skills/launcher-ops/references/launcher-workflow.md +1 -1
  19. package/claw/tests/test_claw_mcp_transport.ts +2 -2
  20. package/claw/tests/test_dexbot_profiles.ts +65 -0
  21. package/dist/analysis/ama_fitting/fetch_lp_candles.js +1 -9
  22. package/dist/analysis/ama_fitting/fetch_lp_candles.js.map +1 -1
  23. package/dist/analysis/analyze_derivatives.js +1 -1
  24. package/dist/analysis/bot_usage/discover_bot_accounts.js +11 -6
  25. package/dist/analysis/bot_usage/discover_bot_accounts.js.map +1 -1
  26. package/dist/analysis/bot_usage/kibana_bot_queries.d.ts +2 -0
  27. package/dist/analysis/bot_usage/kibana_bot_queries.d.ts.map +1 -1
  28. package/dist/analysis/resolve_source.d.ts +1 -0
  29. package/dist/analysis/resolve_source.d.ts.map +1 -1
  30. package/dist/analysis/resolve_source.js +6 -2
  31. package/dist/analysis/resolve_source.js.map +1 -1
  32. package/dist/analysis/tradingview/analyze_tradingview.d.ts +5 -0
  33. package/dist/analysis/tradingview/analyze_tradingview.d.ts.map +1 -1
  34. package/dist/analysis/tradingview/analyze_tradingview.js +112 -3
  35. package/dist/analysis/tradingview/analyze_tradingview.js.map +1 -1
  36. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts +5 -1
  37. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts.map +1 -1
  38. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js +1118 -182
  39. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js.map +1 -1
  40. package/dist/bot.js +1 -1
  41. package/dist/bot.js.map +1 -1
  42. package/dist/dexbot.js +4 -4
  43. package/dist/dexbot.js.map +1 -1
  44. package/dist/market_adapter/ama_signal_runner.js +2 -2
  45. package/dist/market_adapter/candle_utils.d.ts +6 -0
  46. package/dist/market_adapter/candle_utils.d.ts.map +1 -1
  47. package/dist/market_adapter/candle_utils.js +6 -0
  48. package/dist/market_adapter/candle_utils.js.map +1 -1
  49. package/dist/market_adapter/core/kibana_candles.d.ts +17 -1
  50. package/dist/market_adapter/core/kibana_candles.d.ts.map +1 -1
  51. package/dist/market_adapter/core/kibana_candles.js +101 -19
  52. package/dist/market_adapter/core/kibana_candles.js.map +1 -1
  53. package/dist/market_adapter/core/kibana_client.d.ts +2 -0
  54. package/dist/market_adapter/core/kibana_client.d.ts.map +1 -1
  55. package/dist/market_adapter/core/kibana_client.js +31 -4
  56. package/dist/market_adapter/core/kibana_client.js.map +1 -1
  57. package/dist/market_adapter/core/market_adapter_service.d.ts.map +1 -1
  58. package/dist/market_adapter/core/market_adapter_service.js +0 -6
  59. package/dist/market_adapter/core/market_adapter_service.js.map +1 -1
  60. package/dist/market_adapter/inputs/fetch_book_data.d.ts +14 -0
  61. package/dist/market_adapter/inputs/fetch_book_data.d.ts.map +1 -0
  62. package/dist/market_adapter/inputs/fetch_book_data.js +138 -0
  63. package/dist/market_adapter/inputs/fetch_book_data.js.map +1 -0
  64. package/dist/market_adapter/inputs/fetch_cex_synthetic_data.js +1 -1
  65. package/dist/market_adapter/inputs/fetch_lp_data.d.ts +16 -2
  66. package/dist/market_adapter/inputs/fetch_lp_data.d.ts.map +1 -1
  67. package/dist/market_adapter/inputs/fetch_lp_data.js +109 -281
  68. package/dist/market_adapter/inputs/fetch_lp_data.js.map +1 -1
  69. package/dist/market_adapter/inputs/kibana_feed_source.d.ts +87 -0
  70. package/dist/market_adapter/inputs/kibana_feed_source.d.ts.map +1 -0
  71. package/dist/market_adapter/inputs/kibana_feed_source.js +570 -0
  72. package/dist/market_adapter/inputs/kibana_feed_source.js.map +1 -0
  73. package/dist/market_adapter/inputs/kibana_source.d.ts.map +1 -1
  74. package/dist/market_adapter/inputs/kibana_source.js +10 -5
  75. package/dist/market_adapter/inputs/kibana_source.js.map +1 -1
  76. package/dist/market_adapter/inputs/window_cache.d.ts +166 -0
  77. package/dist/market_adapter/inputs/window_cache.d.ts.map +1 -0
  78. package/dist/market_adapter/inputs/window_cache.js +500 -0
  79. package/dist/market_adapter/inputs/window_cache.js.map +1 -0
  80. package/dist/market_adapter/interval_utils.d.ts +8 -1
  81. package/dist/market_adapter/interval_utils.d.ts.map +1 -1
  82. package/dist/market_adapter/interval_utils.js +14 -1
  83. package/dist/market_adapter/interval_utils.js.map +1 -1
  84. package/dist/modules/account_bots.d.ts +1 -0
  85. package/dist/modules/account_bots.d.ts.map +1 -1
  86. package/dist/modules/account_bots.js +21 -5
  87. package/dist/modules/account_bots.js.map +1 -1
  88. package/dist/modules/account_orders.d.ts +25 -4
  89. package/dist/modules/account_orders.d.ts.map +1 -1
  90. package/dist/modules/account_orders.js +67 -4
  91. package/dist/modules/account_orders.js.map +1 -1
  92. package/dist/modules/bitshares-native/chain_client.d.ts +2 -0
  93. package/dist/modules/bitshares-native/chain_client.d.ts.map +1 -1
  94. package/dist/modules/bitshares-native/chain_client.js +3 -1
  95. package/dist/modules/bitshares-native/chain_client.js.map +1 -1
  96. package/dist/modules/bitshares-native/subscriptions.d.ts.map +1 -1
  97. package/dist/modules/bitshares-native/subscriptions.js +13 -3
  98. package/dist/modules/bitshares-native/subscriptions.js.map +1 -1
  99. package/dist/modules/bitshares-native/transport.d.ts +2 -0
  100. package/dist/modules/bitshares-native/transport.d.ts.map +1 -1
  101. package/dist/modules/bitshares-native/transport.js +122 -28
  102. package/dist/modules/bitshares-native/transport.js.map +1 -1
  103. package/dist/modules/bitshares_client.d.ts.map +1 -1
  104. package/dist/modules/bitshares_client.js +59 -8
  105. package/dist/modules/bitshares_client.js.map +1 -1
  106. package/dist/modules/bot_settings.d.ts.map +1 -1
  107. package/dist/modules/bot_settings.js +11 -0
  108. package/dist/modules/bot_settings.js.map +1 -1
  109. package/dist/modules/constants.d.ts +18 -0
  110. package/dist/modules/constants.d.ts.map +1 -1
  111. package/dist/modules/constants.js +90 -4
  112. package/dist/modules/constants.js.map +1 -1
  113. package/dist/modules/dexbot_class.d.ts +11 -0
  114. package/dist/modules/dexbot_class.d.ts.map +1 -1
  115. package/dist/modules/dexbot_class.js +92 -4
  116. package/dist/modules/dexbot_class.js.map +1 -1
  117. package/dist/modules/dexbot_cow_runtime.d.ts +26 -1
  118. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  119. package/dist/modules/dexbot_cow_runtime.js +157 -28
  120. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  121. package/dist/modules/dexbot_fill_runtime.d.ts +66 -1
  122. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  123. package/dist/modules/dexbot_fill_runtime.js +295 -10
  124. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  125. package/dist/modules/dexbot_maintenance_runtime.d.ts +102 -12
  126. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  127. package/dist/modules/dexbot_maintenance_runtime.js +644 -48
  128. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  129. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  130. package/dist/modules/dexbot_startup_runtime.js +13 -0
  131. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  132. package/dist/modules/dexbot_state_recovery.d.ts +15 -1
  133. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  134. package/dist/modules/dexbot_state_recovery.js +52 -8
  135. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  136. package/dist/modules/fund_registry.d.ts +1 -1
  137. package/dist/modules/fund_registry.js +1 -1
  138. package/dist/modules/market_adapter_whitelist.js +1 -1
  139. package/dist/modules/market_adapter_whitelist.js.map +1 -1
  140. package/dist/modules/node_connect_policy.d.ts +52 -0
  141. package/dist/modules/node_connect_policy.d.ts.map +1 -0
  142. package/dist/modules/node_connect_policy.js +35 -0
  143. package/dist/modules/node_connect_policy.js.map +1 -0
  144. package/dist/modules/node_manager.d.ts +32 -0
  145. package/dist/modules/node_manager.d.ts.map +1 -1
  146. package/dist/modules/node_manager.js +76 -9
  147. package/dist/modules/node_manager.js.map +1 -1
  148. package/dist/modules/order/accounting.d.ts +30 -0
  149. package/dist/modules/order/accounting.d.ts.map +1 -1
  150. package/dist/modules/order/accounting.js +176 -3
  151. package/dist/modules/order/accounting.js.map +1 -1
  152. package/dist/modules/order/export.d.ts.map +1 -1
  153. package/dist/modules/order/export.js +1 -0
  154. package/dist/modules/order/export.js.map +1 -1
  155. package/dist/modules/order/grid.d.ts.map +1 -1
  156. package/dist/modules/order/grid.js +189 -25
  157. package/dist/modules/order/grid.js.map +1 -1
  158. package/dist/modules/order/grid_reconcile.d.ts.map +1 -1
  159. package/dist/modules/order/grid_reconcile.js +5 -3
  160. package/dist/modules/order/grid_reconcile.js.map +1 -1
  161. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  162. package/dist/modules/order/grid_reconcile_internal.js +216 -9
  163. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  164. package/dist/modules/order/logger.js +1 -1
  165. package/dist/modules/order/logger.js.map +1 -1
  166. package/dist/modules/order/manager.d.ts +77 -2
  167. package/dist/modules/order/manager.d.ts.map +1 -1
  168. package/dist/modules/order/manager.js +283 -14
  169. package/dist/modules/order/manager.js.map +1 -1
  170. package/dist/modules/order/strategy.d.ts +3 -0
  171. package/dist/modules/order/strategy.d.ts.map +1 -1
  172. package/dist/modules/order/strategy.js +79 -7
  173. package/dist/modules/order/strategy.js.map +1 -1
  174. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  175. package/dist/modules/order/sync_engine.js +16 -1
  176. package/dist/modules/order/sync_engine.js.map +1 -1
  177. package/dist/modules/order/utils/math.d.ts +16 -2
  178. package/dist/modules/order/utils/math.d.ts.map +1 -1
  179. package/dist/modules/order/utils/math.js +30 -3
  180. package/dist/modules/order/utils/math.js.map +1 -1
  181. package/dist/modules/order/utils/order.d.ts +258 -13
  182. package/dist/modules/order/utils/order.d.ts.map +1 -1
  183. package/dist/modules/order/utils/order.js +666 -13
  184. package/dist/modules/order/utils/order.js.map +1 -1
  185. package/dist/modules/order/utils/system.d.ts +36 -0
  186. package/dist/modules/order/utils/system.d.ts.map +1 -1
  187. package/dist/modules/order/utils/system.js +102 -6
  188. package/dist/modules/order/utils/system.js.map +1 -1
  189. package/dist/modules/order/utils/validate.d.ts.map +1 -1
  190. package/dist/modules/order/utils/validate.js +6 -2
  191. package/dist/modules/order/utils/validate.js.map +1 -1
  192. package/dist/modules/paths.d.ts +2 -0
  193. package/dist/modules/paths.d.ts.map +1 -1
  194. package/dist/modules/paths.js +1 -0
  195. package/dist/modules/paths.js.map +1 -1
  196. package/dist/modules/runtime_settings.d.ts +13 -0
  197. package/dist/modules/runtime_settings.d.ts.map +1 -1
  198. package/dist/modules/runtime_settings.js +20 -0
  199. package/dist/modules/runtime_settings.js.map +1 -1
  200. package/dist/modules/utils/errors.d.ts +16 -0
  201. package/dist/modules/utils/errors.d.ts.map +1 -1
  202. package/dist/modules/utils/errors.js +31 -0
  203. package/dist/modules/utils/errors.js.map +1 -1
  204. package/dist/pm2.js +7 -7
  205. package/dist/scripts/analyze-credit.d.ts.map +1 -1
  206. package/dist/scripts/analyze-credit.js +13 -3
  207. package/dist/scripts/analyze-credit.js.map +1 -1
  208. package/dist/scripts/analyze-orders.js +2 -2
  209. package/dist/scripts/analyze-orders.js.map +1 -1
  210. package/dist/scripts/diagnose-kibana-candles.d.ts.map +1 -1
  211. package/dist/scripts/diagnose-kibana-candles.js +0 -1
  212. package/dist/scripts/diagnose-kibana-candles.js.map +1 -1
  213. package/dist/scripts/generate_market_adapter_whitelist.d.ts.map +1 -1
  214. package/dist/scripts/generate_market_adapter_whitelist.js +3 -2
  215. package/dist/scripts/generate_market_adapter_whitelist.js.map +1 -1
  216. package/dist/scripts/test-credit-renewal.js +1 -1
  217. package/dist/scripts/test-credit-renewal.js.map +1 -1
  218. package/dist/scripts/tv.d.ts +24 -2
  219. package/dist/scripts/tv.d.ts.map +1 -1
  220. package/dist/scripts/tv.js +190 -65
  221. package/dist/scripts/tv.js.map +1 -1
  222. package/docs/BITSHARES_ONBOARDING.md +19 -4
  223. package/docs/COW_INVARIANTS.md +18 -1
  224. package/docs/DEXBOT_COMPARISON.md +3 -3
  225. package/docs/EVOLUTION.md +9 -7
  226. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +21 -9
  227. package/docs/GRID_RECALCULATION.md +13 -13
  228. package/docs/GRID_RECONCILE.md +12 -2
  229. package/docs/MPA_CREDIT_USAGE.md +2 -2
  230. package/docs/README.md +5 -6
  231. package/docs/architecture.md +0 -58
  232. package/docs/developer_guide.md +2 -1
  233. package/docs/docker.md +1 -1
  234. package/market_adapter/README.md +17 -10
  235. package/modules/README.md +1 -1
  236. package/package.json +1 -1
  237. package/scripts/README.md +9 -7
  238. package/tests/README.md +2 -2
  239. package/analysis/results/ama_sweep_results_lp_pool_133_1h.json +0 -2455
  240. package/analysis/results/bot_fitting_results_lp_pool_133_1h.json +0 -218
  241. package/analysis/tradingview/h-bts_tradingview.html +0 -1570
  242. package/analysis/tradingview/t-bts_tradingview.html +0 -1570
@@ -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,12 +58,18 @@ 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); }
64
70
  function isOrderOnChain(...args) { return require('./order/utils/order').isOrderOnChain(...args); }
65
71
  function parseChainOrder(...args) { return require('./order/utils/order').parseChainOrder(...args); }
72
+ function parseSlotIndex(...args) { return require('./order/utils/order').parseSlotIndex(...args); }
66
73
  const CODE_ROOT = path.join(__dirname, '..');
67
74
  const PROFILES_DIR = PATHS.PROFILES_DIR;
68
75
  const PROFILES_BOTS_FILE = PATHS.PROFILES.BOTS_JSON;
@@ -147,16 +154,464 @@ function runtimeConfigNeedsMarketAdapter(snapshot, config) {
147
154
  }
148
155
  return usesAmaGridPrice(config);
149
156
  }
157
+ /**
158
+ * Live bot-config allowlist (BOT_LIVE_CONFIG_KEYS) is defined once in
159
+ * runtime_settings.ts (shared with the `dexbot bot` editor hint) and
160
+ * statically imported above. Everything outside it is hint-only:
161
+ * geometry needs `dexbot reset <bot>`, identity needs a restart.
162
+ */
163
+ /**
164
+ * Top-level entry keys excluded from the live bot-config fingerprint.
165
+ * botKey/botIndex are runtime-assigned, accountId is auto-saved next to
166
+ * preferredAccount after the first chain resolution (not a user edit).
167
+ */
168
+ const BOT_CONFIG_FINGERPRINT_IGNORED_KEYS = Object.freeze([
169
+ 'botKey',
170
+ 'botIndex',
171
+ 'accountId',
172
+ ]);
173
+ /**
174
+ * Deterministic JSON stringify with recursively sorted object keys.
175
+ * Key order in bots.json must not count as a config change.
176
+ * @param {any} value - Value to stringify
177
+ * @returns {string} Stable string representation
178
+ */
179
+ function stableStringifyForBotConfig(value) {
180
+ if (value === null || value === undefined)
181
+ return 'null';
182
+ if (Array.isArray(value)) {
183
+ return `[${value.map((v) => stableStringifyForBotConfig(v)).join(',')}]`;
184
+ }
185
+ if (typeof value === 'object') {
186
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
187
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringifyForBotConfig(value[k])}`).join(',')}}`;
188
+ }
189
+ const serialized = JSON.stringify(value);
190
+ return serialized === undefined ? 'null' : serialized;
191
+ }
192
+ /**
193
+ * Normalize a raw bots.json entry for fingerprinting: drop volatile keys.
194
+ * @param {any} entry - Raw bot entry from bots.json
195
+ * @returns {any} Normalized plain object ({} when entry is missing)
196
+ */
197
+ function normalizeBotEntryForFingerprint(entry) {
198
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
199
+ return {};
200
+ const out = {};
201
+ for (const key of Object.keys(entry)) {
202
+ if (BOT_CONFIG_FINGERPRINT_IGNORED_KEYS.includes(key))
203
+ continue;
204
+ out[key] = entry[key];
205
+ }
206
+ return out;
207
+ }
208
+ /**
209
+ * Build the full-entry fingerprint for one bot (stable across key order and
210
+ * comment/whitespace edits, since those never reach the parsed entry).
211
+ * @param {any} entry - Raw bot entry from bots.json
212
+ * @returns {string} Fingerprint ('' when entry is missing)
213
+ */
214
+ function buildBotConfigFingerprint(entry) {
215
+ if (!entry || typeof entry !== 'object')
216
+ return '';
217
+ return stableStringifyForBotConfig(normalizeBotEntryForFingerprint(entry));
218
+ }
219
+ /**
220
+ * Plain-data deep clone (bots.json entries are JSON-parsed, always
221
+ * serializable; the fallback only guards exotic test doubles).
222
+ * Single helper replacing the previously triplicated inline clones.
223
+ * @param {any} value - Value to clone
224
+ * @returns {any} Deep clone (or the original when unserializable)
225
+ */
226
+ function cloneJsonValue(value) {
227
+ if (value === undefined)
228
+ return undefined;
229
+ try {
230
+ return JSON.parse(JSON.stringify(value));
231
+ }
232
+ catch {
233
+ return value;
234
+ }
235
+ }
236
+ /**
237
+ * Merge the given entry keys into the live runtime (bot.config +
238
+ * manager.config) and refresh the derived weight base when included.
239
+ * Shared by the live bot-config check (allowlisted keys) — the full
240
+ * grid-resync path uses replaceBotConfigFromEntryPreservingRuntime below.
241
+ * Synchronous in-memory merge only: no chain I/O, no fill-lock (assignments
242
+ * are atomic; maintenance / targeted-reconcile ticks heal shortfalls/excess).
243
+ * @param {any} bot - DEXBot instance
244
+ * @param {any} entry - Raw bot entry from bots.json
245
+ * @param {string[]} keys - Entry keys to merge
246
+ * @returns {string[]} Merged key names
247
+ */
248
+ function mergeBotConfigKeysIntoRuntime(bot, entry, keys) {
249
+ const applied = [];
250
+ if (!bot?.config || !entry || typeof entry !== 'object')
251
+ return applied;
252
+ for (const key of keys) {
253
+ // A deleted key applies as null (mirrors the former ?? null diff
254
+ // semantics) so the running config can never keep a stale value.
255
+ // Cloned twice: bot.config and manager.config must never alias the
256
+ // same nested object, or a future in-place mutation of one would
257
+ // silently corrupt the other.
258
+ const cloned = cloneJsonValue(entry[key] ?? null);
259
+ bot.config[key] = cloned;
260
+ if (bot.manager?.config) {
261
+ bot.manager.config[key] = cloneJsonValue(entry[key] ?? null);
262
+ }
263
+ if (key === 'weightDistribution') {
264
+ bot._baseWeightDistribution = cloned && typeof cloned === 'object' ? { ...cloned } : cloned;
265
+ }
266
+ applied.push(key);
267
+ }
268
+ return applied;
269
+ }
270
+ /**
271
+ * Full config replace preserving runtime identity (botKey/botIndex) and the
272
+ * constructor-resolved runtime settings (timing, gridLimits, feeParams,
273
+ * etc.). Shared single implementation for performGridResync (previously an
274
+ * inline block duplicating the merge/weights/refresh steps).
275
+ * @param {any} bot - DEXBot instance
276
+ * @param {any} updatedBot - Raw bot entry from bots.json
277
+ * @param {string} contextLabel - Label for the weights-refresh log context
278
+ */
279
+ function replaceBotConfigFromEntryPreservingRuntime(bot, updatedBot, contextLabel) {
280
+ const oldKey = bot.config.botKey;
281
+ const oldIndex = bot.config.botIndex;
282
+ // Preserve runtime-only properties set by the constructor
283
+ // (timing, gridLimits, feeParams, etc.) that are not present
284
+ // in the raw profile from bots.json.
285
+ const runtimeProps = {};
286
+ for (const key of getRuntimeSettingsKeys()) {
287
+ if (bot.config[key] !== undefined) {
288
+ runtimeProps[key] = bot.config[key];
289
+ }
290
+ }
291
+ bot._log(`Reloaded configuration for bot '${bot.config.name}'`);
292
+ bot.config = { ...updatedBot, botKey: oldKey, botIndex: oldIndex, ...runtimeProps };
293
+ if (bot.manager?.config) {
294
+ bot.manager.config = { ...bot.manager.config, ...bot.config };
295
+ }
296
+ // bot.config is a new object now: re-point the credit runtime (its getter
297
+ // reads this.config) so the resync path can't leave it on the stale copy.
298
+ // The live-merge path mutates in place and never needs this.
299
+ if (bot._creditRuntime) {
300
+ bot._creditRuntime.config = bot.config;
301
+ }
302
+ bot._baseWeightDistribution = cloneWeightDistribution(updatedBot.weightDistribution, bot._baseWeightDistribution);
303
+ refreshDynamicWeightDistribution(bot, contextLabel);
304
+ }
305
+ /**
306
+ * Structural gate for live debtPolicy merges (mirrors the credit runtime's
307
+ * isEnabled gate plus the startup validator's required string fields in
308
+ * bot_settings.ts). Full numeric validation stays where it belongs:
309
+ * the `dexbot bot` editor at save time and per-offer at runtime.
310
+ * null/undefined (policy removal) is safe — it takes the disable path.
311
+ * @param {any} value - New debtPolicy value from bots.json
312
+ * @returns {boolean} True when safe to merge live
313
+ */
314
+ function isDebtPolicyLiveApplySafe(value) {
315
+ if (value === null || value === undefined)
316
+ return true;
317
+ if (typeof value !== 'object' || Array.isArray(value))
318
+ return false;
319
+ const lending = value.lending;
320
+ if (!Array.isArray(lending) || lending.length === 0)
321
+ return false;
322
+ return lending.every((item) => item && typeof item === 'object'
323
+ && typeof item.type === 'string' && item.type.length > 0
324
+ && typeof item.asset === 'string' && item.asset.length > 0
325
+ && typeof item.collateralAsset === 'string' && item.collateralAsset.length > 0);
326
+ }
327
+ /**
328
+ * Reconcile the credit runtime after a live debtPolicy merge: (re)create +
329
+ * load state when newly enabled, rebind the watchdog interval to the
330
+ * current runtime, stop the interval when disabled. loadState is a no-op on
331
+ * an already-loaded runtime and both maintenance entry points bail while
332
+ * disabled or in-flight, so this is safe on any tick (bot-level seams
333
+ * guarded for stripped test doubles).
334
+ * @param {any} bot - DEXBot instance
335
+ */
336
+ async function reconcileCreditRuntimeAfterPolicyChange(bot) {
337
+ try {
338
+ if (typeof bot._setupCreditRuntime === 'function') {
339
+ await bot._setupCreditRuntime();
340
+ }
341
+ }
342
+ catch (err) {
343
+ bot._warn?.(`Credit runtime setup failed after debtPolicy change: ${getErrorMessage(err)}`);
344
+ return;
345
+ }
346
+ try {
347
+ if (bot._creditRuntime) {
348
+ if (typeof bot._setupCreditWatchdogInterval === 'function') {
349
+ bot._setupCreditWatchdogInterval();
350
+ }
351
+ }
352
+ else if (typeof bot._stopCreditWatchdogInterval === 'function') {
353
+ bot._stopCreditWatchdogInterval();
354
+ }
355
+ }
356
+ catch (err) {
357
+ bot._warn?.(`Credit watchdog reconcile failed after debtPolicy change: ${getErrorMessage(err)}`);
358
+ }
359
+ }
360
+ /**
361
+ * Diff two normalized entries in a single pass over the key union.
362
+ * Replaces the former twin-loop diffLiveBotConfig +
363
+ * detectNonLiveBotConfigChanges pair.
364
+ * @param {any} oldNormalized - Previously seen normalized entry
365
+ * @param {any} newNormalized - New normalized entry
366
+ * @returns {{liveChanges: Array<{key: string, oldValue: any, newValue: any}>, otherKeys: string[]}} Split changes
367
+ */
368
+ function diffBotConfigEntries(oldNormalized, newNormalized) {
369
+ const prev = oldNormalized && typeof oldNormalized === 'object' ? oldNormalized : {};
370
+ const next = newNormalized && typeof newNormalized === 'object' ? newNormalized : {};
371
+ const liveChanges = [];
372
+ const otherKeys = [];
373
+ for (const key of new Set([...Object.keys(prev), ...Object.keys(next)])) {
374
+ const before = stableStringifyForBotConfig(prev[key] ?? null);
375
+ const after = stableStringifyForBotConfig(next[key] ?? null);
376
+ if (before === after)
377
+ continue;
378
+ if (BOT_LIVE_CONFIG_KEYS.includes(key)) {
379
+ liveChanges.push({ key, oldValue: prev[key] ?? null, newValue: next[key] ?? null });
380
+ }
381
+ else {
382
+ otherKeys.push(key);
383
+ }
384
+ }
385
+ return { liveChanges, otherKeys };
386
+ }
387
+ /**
388
+ * Check bots.json for this bot's entry and live-apply allowlisted keys
389
+ * (BOT_LIVE_CONFIG_KEYS; debtPolicy additionally reconciles the credit
390
+ * runtime, malformed debtPolicy is hinted instead of merged).
391
+ * Best-effort and idempotent: synchronous in-memory merge only, no chain
392
+ * I/O, no fill-lock (assignments are atomic; the existing maintenance /
393
+ * targeted-reconcile ticks heal shortfalls/excess on the next cycle).
394
+ * Geometry changes are NOT applied (need `dexbot reset <bot>`), identity
395
+ * changes are NOT applied (need restart) — on the steady-state path both
396
+ * produce a one-time hint per fingerprint (the fingerprint advances past
397
+ * hinted keys, malformed debtPolicy included, so a persistently-broken file
398
+ * warns once until the file changes again). The first-check baseline only
399
+ * converges the race-safe keys and stays silent on the rest: a geometry
400
+ * edit landing in the process-load → first-tick window is absorbed into
401
+ * the baseline with no hint. Corrupt/unreadable files and missing entries
402
+ * never touch the stored fingerprint (except the missing-entry hint
403
+ * throttle) so the next tick re-evaluates.
404
+ * @param {any} bot - DEXBot instance
405
+ * @param {string} [context='bots-config poll'] - Context label for logging
406
+ * @param {any} [preloadedSnapshot=null] - Reuse an already-loaded snapshot
407
+ * @returns {Promise<any>} Result with applied/liveChanges/otherKeys or skipped
408
+ */
409
+ async function checkAndApplyBotConfigChanges(bot, context = 'bots-config poll', preloadedSnapshot = null) {
410
+ try {
411
+ if (!bot || !bot.config) {
412
+ return { skipped: true, reason: 'missing-config' };
413
+ }
414
+ const snapshot = preloadedSnapshot
415
+ ?? (typeof bot._loadBotsConfigSnapshot === 'function'
416
+ ? await bot._loadBotsConfigSnapshot()
417
+ : loadBotsConfigSnapshot());
418
+ if (!snapshot || snapshot.corrupt || snapshot.readError) {
419
+ return {
420
+ skipped: true,
421
+ reason: snapshot?.corrupt ? 'corrupt-config' : snapshot?.readError ? 'unreadable-config' : 'missing-snapshot',
422
+ };
423
+ }
424
+ const entry = findSnapshotBotForRuntimeConfig(snapshot, bot.config);
425
+ const botName = String(bot.config?.name || bot.config?.botKey || 'bot');
426
+ if (!entry) {
427
+ const missingMarker = `missing:${String(bot.config?.botKey || bot.config?.name || '')}`;
428
+ if (bot._lastBotConfigHintFingerprint !== missingMarker) {
429
+ bot._lastBotConfigHintFingerprint = missingMarker;
430
+ bot._warn?.(`bots.json no longer contains an active entry for '${botName}' during ${context}; ` +
431
+ `roster changes need a stop/start.`);
432
+ }
433
+ return { skipped: true, reason: 'bot-not-found' };
434
+ }
435
+ const normalized = normalizeBotEntryForFingerprint(entry);
436
+ const fingerprint = stableStringifyForBotConfig(normalized);
437
+ if (bot._appliedBotConfigFingerprint === null || bot._appliedBotConfigFingerprint === undefined) {
438
+ bot._appliedBotConfigFingerprint = fingerprint;
439
+ bot._appliedBotConfigEntry = normalized;
440
+ // Startup race: the file may have changed between process config
441
+ // load and this first check. Converge the race-safe keys so the
442
+ // running bot can never sit on a value the file no longer
443
+ // contains. weightDistribution is excluded —
444
+ // refreshDynamicWeightDistribution owns the live value at runtime.
445
+ const racePrev = {};
446
+ for (const key of BOT_LIVE_CONFIG_KEYS) {
447
+ if (key === 'weightDistribution')
448
+ continue;
449
+ racePrev[key] = bot.config?.[key];
450
+ }
451
+ const raceDiff = diffBotConfigEntries(racePrev, normalized);
452
+ // Same invariant as the steady-state path: a malformed debtPolicy
453
+ // never reaches the runtime (warn once; the next save re-evaluates).
454
+ const raceLive = raceDiff.liveChanges.filter((c) => c.key !== 'debtPolicy' || isDebtPolicyLiveApplySafe(c.newValue));
455
+ if (raceLive.length !== raceDiff.liveChanges.length) {
456
+ bot._warn?.(`bots.json debtPolicy for '${botName}' failed validation at startup check; ` +
457
+ `keeping startup policy — fix the shape or run 'dexbot reset ${botName}'.`);
458
+ }
459
+ const raceApplied = mergeBotConfigKeysIntoRuntime(bot, entry, raceLive.map((c) => c.key));
460
+ if (raceApplied.includes('debtPolicy')) {
461
+ await reconcileCreditRuntimeAfterPolicyChange(bot);
462
+ }
463
+ if (raceApplied.length > 0) {
464
+ bot._log?.(`Picked up bots.json changes for '${botName}' at startup check (no restart needed): ${raceApplied.join(', ')}.`, 'info');
465
+ }
466
+ return { applied: raceApplied.length > 0, reason: 'baseline', fingerprint, liveChanges: raceApplied };
467
+ }
468
+ if (fingerprint === bot._appliedBotConfigFingerprint) {
469
+ return { applied: false, reason: 'unchanged', fingerprint };
470
+ }
471
+ const { liveChanges: rawLiveChanges, otherKeys: rawOtherKeys } = diffBotConfigEntries(bot._appliedBotConfigEntry, normalized);
472
+ // Malformed debtPolicy never reaches the runtime: hint reset/restart
473
+ // instead of merging garbage the credit cycle would choke on.
474
+ const liveChanges = rawLiveChanges.filter((c) => c.key !== 'debtPolicy' || isDebtPolicyLiveApplySafe(c.newValue));
475
+ const otherKeys = [...rawOtherKeys];
476
+ for (const c of rawLiveChanges) {
477
+ if (c.key === 'debtPolicy' && !isDebtPolicyLiveApplySafe(c.newValue) && !otherKeys.includes('debtPolicy')) {
478
+ otherKeys.push('debtPolicy');
479
+ }
480
+ }
481
+ const appliedKeys = mergeBotConfigKeysIntoRuntime(bot, entry, liveChanges.map((c) => c.key));
482
+ if (appliedKeys.includes('debtPolicy')) {
483
+ await reconcileCreditRuntimeAfterPolicyChange(bot);
484
+ }
485
+ if (appliedKeys.includes('weightDistribution')) {
486
+ // Same as the resync path: the file value is the base, the live
487
+ // effective weights (when whitelisted/ready) take precedence in
488
+ // bot/manager config immediately instead of next tick.
489
+ refreshDynamicWeightDistribution(bot, context);
490
+ }
491
+ bot._appliedBotConfigFingerprint = fingerprint;
492
+ bot._appliedBotConfigEntry = normalized;
493
+ if (liveChanges.length > 0) {
494
+ const summary = liveChanges
495
+ .map((c) => `${c.key} ${stableStringifyForBotConfig(c.oldValue)}->${stableStringifyForBotConfig(c.newValue)}`)
496
+ .join('; ');
497
+ bot._log?.(`Applied bots.json changes for '${botName}' live during ${context} (no restart needed): ${summary}. ` +
498
+ `Targeted maintenance will place missing / cancel excess orders.`, 'info');
499
+ }
500
+ if (otherKeys.length > 0) {
501
+ const shown = otherKeys.slice(0, 8).join(', ');
502
+ const suffix = otherKeys.length > 8 ? ` (+${otherKeys.length - 8} more)` : '';
503
+ bot._warn?.(`bots.json changed ${shown}${suffix} for '${botName}' during ${context}; ` +
504
+ `these were NOT applied live — run 'dexbot reset ${botName}' for grid geometry ` +
505
+ `or restart for market/account identity.`);
506
+ }
507
+ if (liveChanges.length === 0 && otherKeys.length === 0) {
508
+ bot._log?.(`Detected bots.json bookkeeping change for '${botName}' during ${context}; no tradable setting changed.`, 'debug');
509
+ }
510
+ return { applied: liveChanges.length > 0, liveChanges: liveChanges.map((c) => c.key), otherKeys, fingerprint };
511
+ }
512
+ catch (err) {
513
+ try {
514
+ bot?._warn?.(`Bot-config live check failed during ${context}: ${getErrorMessage(err)}`);
515
+ }
516
+ catch { /* logging must never break the tick */ }
517
+ return { skipped: true, reason: 'error', error: getErrorMessage(err) };
518
+ }
519
+ }
150
520
  function countLiveGridOrders(manager, type) {
151
521
  if (!manager)
152
522
  return 0;
153
523
  const active = manager.getOrdersByTypeAndState?.(type, ORDER_STATES.ACTIVE) || [];
154
524
  const partial = manager.getOrdersByTypeAndState?.(type, ORDER_STATES.PARTIAL) || [];
155
- return active.concat(partial).filter((o) => o?.orderId).length;
525
+ // Slot-N gated: fork-kept shelf/manual orders (non-slot-N ids, e.g.
526
+ // deep-*) sit outside window accounting — same gate as reserve
527
+ // classification and startup cancel candidates (issue #27 follow-up).
528
+ // Without this, a live shelf inflates the live window+reserves count and
529
+ // masks a real window/reserve shortfall, so targeted sync never fires.
530
+ // No-op on grids that only mint slot-N ids.
531
+ return active.concat(partial).filter((o) => o?.orderId && parseSlotIndex(o?.id) !== null).length;
156
532
  }
157
533
  function getTargetActiveOrders(config, side) {
158
534
  const configured = Number(config?.activeOrders?.[side]);
159
- return Math.max(0, Number.isFinite(configured) ? configured : 1);
535
+ // Reserve ladder rests live on-chain: countLiveGridOrders sees window +
536
+ // edge orders, so the shortfall target must include reserves too —
537
+ // otherwise live reserves mask window shortfalls and a filled/cancelled
538
+ // reserve order never triggers targeted reconciliation.
539
+ const reserves = resolveReserveCount(config, side);
540
+ return Math.max(0, Number.isFinite(configured) ? configured : 1) + reserves;
541
+ }
542
+ /**
543
+ * Count live on-chain reserve orders for one side (issue #27 follow-up).
544
+ *
545
+ * The window shortfall check above compares live window+reserves against
546
+ * window+reserves, so a pre-existing window surplus masks a reserve deficit
547
+ * (e.g. target 6+2=8 vs 12 live after reserveOrders 0→2: no shortfall, no
548
+ * sync, zero reserve placements). Reserves get their own independent reason
549
+ * below — the mirror of the masking case getTargetActiveOrders handles.
550
+ *
551
+ * Classification reuses the single source of truth (reserveEdgeIdSet +
552
+ * resolveLiveReserveEdgeAnchorPrice, same anchor the placement pickers use)
553
+ * over the full master grid, intersected with live ACTIVE/PARTIAL orderIds
554
+ * (same live definition as countLiveGridOrders). The edge pick excludes the
555
+ * window (liveWindowIdSet — the same exclusion every placement picker
556
+ * applies): window + edge are additive in targets/fees/hold-back, but
557
+ * without the exclusion a window that reaches the grid edge (keep-low
558
+ * window = bottom slots = floor edge) makes the edge pick land on window
559
+ * members and the count reads N/N with zero dedicated reserves, so the
560
+ * deficit never fires (issue #27 follow-up).
561
+ * @param {any} manager - OrderManager
562
+ * @param {any} config - Bot configuration (reserve count source)
563
+ * @param {any} type - ORDER_TYPES.BUY or ORDER_TYPES.SELL
564
+ * @returns {number|null} Live reserve count, or null when unclassifiable
565
+ */
566
+ function countLiveReserveOrders(manager, config, type) {
567
+ try {
568
+ const side = type === ORDER_TYPES.SELL ? 'sell' : 'buy';
569
+ const required = resolveReserveCount(config, side);
570
+ if (!(required > 0))
571
+ return 0;
572
+ if (!manager)
573
+ return null;
574
+ // Full master grid only: classifying from live-only indexed lookups
575
+ // would pick the "edge" of the live subset, not the grid edge, and
576
+ // mis-fire on stripped test doubles. Fail closed (null) without it.
577
+ if (!manager.orders || typeof manager.orders.values !== 'function')
578
+ return null;
579
+ const allSlots = Array.from(manager.orders.values());
580
+ if (allSlots.length === 0)
581
+ return null;
582
+ const anchor = resolveLiveReserveEdgeAnchorPrice(manager, side);
583
+ // Fail open on unknown window geometry (null): without a boundary the
584
+ // pickers cannot place reserves either, so keep the previous
585
+ // (exclusion-free) classification instead of guessing.
586
+ const windowIds = liveWindowIdSet(manager, type);
587
+ const reserveIds = reserveEdgeIdSet(allSlots, config, type, anchor, windowIds);
588
+ if (!reserveIds || reserveIds.size === 0)
589
+ return 0;
590
+ const liveIds = new Set();
591
+ if (typeof manager.getOrdersByTypeAndState === 'function') {
592
+ for (const state of [ORDER_STATES.ACTIVE, ORDER_STATES.PARTIAL]) {
593
+ for (const o of manager.getOrdersByTypeAndState(type, state) || []) {
594
+ if (o?.id != null && o?.orderId)
595
+ liveIds.add(String(o.id));
596
+ }
597
+ }
598
+ }
599
+ else {
600
+ for (const s of allSlots) {
601
+ if (s?.id != null && s?.orderId)
602
+ liveIds.add(String(s.id));
603
+ }
604
+ }
605
+ let live = 0;
606
+ for (const id of reserveIds) {
607
+ if (liveIds.has(String(id)))
608
+ live++;
609
+ }
610
+ return live;
611
+ }
612
+ catch {
613
+ return null;
614
+ }
160
615
  }
161
616
  function _hasBudgetForSide(manager, config, side) {
162
617
  try {
@@ -192,6 +647,21 @@ function getTargetedSyncReason(bot) {
192
647
  shortfalls.push(`sell ${liveSell}/${targetSell}`);
193
648
  }
194
649
  }
650
+ // Reserve deficit is independent of window surplus: a bot carrying extra
651
+ // rail/stranded orders can sit above target while its reserve edge is
652
+ // empty (live-applied reserveOrders 0→N, filled/cancelled reserve).
653
+ // Without this, placement waits for fills or the 4h fetch (issue #27).
654
+ for (const [type, side] of [[ORDER_TYPES.BUY, 'buy'], [ORDER_TYPES.SELL, 'sell']]) {
655
+ const required = resolveReserveCount(bot.config, side);
656
+ if (!(required > 0))
657
+ continue;
658
+ const liveReserves = countLiveReserveOrders(bot.manager, bot.config, type);
659
+ if (liveReserves !== null && liveReserves < required) {
660
+ if (_hasBudgetForSide(bot.manager, bot.config, side)) {
661
+ shortfalls.push(`${side} reserves ${liveReserves}/${required}`);
662
+ }
663
+ }
664
+ }
195
665
  const drift = bot.manager.checkFundDriftAfterFills?.();
196
666
  if (drift && drift.isValid === false) {
197
667
  return { reason: `fund drift: ${drift.reason}`, targetBuy, targetSell, liveBuy, liveSell, drift };
@@ -357,13 +827,35 @@ async function syncMarketAdapterOnPeriodicConfigCheck(bot, context = 'periodic')
357
827
  if (typeof bot._syncMarketAdapterHook === 'function') {
358
828
  return await bot._syncMarketAdapterHook(context);
359
829
  }
830
+ // Single bots.json read shared by the live bot-config check and the
831
+ // adapter drive below (one read per tick, not two). Throwing reads
832
+ // (transient I/O) behave like corrupt: skip everything, keep stored
833
+ // fingerprints, retry next tick.
834
+ let snapshot = null;
835
+ try {
836
+ snapshot = typeof bot._loadBotsConfigSnapshot === 'function'
837
+ ? await bot._loadBotsConfigSnapshot()
838
+ : loadBotsConfigSnapshot();
839
+ }
840
+ catch (err) {
841
+ bot._warn(`Ignoring unreadable bots.json during ${context}; keeping previous market adapter state.`);
842
+ return { skipped: true, reason: 'unreadable-config' };
843
+ }
844
+ // Live bot-config check runs on EVERY path (including wrapper-owned):
845
+ // no adapter driving, best-effort. It live-applies allowlisted keys
846
+ // (BOT_LIVE_CONFIG_KEYS) and hints
847
+ // reset/restart for everything else (Issue #27 follow-up).
848
+ try {
849
+ await checkAndApplyBotConfigChanges(bot, context, snapshot);
850
+ }
851
+ catch { /* checkAndApply never throws; belt-and-suspenders */ }
360
852
  // 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.
853
+ // (shared 1min tick) is the sole market-adapter spawner. A per-bot drive here would
854
+ // re-drive the adapter N times (once per bot) and fight the wrapper over
855
+ // the adapter child, so bots skip the adapter drive entirely and act as
856
+ // pure adapter-output consumers. The live bot-config check above still
857
+ // ran. Wrapper-less modes (dexbot test one-shot, isolated supervisor,
858
+ // PM2) keep the in-bot fallback below.
367
859
  if (!isPm2Runtime() && isWrapperAdapterOwner()) {
368
860
  return { skipped: true, reason: 'wrapper-owned' };
369
861
  }
@@ -372,9 +864,6 @@ async function syncMarketAdapterOnPeriodicConfigCheck(bot, context = 'periodic')
372
864
  }
373
865
  bot._marketAdapterWatchdogInFlight = true;
374
866
  try {
375
- const snapshot = typeof bot._loadBotsConfigSnapshot === 'function'
376
- ? await bot._loadBotsConfigSnapshot()
377
- : loadBotsConfigSnapshot();
378
867
  // Corrupt/unreadable bots.json must not move the adapter: previously
379
868
  // the parse/read throw aborted the check and left the adapter alone.
380
869
  // Keep that behavior by skipping the sync without touching the stored
@@ -744,22 +1233,7 @@ function performGridResync(bot, options = {}) {
744
1233
  const myName = self.config.name;
745
1234
  const updatedBot = allBotsConfig.find((b) => isSameBotName(b.name, myName));
746
1235
  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');
1236
+ replaceBotConfigFromEntryPreservingRuntime(self, updatedBot, 'grid resync');
763
1237
  }
764
1238
  }
765
1239
  catch (e) {
@@ -977,6 +1451,29 @@ async function performPeriodicGridChecks(bot) {
977
1451
  else {
978
1452
  await runGridMaintenance(bot, 'periodic');
979
1453
  }
1454
+ logDeferredHoldSummary(bot);
1455
+ }
1456
+ /**
1457
+ * Summarize deliberate deferred holds once per count change. The per-order
1458
+ * sync logs report each hold when it first appears; this surfaces the running
1459
+ * total (funds stay locked until an operator clears them) without re-logging
1460
+ * the same count every tick.
1461
+ *
1462
+ * @param {import('./dexbot_class.js').DEXBot} bot
1463
+ */
1464
+ function logDeferredHoldSummary(bot) {
1465
+ const unmatched = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
1466
+ ? bot.manager._lastUnmatchedChainOrders
1467
+ : [];
1468
+ const held = unmatched.filter((u) => isNonBlockingUnmatchedOrder(u)).length;
1469
+ if (held === 0) {
1470
+ bot._lastHeldChainOrderCount = 0;
1471
+ return;
1472
+ }
1473
+ if (held === bot._lastHeldChainOrderCount)
1474
+ return;
1475
+ bot._lastHeldChainOrderCount = held;
1476
+ bot._log?.(`[HOLD] ${held} deferred chain order(s) held outside the active pipeline (funds stay locked until cleared)`, 'warn');
980
1477
  }
981
1478
  /**
982
1479
  * Check if the continuous open-orders sync loop is enabled.
@@ -1217,17 +1714,15 @@ function stopBlockchainFetchInterval(bot) {
1217
1714
  }
1218
1715
  }
1219
1716
  /**
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.
1717
+ * Set up the periodic bots.json poll interval.
1718
+ * Runs in ALL modes (including wrapper-owned monolithic): the tick drives
1719
+ * syncMarketAdapterOnPeriodicConfigCheck, which always runs the live
1720
+ * bot-config check (allowlisted keys applied without restart, Issue #27
1721
+ * follow-up) and additionally drives the market adapter only in
1722
+ * wrapper-less modes (dexbot test one-shot, isolated supervisor, PM2).
1723
+ * Decoupled from the heavy blockchain fetch interval (default 240min) so
1724
+ * config changes are visible within BOTS_CONFIG_POLL_INTERVAL_MS
1725
+ * (default 1min, shared with the wrapper watchdog interval).
1231
1726
  * @param {import('./dexbot_class.js').DEXBot} bot
1232
1727
  */
1233
1728
  function setupBotsConfigPollInterval(bot) {
@@ -1237,13 +1732,14 @@ function setupBotsConfigPollInterval(bot) {
1237
1732
  intervalMs = Number(TIMING.BOTS_CONFIG_POLL_INTERVAL_MS);
1238
1733
  }
1239
1734
  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).');
1735
+ bot._log(`Bots-config poll interval disabled (value: ${intervalMs}). Adapter and live bot-config changes will only be seen on blockchain fetch ticks.`);
1245
1736
  return;
1246
1737
  }
1738
+ // NOTE: no wrapper-owned early return here on purpose. The adapter drive
1739
+ // inside syncMarketAdapterOnPeriodicConfigCheck still skips when the
1740
+ // wrapper owns it, but the live bot-config check at the top of that
1741
+ // function must run for every bot (otherwise monolithic bots would never
1742
+ // pick up bots.json edits until the 240min blockchain fetch tick).
1247
1743
  // Ensure the fingerprint exists before the first interval fires.
1248
1744
  // setupBlockchainFetchInterval already fires syncMarketAdapterOnPeriodicConfigCheck
1249
1745
  // at startup, so this is a fallback for bots that never run the blockchain interval
@@ -1280,7 +1776,7 @@ function setupBotsConfigPollInterval(bot) {
1280
1776
  if (typeof bot._botsConfigPollInterval.unref === 'function') {
1281
1777
  bot._botsConfigPollInterval.unref();
1282
1778
  }
1283
- bot._log(`Started bots-config poll interval: every ${Math.round(intervalMs / 1000)}s (fingerprint check)`);
1779
+ bot._log(`Started bots-config poll interval: every ${Math.round(intervalMs / 1000)}s (adapter fingerprint + live bot-config check)`);
1284
1780
  }
1285
1781
  /**
1286
1782
  * Stop the periodic bots.json fingerprint poll interval.
@@ -1584,7 +2080,9 @@ async function executeMaintenanceLogic(bot, context) {
1584
2080
  if (divergence.needsUpdate) {
1585
2081
  const hasRmsDivergence = !!(divergence.buy.rms || divergence.sell.rms);
1586
2082
  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})`);
2083
+ const buyDir = divergence.buy.shrink ? '/shrink' : '';
2084
+ const sellDir = divergence.sell.shrink ? '/shrink' : '';
2085
+ bot._log(`Grid update triggered by funds during ${context} (buy: ${divergence.buy.ratio}${buyDir}, sell: ${divergence.sell.ratio}${sellDir})`);
1588
2086
  }
1589
2087
  if (hasRmsDivergence) {
1590
2088
  bot._log(`Grid update triggered by structural divergence during ${context}: buy=${Format.formatPrice6(divergence.buy.metric)}, sell=${Format.formatPrice6(divergence.sell.metric)}`);
@@ -1649,10 +2147,14 @@ async function executeMaintenanceLogic(bot, context) {
1649
2147
  const spreadResult = await bot.manager.checkSpreadCondition(BitShares, bot.updateOrdersOnChainPlan.bind(bot));
1650
2148
  if (await bot._abortFlowIfIllegalState(`${context} spread check`))
1651
2149
  return;
1652
- if (spreadResult && spreadResult.ordersPlaced > 0) {
2150
+ const spreadPlaced = Number(spreadResult?.ordersPlaced) || 0;
2151
+ if (spreadPlaced > 0) {
1653
2152
  bot._log(`✓ Spread correction during ${context}: ${spreadResult.ordersPlaced} order(s) placed`);
1654
2153
  await bot._persistAndRecoverIfNeeded();
1655
2154
  }
2155
+ // Persistence watchdog: a correction that keeps placing nothing
2156
+ // while the spread stays wide is a stale grid, not patience.
2157
+ trackOutOfSpreadStaleness(bot, true, spreadPlaced);
1656
2158
  }
1657
2159
  }
1658
2160
  catch (err) {
@@ -1667,6 +2169,82 @@ async function executeMaintenanceLogic(bot, context) {
1667
2169
  }
1668
2170
  }
1669
2171
  }
2172
+ /**
2173
+ * Out-of-spread persistence watchdog: the never-run-stale backstop.
2174
+ *
2175
+ * The spread check retries every pipeline-empty tick (level-triggered), but a
2176
+ * correction can keep producing zero candidates indefinitely (no funded side,
2177
+ * no correctable slots) while the grid sits stale — and the identical-held-
2178
+ * plan suppression blocks fill-less replans until a fresh fill that a stale
2179
+ * grid cannot produce. Time-based (not tick-counted) so it holds for any
2180
+ * maintenance cadence: warn once past SPREAD_STALE_WARN_MS, then request a
2181
+ * structural re-center past SPREAD_STALE_ESCALATE_MS (existing resync guards
2182
+ * dedupe concurrent requests; the re-center moves the boundary, which clears
2183
+ * any held-plan signature). Resets whenever the spread heals or a correction
2184
+ * places orders.
2185
+ * @param {any} bot
2186
+ * @param {boolean} spreadChecked - Whether the spread check ran this tick
2187
+ * @param {number} ordersPlaced - Correction orders placed this tick
2188
+ * @returns {{staleMs: number, escalated: boolean}}
2189
+ */
2190
+ export function trackOutOfSpreadStaleness(bot, spreadChecked, ordersPlaced) {
2191
+ const outOfSpread = Number(bot?.manager?.outOfSpread) || 0;
2192
+ if (ordersPlaced > 0 || outOfSpread === 0) {
2193
+ bot._outOfSpreadSince = 0;
2194
+ bot._outOfSpreadStaleWarned = false;
2195
+ return { staleMs: 0, escalated: false };
2196
+ }
2197
+ if (!spreadChecked) {
2198
+ const since = Number(bot._outOfSpreadSince) || 0;
2199
+ return { staleMs: since > 0 ? Date.now() - since : 0, escalated: false };
2200
+ }
2201
+ const now = Date.now();
2202
+ if (!Number(bot._outOfSpreadSince))
2203
+ bot._outOfSpreadSince = now;
2204
+ const staleMs = now - Number(bot._outOfSpreadSince);
2205
+ const staleMin = Math.round(staleMs / 60000);
2206
+ const warnMs = Number(TIMING?.SPREAD_STALE_WARN_MS) > 0
2207
+ ? Number(TIMING.SPREAD_STALE_WARN_MS)
2208
+ : 10 * 60 * 1000;
2209
+ const escalateMs = Number(TIMING?.SPREAD_STALE_ESCALATE_MS) > 0
2210
+ ? Number(TIMING.SPREAD_STALE_ESCALATE_MS)
2211
+ : 30 * 60 * 1000;
2212
+ if (staleMs >= warnMs && !bot._outOfSpreadStaleWarned) {
2213
+ bot._outOfSpreadStaleWarned = true;
2214
+ bot._log(`[SPREAD-STALE] Spread out of tolerance for ${staleMin}min with no correction placed ` +
2215
+ `(outOfSpread=${outOfSpread}); structural re-center follows if unhealed`, 'warn');
2216
+ }
2217
+ if (staleMs >= escalateMs && typeof bot.manager?.requestStructuralGridResync === 'function') {
2218
+ // Dedicated spread-stale cooldown (NOT BOUNDARY_HOLD_RESYNC_COOLDOWN_MS:
2219
+ // the two watchdogs must tune independently). Falls back to the legacy
2220
+ // boundary-hold key only for operators who overrode it before the split,
2221
+ // then to the 5min default.
2222
+ const cooldownMs = Number(TIMING?.SPREAD_STALE_RESYNC_COOLDOWN_MS) > 0
2223
+ ? Number(TIMING.SPREAD_STALE_RESYNC_COOLDOWN_MS)
2224
+ : Number(TIMING?.BOUNDARY_HOLD_RESYNC_COOLDOWN_MS) > 0
2225
+ ? Number(TIMING.BOUNDARY_HOLD_RESYNC_COOLDOWN_MS)
2226
+ : 5 * 60 * 1000;
2227
+ const lastAt = Number(bot._lastSpreadStaleResyncAt) || 0;
2228
+ if (now - lastAt >= cooldownMs) {
2229
+ bot._lastSpreadStaleResyncAt = now;
2230
+ bot._log(`[SPREAD-STALE] Spread out of tolerance for ${staleMin}min despite corrections; ` +
2231
+ `requesting structural re-center`, 'warn');
2232
+ try {
2233
+ const res = bot.manager.requestStructuralGridResync('spread-stale-persistent', {
2234
+ reason: `Spread out of tolerance for ${staleMin}min with no effective correction (outOfSpread=${outOfSpread})`
2235
+ });
2236
+ res?.catch?.((err) => {
2237
+ bot.manager?.logger?.log?.(`[SPREAD-STALE] Structural re-center request failed: ${getErrorMessage(err)}`, 'error');
2238
+ });
2239
+ }
2240
+ catch (err) {
2241
+ bot.manager?.logger?.log?.(`[SPREAD-STALE] Structural re-center request failed: ${getErrorMessage(err)}`, 'error');
2242
+ }
2243
+ return { staleMs, escalated: true };
2244
+ }
2245
+ }
2246
+ return { staleMs, escalated: false };
2247
+ }
1670
2248
  /**
1671
2249
  * Cancel a single order, deferring on an uncertain daemon broadcast.
1672
2250
  * The credential daemon retries internally only failures that provably never
@@ -2032,6 +2610,7 @@ function drainFillQueueAfterPipelineClear(bot) {
2032
2610
  if (!bot._incomingFillQueue || bot._incomingFillQueue.length === 0)
2033
2611
  return;
2034
2612
  bot._log(`[FILL-QUEUE] Pipeline cleared; draining ${bot._incomingFillQueue.length} deferred fill(s).`, 'info');
2613
+ bot._deferredFillsPending = true;
2035
2614
  scheduleFillConsumerRestartFn(bot, chainOrders);
2036
2615
  }
2037
2616
  catch { }
@@ -2239,6 +2818,14 @@ function markGridActivity(bot, reason = 'activity') {
2239
2818
  */
2240
2819
  function getMetrics(bot) {
2241
2820
  bot.manager?._cleanExpiredLocks?.();
2821
+ const unmatched = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
2822
+ ? bot.manager._lastUnmatchedChainOrders
2823
+ : [];
2824
+ // Deliberate holds (reason suffixed `-deferred`) never block the pipeline,
2825
+ // but they DO lock funds until an operator clears them. Surface the split
2826
+ // so the permanent holds are visible in status/metrics instead of only in
2827
+ // per-order sync logs.
2828
+ const heldUnmatched = unmatched.filter((u) => isNonBlockingUnmatchedOrder(u));
2242
2829
  return {
2243
2830
  ...bot._metrics,
2244
2831
  queueDepth: bot._incomingFillQueue.length,
@@ -2246,7 +2833,10 @@ function getMetrics(bot) {
2246
2833
  divergenceLockActive: bot.manager?._divergenceLock?.isLocked() || false,
2247
2834
  shadowLocksActive: bot.manager?.shadowOrderIds?.size || 0,
2248
2835
  recoveryExhaustedAt: bot.manager?._recoveryExhaustedAt || null,
2249
- recentFillsTracked: bot._recentlyProcessedFills.size
2836
+ recentFillsTracked: bot._recentlyProcessedFills.size,
2837
+ unmatchedChainOrders: unmatched.length,
2838
+ heldChainOrders: heldUnmatched.length,
2839
+ blockingChainOrders: unmatched.length - heldUnmatched.length
2250
2840
  };
2251
2841
  }
2252
2842
  /**
@@ -2321,10 +2911,12 @@ async function syncOpenOrdersAndProcessFillsImpl(bot, tag) {
2321
2911
  return { syncResult: null, aborted: true, hasUnmatched: -1, openOrders: null };
2322
2912
  }
2323
2913
  }
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 };
2914
+ 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
2915
  export default {
2326
2916
  loadBotsConfigSnapshot,
2327
2917
  isWrapperAdapterOwner,
2918
+ checkAndApplyBotConfigChanges,
2919
+ buildBotConfigFingerprint,
2328
2920
  refreshDynamicWeightDistribution,
2329
2921
  performGridResync,
2330
2922
  updateBotGridResetMetadata,
@@ -2339,9 +2931,13 @@ export default {
2339
2931
  setupBotsConfigPollInterval,
2340
2932
  stopBotsConfigPollInterval,
2341
2933
  executeMaintenanceLogic,
2934
+ getTargetedSyncReason,
2935
+ countLiveReserveOrders,
2936
+ maybeRunTargetedDriftReconciliation,
2342
2937
  cancelDustOrders,
2343
2938
  isOrderDoesNotExistError,
2344
2939
  runGridMaintenance,
2940
+ trackOutOfSpreadStaleness,
2345
2941
  stopMarketAdapterPm2,
2346
2942
  releaseMarketAdapterRuntime,
2347
2943
  syncMarketAdapterOnPeriodicConfigCheck,