dexbot 1.6.0 → 1.6.2

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 (114) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +5 -4
  3. package/analysis/ama_fitting/package.json +1 -1
  4. package/analysis/tradingview/tradingview_uplot_chart_generator.ts +19 -5
  5. package/analysis/trend_detection/package.json +1 -1
  6. package/claw/package.json +1 -1
  7. package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
  8. package/claw/runtimes/openclaw-plugin/package.json +1 -1
  9. package/claw/tests/test_claw_mcp_transport.ts +2 -2
  10. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts.map +1 -1
  11. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js +19 -5
  12. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js.map +1 -1
  13. package/dist/dexbot.js +1 -1
  14. package/dist/dexbot.js.map +1 -1
  15. package/dist/market_adapter/inputs/fetch_book_data.js +2 -2
  16. package/dist/market_adapter/inputs/fetch_book_data.js.map +1 -1
  17. package/dist/market_adapter/inputs/fetch_lp_data.d.ts +22 -3
  18. package/dist/market_adapter/inputs/fetch_lp_data.d.ts.map +1 -1
  19. package/dist/market_adapter/inputs/fetch_lp_data.js +15 -15
  20. package/dist/market_adapter/inputs/fetch_lp_data.js.map +1 -1
  21. package/dist/market_adapter/inputs/kibana_feed_source.js +2 -2
  22. package/dist/market_adapter/inputs/kibana_feed_source.js.map +1 -1
  23. package/dist/market_adapter/inputs/window_cache.d.ts +54 -8
  24. package/dist/market_adapter/inputs/window_cache.d.ts.map +1 -1
  25. package/dist/market_adapter/inputs/window_cache.js +328 -87
  26. package/dist/market_adapter/inputs/window_cache.js.map +1 -1
  27. package/dist/modules/account_bots.js +2 -2
  28. package/dist/modules/account_bots.js.map +1 -1
  29. package/dist/modules/bitshares-native/subscriptions.d.ts +1 -1
  30. package/dist/modules/bitshares-native/subscriptions.d.ts.map +1 -1
  31. package/dist/modules/bitshares-native/subscriptions.js +18 -4
  32. package/dist/modules/bitshares-native/subscriptions.js.map +1 -1
  33. package/dist/modules/bitshares_client.d.ts.map +1 -1
  34. package/dist/modules/bitshares_client.js +10 -0
  35. package/dist/modules/bitshares_client.js.map +1 -1
  36. package/dist/modules/constants.d.ts +12 -3
  37. package/dist/modules/constants.d.ts.map +1 -1
  38. package/dist/modules/constants.js +66 -20
  39. package/dist/modules/constants.js.map +1 -1
  40. package/dist/modules/credit_runtime.d.ts.map +1 -1
  41. package/dist/modules/credit_runtime.js +24 -6
  42. package/dist/modules/credit_runtime.js.map +1 -1
  43. package/dist/modules/dexbot_class.d.ts +34 -6
  44. package/dist/modules/dexbot_class.d.ts.map +1 -1
  45. package/dist/modules/dexbot_class.js +94 -17
  46. package/dist/modules/dexbot_class.js.map +1 -1
  47. package/dist/modules/dexbot_cow_runtime.d.ts +57 -6
  48. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  49. package/dist/modules/dexbot_cow_runtime.js +474 -38
  50. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  51. package/dist/modules/dexbot_fill_runtime.d.ts +53 -1
  52. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  53. package/dist/modules/dexbot_fill_runtime.js +277 -9
  54. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  55. package/dist/modules/dexbot_maintenance_runtime.d.ts +69 -0
  56. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  57. package/dist/modules/dexbot_maintenance_runtime.js +277 -7
  58. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  59. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  60. package/dist/modules/dexbot_startup_runtime.js +6 -1
  61. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  62. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  63. package/dist/modules/dexbot_state_recovery.js +32 -4
  64. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  65. package/dist/modules/market_adapter_whitelist.js +1 -1
  66. package/dist/modules/market_adapter_whitelist.js.map +1 -1
  67. package/dist/modules/order/grid.d.ts +3 -1
  68. package/dist/modules/order/grid.d.ts.map +1 -1
  69. package/dist/modules/order/grid.js +93 -16
  70. package/dist/modules/order/grid.js.map +1 -1
  71. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  72. package/dist/modules/order/grid_reconcile_internal.js +73 -7
  73. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  74. package/dist/modules/order/logger.js +1 -1
  75. package/dist/modules/order/logger.js.map +1 -1
  76. package/dist/modules/order/manager.d.ts +14 -1
  77. package/dist/modules/order/manager.d.ts.map +1 -1
  78. package/dist/modules/order/manager.js +54 -2
  79. package/dist/modules/order/manager.js.map +1 -1
  80. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  81. package/dist/modules/order/sync_engine.js +183 -10
  82. package/dist/modules/order/sync_engine.js.map +1 -1
  83. package/dist/modules/order/utils/math.d.ts +65 -15
  84. package/dist/modules/order/utils/math.d.ts.map +1 -1
  85. package/dist/modules/order/utils/math.js +93 -22
  86. package/dist/modules/order/utils/math.js.map +1 -1
  87. package/dist/modules/order/utils/order.d.ts +51 -1
  88. package/dist/modules/order/utils/order.d.ts.map +1 -1
  89. package/dist/modules/order/utils/order.js +122 -2
  90. package/dist/modules/order/utils/order.js.map +1 -1
  91. package/dist/modules/utils/errors.d.ts +22 -0
  92. package/dist/modules/utils/errors.d.ts.map +1 -1
  93. package/dist/modules/utils/errors.js +30 -0
  94. package/dist/modules/utils/errors.js.map +1 -1
  95. package/dist/scripts/generate_market_adapter_whitelist.d.ts.map +1 -1
  96. package/dist/scripts/generate_market_adapter_whitelist.js +3 -2
  97. package/dist/scripts/generate_market_adapter_whitelist.js.map +1 -1
  98. package/docs/BITSHARES_ONBOARDING.md +14 -6
  99. package/docs/COPY_ON_WRITE_MASTER_PLAN.md +1 -1
  100. package/docs/DEXBOT_COMPARISON.md +3 -7
  101. package/docs/EVOLUTION.md +9 -7
  102. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +4 -9
  103. package/docs/GRID_RECALCULATION.md +8 -7
  104. package/docs/LIFECYCLE.md +2 -2
  105. package/docs/README.md +9 -1
  106. package/docs/architecture.md +7 -7
  107. package/docs/developer_guide.md +4 -4
  108. package/market_adapter/README.md +14 -11
  109. package/package.json +1 -1
  110. package/scripts/README.md +4 -4
  111. package/analysis/results/ama_sweep_results_lp_pool_133_1h.json +0 -2455
  112. package/analysis/results/bot_fitting_results_lp_pool_133_1h.json +0 -218
  113. package/analysis/tradingview/h-bts_tradingview.html +0 -1570
  114. package/analysis/tradingview/t-bts_tradingview.html +0 -1570
@@ -64,11 +64,13 @@ function liveWindowIdSet(...args) { return require('./order/utils/order').liveWi
64
64
  function resolveLiveReserveEdgeAnchorPrice(...args) { return require('./order/utils/order').resolveLiveReserveEdgeAnchorPrice(...args); }
65
65
  function formatUnmatchedChainOrder(...args) { return require('./order/utils/order').formatUnmatchedChainOrder(...args); }
66
66
  function isNonBlockingUnmatchedOrder(...args) { return require('./order/utils/order').isNonBlockingUnmatchedOrder(...args); }
67
+ function isStrandedHoldOrder(...args) { return require('./order/utils/order').isStrandedHoldOrder(...args); }
67
68
  function getSideBudget(...args) { return require('./order/utils/order').getSideBudget(...args); }
68
69
  function getActiveOrdersTotal(config) { return require('./order/utils/order').getActiveOrdersTotal(config); }
69
70
  function correctAllPriceMismatches(...args) { return require('./order/utils/order').correctAllPriceMismatches(...args); }
70
71
  function isOrderOnChain(...args) { return require('./order/utils/order').isOrderOnChain(...args); }
71
72
  function parseChainOrder(...args) { return require('./order/utils/order').parseChainOrder(...args); }
73
+ function parseSlotIndex(...args) { return require('./order/utils/order').parseSlotIndex(...args); }
72
74
  const CODE_ROOT = path.join(__dirname, '..');
73
75
  const PROFILES_DIR = PATHS.PROFILES_DIR;
74
76
  const PROFILES_BOTS_FILE = PATHS.PROFILES.BOTS_JSON;
@@ -521,7 +523,13 @@ function countLiveGridOrders(manager, type) {
521
523
  return 0;
522
524
  const active = manager.getOrdersByTypeAndState?.(type, ORDER_STATES.ACTIVE) || [];
523
525
  const partial = manager.getOrdersByTypeAndState?.(type, ORDER_STATES.PARTIAL) || [];
524
- return active.concat(partial).filter((o) => o?.orderId).length;
526
+ // Slot-N gated: fork-kept shelf/manual orders (non-slot-N ids, e.g.
527
+ // deep-*) sit outside window accounting — same gate as reserve
528
+ // classification and startup cancel candidates (issue #27 follow-up).
529
+ // Without this, a live shelf inflates the live window+reserves count and
530
+ // masks a real window/reserve shortfall, so targeted sync never fires.
531
+ // No-op on grids that only mint slot-N ids.
532
+ return active.concat(partial).filter((o) => o?.orderId && parseSlotIndex(o?.id) !== null).length;
525
533
  }
526
534
  function getTargetActiveOrders(config, side) {
527
535
  const configured = Number(config?.activeOrders?.[side]);
@@ -1452,21 +1460,201 @@ async function performPeriodicGridChecks(bot) {
1452
1460
  * total (funds stay locked until an operator clears them) without re-logging
1453
1461
  * the same count every tick.
1454
1462
  *
1463
+ * The detail line names WHY each hold is held and how far it sits from the
1464
+ * grid, because "held" alone is ambiguous between deliberate deferral and a
1465
+ * stuck bot. A slow re-warn (rate-limited) additionally distinguishes a hold
1466
+ * that is still being actioned from one that has been static for a long time.
1467
+ *
1455
1468
  * @param {import('./dexbot_class.js').DEXBot} bot
1456
1469
  */
1457
1470
  function logDeferredHoldSummary(bot) {
1458
1471
  const unmatched = Array.isArray(bot.manager?._lastUnmatchedChainOrders)
1459
1472
  ? bot.manager._lastUnmatchedChainOrders
1460
1473
  : [];
1461
- const held = unmatched.filter((u) => isNonBlockingUnmatchedOrder(u)).length;
1474
+ const heldOrders = unmatched.filter((u) => isNonBlockingUnmatchedOrder(u));
1475
+ const held = heldOrders.length;
1462
1476
  if (held === 0) {
1463
- bot._lastHeldChainOrderCount = 0;
1477
+ bot._lastHeldChainOrderSignature = '';
1478
+ bot._lastHeldChainOrderWarnAt = 0;
1479
+ bot._lastHeldChainOrderSignatureSince = 0;
1480
+ bot._strandedHoldSince = new Map();
1481
+ return;
1482
+ }
1483
+ // Per-order age tracking for the stranded subset.
1484
+ //
1485
+ // The whole-set signature below includes every entry's reason, so an
1486
+ // UNRELATED hold flapping in and out (e.g. a per-sync
1487
+ // `broadcast-active-deferred`) changes the signature and restarts the age
1488
+ // clock every cycle. A genuinely stranded order then never reaches the
1489
+ // escalation age -- it is starved forever by churn it has nothing to do
1490
+ // with. Verified by simulation: with the whole-set clock, a stranded
1491
+ // order plus a 6-hourly flap reported `CLOCK RESET` on every single tick
1492
+ // and never accumulated age.
1493
+ //
1494
+ // So age is tracked per stranded order identity (id@price/size), keyed off
1495
+ // when THAT order was first seen stranded. Unrelated churn cannot touch it.
1496
+ if (!(bot._strandedHoldSince instanceof Map))
1497
+ bot._strandedHoldSince = new Map();
1498
+ const strandedHoldSince = bot._strandedHoldSince;
1499
+ const stranded = heldOrders.filter((u) => isStrandedHoldOrder(u));
1500
+ const seenKeys = new Set();
1501
+ const now = Date.now();
1502
+ let oldestStrandedMs = 0;
1503
+ for (const u of stranded) {
1504
+ const key = `${u?.chainOrderId ?? '?'}@${u?.price ?? '?'}/${u?.size ?? '?'}:${u?.reason ?? '?'}`;
1505
+ seenKeys.add(key);
1506
+ if (!strandedHoldSince.has(key))
1507
+ strandedHoldSince.set(key, now);
1508
+ oldestStrandedMs = Math.max(oldestStrandedMs, now - Number(strandedHoldSince.get(key)));
1509
+ }
1510
+ // Drop entries that are no longer stranded, so a slot that clears and is
1511
+ // later re-held starts a fresh age rather than inheriting the old one.
1512
+ for (const key of Array.from(strandedHoldSince.keys())) {
1513
+ if (!seenKeys.has(key))
1514
+ strandedHoldSince.delete(key);
1515
+ }
1516
+ const signature = heldOrders
1517
+ .map((u) => `${u?.chainOrderId ?? '?'}@${u?.price ?? '?'}/${u?.size ?? '?'}:${u?.reason ?? '?'}`)
1518
+ .sort()
1519
+ .join(',');
1520
+ // A count-only change gate hid same-count churn (one hold clearing while
1521
+ // another appeared looked like "no change" and was never logged). The
1522
+ // signature covers membership and terms, so any real change re-logs once.
1523
+ if (signature !== bot._lastHeldChainOrderSignature) {
1524
+ bot._lastHeldChainOrderSignature = signature;
1525
+ bot._lastHeldChainOrderWarnAt = Date.now();
1526
+ // Restart the whole-set logging clock: the signature changed, so this
1527
+ // is a new hold situation and the running total is worth re-logging.
1528
+ // NOTE: this clock drives LOGGING ONLY. Escalation below uses the
1529
+ // per-order stranded ages, because a signature change caused by
1530
+ // unrelated churn must not suppress escalation -- returning early here
1531
+ // (as an earlier revision did) meant any flapping hold skipped the
1532
+ // escalation call entirely, so a genuinely stranded order never
1533
+ // escalated at all.
1534
+ bot._lastHeldChainOrderSignatureSince = Date.now();
1535
+ bot._log?.(`[HOLD] ${held} deferred chain order(s) held outside the active pipeline ` +
1536
+ `(funds stay locked until cleared): ${describeDeferredHolds(bot, heldOrders)}`, 'warn');
1537
+ // Escalation is NOT gated on an unchanged signature: a stranded order
1538
+ // whose age has crossed the threshold must escalate even if an
1539
+ // unrelated transient hold is flapping in and out alongside it.
1540
+ considerDeferredHoldEscalation(bot, stranded, oldestStrandedMs);
1541
+ return;
1542
+ }
1543
+ // The signature is unchanged.
1544
+ if (!(Number(bot._lastHeldChainOrderSignatureSince) > 0)) {
1545
+ bot._lastHeldChainOrderSignatureSince = Date.now();
1546
+ }
1547
+ // Escalate on the STRANDED subset's own age, not the whole held set's.
1548
+ considerDeferredHoldEscalation(bot, stranded, oldestStrandedMs);
1549
+ // Unchanged set: re-warn slowly so an indefinitely-held order is visibly
1550
+ // still held rather than silently indistinguishable from a stalled loop.
1551
+ const warnEvery = Number(TIMING.STALE_TOTALS_WARN_RATE_LIMIT_MS) || 60000;
1552
+ const sinceWarn = Date.now() - (Number(bot._lastHeldChainOrderWarnAt) || 0);
1553
+ if (sinceWarn >= warnEvery) {
1554
+ bot._lastHeldChainOrderWarnAt = Date.now();
1555
+ // Age from the SIGNATURE-STABLE clock, not `_lastUnmatchedChainOrdersAt`
1556
+ // (which refresh on every observing sync and would report "~0m" for a
1557
+ // hold that has really been stuck for hours).
1558
+ const heldSince = Number(bot._lastHeldChainOrderSignatureSince) || 0;
1559
+ const ageMin = heldSince > 0 ? Math.round((Date.now() - heldSince) / 60000) : 0;
1560
+ bot._log?.(`[HOLD] still holding ${held} deferred chain order(s) for ~${ageMin}m ` +
1561
+ `with no change (funds stay locked until cleared): ${describeDeferredHolds(bot, heldOrders)}`, 'warn');
1562
+ }
1563
+ }
1564
+ /**
1565
+ * Escalate an INDEFINITELY-HELD deferred hold to a structural resync.
1566
+ *
1567
+ * Out-of-rail orphans hold locked funds and are never auto-cancelled per cycle.
1568
+ * That default is correct -- cancelling on ambiguous evidence is irreversible --
1569
+ * but it left "held indefinitely" with no exit. A hold that survives unchanged
1570
+ * for `DEFERRED_HOLD_ESCALATE_MS` is no longer ambiguous evidence: the grid has
1571
+ * had ample opportunity to resolve it and has not.
1572
+ *
1573
+ * The structural resync is the appropriate exit because the full reset's
1574
+ * reconcile is update-first: unmatched chain orders are price-updated onto rail
1575
+ * slots (emitting the rail's genesis level, so the reconcile-update guard does
1576
+ * not block it) and only true surplus is cancelled. Funds are released without
1577
+ * introducing any new cancellation policy.
1578
+ *
1579
+ * Fire-and-forget; the resync is itself debounced and batch-in-flight aware.
1580
+ */
1581
+ function considerDeferredHoldEscalation(bot, strandedOrders, strandedMs) {
1582
+ const escalateMs = Number(TIMING?.DEFERRED_HOLD_ESCALATE_MS) > 0
1583
+ ? Number(TIMING.DEFERRED_HOLD_ESCALATE_MS)
1584
+ : 24 * 60 * 60 * 1000;
1585
+ const held = strandedOrders.length;
1586
+ if (held === 0)
1587
+ return;
1588
+ // Age is supplied by the caller: the oldest STRANDED order's age, measured
1589
+ // from when that order was first seen stranded (per-order clock). It is
1590
+ // deliberately NOT the whole held-set signature age, which unrelated
1591
+ // flapping holds reset every cycle and which would starve escalation.
1592
+ const heldMs = Number(strandedMs) || 0;
1593
+ if (heldMs < escalateMs)
1594
+ return;
1595
+ if (typeof bot?.manager?.requestStructuralGridResync !== 'function') {
1596
+ bot?._log?.(`[HOLD] ${held} deferred chain order(s) held unchanged for ${Math.round(heldMs / 3600000)}h ` +
1597
+ `but requestStructuralGridResync is unavailable; funds stay locked`, 'error');
1464
1598
  return;
1465
1599
  }
1466
- if (held === bot._lastHeldChainOrderCount)
1600
+ const cooldownMs = Number(TIMING?.DEFERRED_HOLD_RESYNC_COOLDOWN_MS) > 0
1601
+ ? Number(TIMING.DEFERRED_HOLD_RESYNC_COOLDOWN_MS)
1602
+ : 6 * 60 * 60 * 1000;
1603
+ const now = Date.now();
1604
+ const lastAt = Number(bot._lastDeferredHoldResyncAt) || 0;
1605
+ if (now - lastAt < cooldownMs)
1467
1606
  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');
1607
+ bot._lastDeferredHoldResyncAt = now;
1608
+ const heldHours = Math.round(heldMs / 3600000);
1609
+ bot?._log?.(`[HOLD] ${held} deferred chain order(s) held unchanged for ~${heldHours}h; ` +
1610
+ `requesting structural resync to resolve the hold (reconcile is update-first, ` +
1611
+ `so funds are released by price-updating onto rail slots) — ` +
1612
+ `${describeDeferredHolds(bot, strandedOrders)}`, 'error');
1613
+ try {
1614
+ const res = bot.manager.requestStructuralGridResync('deferred-hold-stale', {
1615
+ reason: `${held} deferred chain order(s) held unchanged for ~${heldHours}h`,
1616
+ heldCount: held,
1617
+ heldMs,
1618
+ });
1619
+ res?.catch?.((err) => {
1620
+ bot.manager?.logger?.log?.(`[HOLD] Structural resync for stale hold failed: ${getErrorMessage(err)}`, 'error');
1621
+ });
1622
+ }
1623
+ catch (err) {
1624
+ bot.manager?.logger?.log?.(`[HOLD] Structural resync for stale hold failed: ${getErrorMessage(err)}`, 'error');
1625
+ }
1626
+ }
1627
+ /**
1628
+ * Render the per-order detail for a deferred-hold summary: side, price, size,
1629
+ * why it is held, and distance from the nearest grid bound when known.
1630
+ *
1631
+ * Distance is expressed as a percentage of the bound so the operator can tell
1632
+ * a near-miss (a few bps outside, likely a rounding/drift artifact) from a
1633
+ * deliberately-placed far order (dip protection after a grid reset).
1634
+ */
1635
+ function describeDeferredHolds(bot, heldOrders) {
1636
+ const genesis = bot.manager?._genesis;
1637
+ const levels = Array.isArray(genesis?.priceLevels) ? genesis.priceLevels : [];
1638
+ const lower = levels.length > 0 ? Number(levels[0]) : NaN;
1639
+ const upper = levels.length > 0 ? Number(levels[levels.length - 1]) : NaN;
1640
+ return heldOrders
1641
+ .map((u) => {
1642
+ const side = u?.type === ORDER_TYPES.SELL ? 'sell' : u?.type === ORDER_TYPES.BUY ? 'buy' : 'unknown';
1643
+ const price = Number(u?.price);
1644
+ const size = u?.size != null ? u.size : '?';
1645
+ const reason = u?.reason || 'unspecified';
1646
+ const parts = [`${u?.chainOrderId ?? '?'} ${side} price=${Number.isFinite(price) ? price : '?'} size=${size} reason=${reason}`];
1647
+ if (Number.isFinite(price)) {
1648
+ if (Number.isFinite(lower) && price < lower) {
1649
+ parts.push(`below-grid by ${(((lower - price) / lower) * 100).toFixed(4)}% (bound ${lower})`);
1650
+ }
1651
+ else if (Number.isFinite(upper) && price > upper) {
1652
+ parts.push(`above-grid by ${(((price - upper) / upper) * 100).toFixed(4)}% (bound ${upper})`);
1653
+ }
1654
+ }
1655
+ return parts.join(' ');
1656
+ })
1657
+ .join(' | ');
1470
1658
  }
1471
1659
  /**
1472
1660
  * Check if the continuous open-orders sync loop is enabled.
@@ -2140,10 +2328,14 @@ async function executeMaintenanceLogic(bot, context) {
2140
2328
  const spreadResult = await bot.manager.checkSpreadCondition(BitShares, bot.updateOrdersOnChainPlan.bind(bot));
2141
2329
  if (await bot._abortFlowIfIllegalState(`${context} spread check`))
2142
2330
  return;
2143
- if (spreadResult && spreadResult.ordersPlaced > 0) {
2331
+ const spreadPlaced = Number(spreadResult?.ordersPlaced) || 0;
2332
+ if (spreadPlaced > 0) {
2144
2333
  bot._log(`✓ Spread correction during ${context}: ${spreadResult.ordersPlaced} order(s) placed`);
2145
2334
  await bot._persistAndRecoverIfNeeded();
2146
2335
  }
2336
+ // Persistence watchdog: a correction that keeps placing nothing
2337
+ // while the spread stays wide is a stale grid, not patience.
2338
+ trackOutOfSpreadStaleness(bot, true, spreadPlaced);
2147
2339
  }
2148
2340
  }
2149
2341
  catch (err) {
@@ -2158,6 +2350,82 @@ async function executeMaintenanceLogic(bot, context) {
2158
2350
  }
2159
2351
  }
2160
2352
  }
2353
+ /**
2354
+ * Out-of-spread persistence watchdog: the never-run-stale backstop.
2355
+ *
2356
+ * The spread check retries every pipeline-empty tick (level-triggered), but a
2357
+ * correction can keep producing zero candidates indefinitely (no funded side,
2358
+ * no correctable slots) while the grid sits stale — and the identical-held-
2359
+ * plan suppression blocks fill-less replans until a fresh fill that a stale
2360
+ * grid cannot produce. Time-based (not tick-counted) so it holds for any
2361
+ * maintenance cadence: warn once past SPREAD_STALE_WARN_MS, then request a
2362
+ * structural re-center past SPREAD_STALE_ESCALATE_MS (existing resync guards
2363
+ * dedupe concurrent requests; the re-center moves the boundary, which clears
2364
+ * any held-plan signature). Resets whenever the spread heals or a correction
2365
+ * places orders.
2366
+ * @param {any} bot
2367
+ * @param {boolean} spreadChecked - Whether the spread check ran this tick
2368
+ * @param {number} ordersPlaced - Correction orders placed this tick
2369
+ * @returns {{staleMs: number, escalated: boolean}}
2370
+ */
2371
+ export function trackOutOfSpreadStaleness(bot, spreadChecked, ordersPlaced) {
2372
+ const outOfSpread = Number(bot?.manager?.outOfSpread) || 0;
2373
+ if (ordersPlaced > 0 || outOfSpread === 0) {
2374
+ bot._outOfSpreadSince = 0;
2375
+ bot._outOfSpreadStaleWarned = false;
2376
+ return { staleMs: 0, escalated: false };
2377
+ }
2378
+ if (!spreadChecked) {
2379
+ const since = Number(bot._outOfSpreadSince) || 0;
2380
+ return { staleMs: since > 0 ? Date.now() - since : 0, escalated: false };
2381
+ }
2382
+ const now = Date.now();
2383
+ if (!Number(bot._outOfSpreadSince))
2384
+ bot._outOfSpreadSince = now;
2385
+ const staleMs = now - Number(bot._outOfSpreadSince);
2386
+ const staleMin = Math.round(staleMs / 60000);
2387
+ const warnMs = Number(TIMING?.SPREAD_STALE_WARN_MS) > 0
2388
+ ? Number(TIMING.SPREAD_STALE_WARN_MS)
2389
+ : 10 * 60 * 1000;
2390
+ const escalateMs = Number(TIMING?.SPREAD_STALE_ESCALATE_MS) > 0
2391
+ ? Number(TIMING.SPREAD_STALE_ESCALATE_MS)
2392
+ : 30 * 60 * 1000;
2393
+ if (staleMs >= warnMs && !bot._outOfSpreadStaleWarned) {
2394
+ bot._outOfSpreadStaleWarned = true;
2395
+ bot._log(`[SPREAD-STALE] Spread out of tolerance for ${staleMin}min with no correction placed ` +
2396
+ `(outOfSpread=${outOfSpread}); structural re-center follows if unhealed`, 'warn');
2397
+ }
2398
+ if (staleMs >= escalateMs && typeof bot.manager?.requestStructuralGridResync === 'function') {
2399
+ // Dedicated spread-stale cooldown (NOT BOUNDARY_HOLD_RESYNC_COOLDOWN_MS:
2400
+ // the two watchdogs must tune independently). Falls back to the legacy
2401
+ // boundary-hold key only for operators who overrode it before the split,
2402
+ // then to the 5min default.
2403
+ const cooldownMs = Number(TIMING?.SPREAD_STALE_RESYNC_COOLDOWN_MS) > 0
2404
+ ? Number(TIMING.SPREAD_STALE_RESYNC_COOLDOWN_MS)
2405
+ : Number(TIMING?.BOUNDARY_HOLD_RESYNC_COOLDOWN_MS) > 0
2406
+ ? Number(TIMING.BOUNDARY_HOLD_RESYNC_COOLDOWN_MS)
2407
+ : 5 * 60 * 1000;
2408
+ const lastAt = Number(bot._lastSpreadStaleResyncAt) || 0;
2409
+ if (now - lastAt >= cooldownMs) {
2410
+ bot._lastSpreadStaleResyncAt = now;
2411
+ bot._log(`[SPREAD-STALE] Spread out of tolerance for ${staleMin}min despite corrections; ` +
2412
+ `requesting structural re-center`, 'warn');
2413
+ try {
2414
+ const res = bot.manager.requestStructuralGridResync('spread-stale-persistent', {
2415
+ reason: `Spread out of tolerance for ${staleMin}min with no effective correction (outOfSpread=${outOfSpread})`
2416
+ });
2417
+ res?.catch?.((err) => {
2418
+ bot.manager?.logger?.log?.(`[SPREAD-STALE] Structural re-center request failed: ${getErrorMessage(err)}`, 'error');
2419
+ });
2420
+ }
2421
+ catch (err) {
2422
+ bot.manager?.logger?.log?.(`[SPREAD-STALE] Structural re-center request failed: ${getErrorMessage(err)}`, 'error');
2423
+ }
2424
+ return { staleMs, escalated: true };
2425
+ }
2426
+ }
2427
+ return { staleMs, escalated: false };
2428
+ }
2161
2429
  /**
2162
2430
  * Cancel a single order, deferring on an uncertain daemon broadcast.
2163
2431
  * The credential daemon retries internally only failures that provably never
@@ -2850,6 +3118,7 @@ export default {
2850
3118
  cancelDustOrders,
2851
3119
  isOrderDoesNotExistError,
2852
3120
  runGridMaintenance,
3121
+ trackOutOfSpreadStaleness,
2853
3122
  stopMarketAdapterPm2,
2854
3123
  releaseMarketAdapterRuntime,
2855
3124
  syncMarketAdapterOnPeriodicConfigCheck,
@@ -2866,5 +3135,6 @@ export default {
2866
3135
  markGridActivity,
2867
3136
  getMetrics,
2868
3137
  syncOpenOrdersAndProcessFills,
3138
+ _internalDeferredHold: { logDeferredHoldSummary, describeDeferredHolds, considerDeferredHoldEscalation },
2869
3139
  };
2870
3140
  //# sourceMappingURL=dexbot_maintenance_runtime.js.map