dexbot 1.6.1 → 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.
- package/CHANGELOG.md +20 -0
- package/analysis/ama_fitting/package.json +1 -1
- package/analysis/tradingview/tradingview_uplot_chart_generator.ts +19 -5
- package/analysis/trend_detection/package.json +1 -1
- package/claw/package.json +1 -1
- package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
- package/claw/runtimes/openclaw-plugin/package.json +1 -1
- package/claw/tests/test_claw_mcp_transport.ts +2 -2
- package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts.map +1 -1
- package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js +19 -5
- package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js.map +1 -1
- package/dist/market_adapter/inputs/fetch_book_data.js +2 -2
- package/dist/market_adapter/inputs/fetch_book_data.js.map +1 -1
- package/dist/market_adapter/inputs/fetch_lp_data.d.ts +22 -3
- package/dist/market_adapter/inputs/fetch_lp_data.d.ts.map +1 -1
- package/dist/market_adapter/inputs/fetch_lp_data.js +15 -15
- package/dist/market_adapter/inputs/fetch_lp_data.js.map +1 -1
- package/dist/market_adapter/inputs/kibana_feed_source.js +2 -2
- package/dist/market_adapter/inputs/kibana_feed_source.js.map +1 -1
- package/dist/market_adapter/inputs/window_cache.d.ts +54 -8
- package/dist/market_adapter/inputs/window_cache.d.ts.map +1 -1
- package/dist/market_adapter/inputs/window_cache.js +328 -87
- package/dist/market_adapter/inputs/window_cache.js.map +1 -1
- package/dist/modules/bitshares-native/subscriptions.d.ts +1 -1
- package/dist/modules/bitshares-native/subscriptions.d.ts.map +1 -1
- package/dist/modules/bitshares-native/subscriptions.js +18 -4
- package/dist/modules/bitshares-native/subscriptions.js.map +1 -1
- package/dist/modules/bitshares_client.d.ts.map +1 -1
- package/dist/modules/bitshares_client.js +10 -0
- package/dist/modules/bitshares_client.js.map +1 -1
- package/dist/modules/constants.d.ts +7 -3
- package/dist/modules/constants.d.ts.map +1 -1
- package/dist/modules/constants.js +44 -20
- package/dist/modules/constants.js.map +1 -1
- package/dist/modules/credit_runtime.d.ts.map +1 -1
- package/dist/modules/credit_runtime.js +24 -6
- package/dist/modules/credit_runtime.js.map +1 -1
- package/dist/modules/dexbot_class.d.ts +31 -6
- package/dist/modules/dexbot_class.d.ts.map +1 -1
- package/dist/modules/dexbot_class.js +55 -11
- package/dist/modules/dexbot_class.js.map +1 -1
- package/dist/modules/dexbot_cow_runtime.d.ts +57 -6
- package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_cow_runtime.js +474 -38
- package/dist/modules/dexbot_cow_runtime.js.map +1 -1
- package/dist/modules/dexbot_fill_runtime.d.ts +28 -1
- package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_fill_runtime.js +116 -3
- package/dist/modules/dexbot_fill_runtime.js.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.d.ts +46 -0
- package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.js +187 -5
- package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
- package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_startup_runtime.js +6 -1
- package/dist/modules/dexbot_startup_runtime.js.map +1 -1
- package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
- package/dist/modules/dexbot_state_recovery.js +16 -3
- package/dist/modules/dexbot_state_recovery.js.map +1 -1
- package/dist/modules/order/grid.d.ts +3 -1
- package/dist/modules/order/grid.d.ts.map +1 -1
- package/dist/modules/order/grid.js +47 -6
- package/dist/modules/order/grid.js.map +1 -1
- package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
- package/dist/modules/order/grid_reconcile_internal.js +39 -4
- package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
- package/dist/modules/order/sync_engine.d.ts.map +1 -1
- package/dist/modules/order/sync_engine.js +183 -10
- package/dist/modules/order/sync_engine.js.map +1 -1
- package/dist/modules/order/utils/math.d.ts +60 -14
- package/dist/modules/order/utils/math.d.ts.map +1 -1
- package/dist/modules/order/utils/math.js +87 -20
- package/dist/modules/order/utils/math.js.map +1 -1
- package/dist/modules/order/utils/order.d.ts +51 -1
- package/dist/modules/order/utils/order.d.ts.map +1 -1
- package/dist/modules/order/utils/order.js +118 -1
- package/dist/modules/order/utils/order.js.map +1 -1
- package/dist/modules/utils/errors.d.ts +22 -0
- package/dist/modules/utils/errors.d.ts.map +1 -1
- package/dist/modules/utils/errors.js +30 -0
- package/dist/modules/utils/errors.js.map +1 -1
- package/docs/COPY_ON_WRITE_MASTER_PLAN.md +1 -1
- package/docs/DEXBOT_COMPARISON.md +3 -7
- package/docs/EVOLUTION.md +8 -7
- package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +4 -9
- package/docs/LIFECYCLE.md +2 -2
- package/docs/README.md +9 -1
- package/docs/architecture.md +7 -7
- package/docs/developer_guide.md +4 -4
- package/market_adapter/README.md +1 -1
- package/package.json +1 -1
|
@@ -76,7 +76,7 @@ import * as FormatModule from './order/format.js';
|
|
|
76
76
|
const Format = FormatModule;
|
|
77
77
|
import * as workingGridModule from './order/working_grid.js';
|
|
78
78
|
const { WorkingGrid } = workingGridModule;
|
|
79
|
-
import { getErrorMessage } from './utils/errors.js';
|
|
79
|
+
import { getErrorMessage, resolveSeamMs, resolveSeamMsOrNull } from './utils/errors.js';
|
|
80
80
|
// Maximum number of times the pre-broadcast staleness guard may re-plan the
|
|
81
81
|
// batch from a fresh master before proceeding anyway. Bounded so a master
|
|
82
82
|
// grid that keeps mutating (fill bursts, sync loops) can never livelock the
|
|
@@ -1286,11 +1286,11 @@ function formatPartialBroadcastSummary(err) {
|
|
|
1286
1286
|
return `${broadcast ?? '?'}/${total ?? '?'} groups broadcast`;
|
|
1287
1287
|
}
|
|
1288
1288
|
/**
|
|
1289
|
-
* Execute a batch with retry-on-uncertain semantics, enforcing a
|
|
1290
|
-
* operation cap (
|
|
1291
|
-
* operations than the cap, it is split into sequential broadcast chunks
|
|
1292
|
-
* most `maxOps` operations each, so a single on-chain transaction never
|
|
1293
|
-
* more than
|
|
1289
|
+
* Execute a batch with retry-on-uncertain semantics, enforcing a gap-slot
|
|
1290
|
+
* per-broadcast operation cap (_getGapSlotBatchSize). When the batch carries
|
|
1291
|
+
* more operations than the cap, it is split into sequential broadcast chunks
|
|
1292
|
+
* of at most `maxOps` operations each, so a single on-chain transaction never
|
|
1293
|
+
* holds more than gapSlots order operations (the original "N fills
|
|
1294
1294
|
* per broadcast" intent, applied at the op level rather than the fill level).
|
|
1295
1295
|
*
|
|
1296
1296
|
* Failure isolation — no swallowed orders: if one chunk's broadcast is
|
|
@@ -1316,12 +1316,11 @@ function formatPartialBroadcastSummary(err) {
|
|
|
1316
1316
|
* @returns {Promise<{result: Object, opContexts: Array}>}
|
|
1317
1317
|
*/
|
|
1318
1318
|
async function executeChunkedWithRetryOnUncertain(bot, operations, opContexts) {
|
|
1319
|
-
const
|
|
1319
|
+
const fromAccessor = typeof bot._getMaxOpsPerBroadcast === 'function'
|
|
1320
1320
|
? bot._getMaxOpsPerBroadcast()
|
|
1321
|
-
:
|
|
1322
|
-
const
|
|
1323
|
-
|
|
1324
|
-
: 1;
|
|
1321
|
+
: (typeof bot._getGapSlotBatchSize === 'function' ? bot._getGapSlotBatchSize() : undefined);
|
|
1322
|
+
const requested = Number(fromAccessor);
|
|
1323
|
+
const maxOps = Number.isFinite(requested) && requested >= 1 ? Math.floor(requested) : 1;
|
|
1325
1324
|
if (!Array.isArray(operations) || operations.length <= maxOps) {
|
|
1326
1325
|
return await executeWithRetryOnUncertain(bot, operations, opContexts);
|
|
1327
1326
|
}
|
|
@@ -1332,7 +1331,7 @@ async function executeChunkedWithRetryOnUncertain(bot, operations, opContexts) {
|
|
|
1332
1331
|
opContexts: opContexts.slice(i, i + maxOps),
|
|
1333
1332
|
});
|
|
1334
1333
|
}
|
|
1335
|
-
bot.manager.logger.log(`[COW] Splitting ${operations.length} operations into ${chunks.length} broadcast chunk(s) of at most ${maxOps} ops each (
|
|
1334
|
+
bot.manager.logger.log(`[COW] Splitting ${operations.length} operations into ${chunks.length} broadcast chunk(s) of at most ${maxOps} ops each (gap-slot batch size).`, 'info');
|
|
1336
1335
|
const mergedOperationResults = [];
|
|
1337
1336
|
const mergedRawResults = [];
|
|
1338
1337
|
const mergedContexts = [];
|
|
@@ -1609,6 +1608,192 @@ function resolveIdealSizeForValidation(_bot, orderLike, fallbackSize = null) {
|
|
|
1609
1608
|
function validateOrderSizeForExecution(bot, size, type, orderLike = null, fallbackSize = null) {
|
|
1610
1609
|
return validateOrderSize(size, type, bot.manager.assets, bot.config.gridLimits?.MIN_ORDER_SIZE_FACTOR, resolveIdealSizeForValidation(bot, orderLike, fallbackSize), bot.config.gridLimits?.PARTIAL_DUST_THRESHOLD_PERCENTAGE);
|
|
1611
1610
|
}
|
|
1611
|
+
function checkGridPriceInvariant(slotId, price, genesis) {
|
|
1612
|
+
return orderUtils.checkGridPriceInvariant(slotId, price, genesis);
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Derive the authoritative price for a rotation destination slot.
|
|
1616
|
+
*
|
|
1617
|
+
* A rotation re-prices to the destination slot, so the destination's genesis
|
|
1618
|
+
* level is the authoritative price — not `action.newPrice`, which the planner
|
|
1619
|
+
* copies from the destination hole's `order.price` and which is therefore only
|
|
1620
|
+
* as sound as whatever last wrote that object. Deriving the emitted price here
|
|
1621
|
+
* means a planner bug cannot produce a mis-priced UPDATE on its own; the
|
|
1622
|
+
* invariant check remains as the backstop for the no-genesis case.
|
|
1623
|
+
*
|
|
1624
|
+
* @returns {number} the destination's genesis level, or NaN when there is no
|
|
1625
|
+
* genesis ladder / no parseable destination index (caller falls back to the
|
|
1626
|
+
* planned price, which is the migration case the checker fails open on).
|
|
1627
|
+
*/
|
|
1628
|
+
function deriveRotationPrice(bot, newGridId) {
|
|
1629
|
+
try {
|
|
1630
|
+
const idx = parseSlotIndex(newGridId);
|
|
1631
|
+
if (idx === null || idx === undefined || !Number.isFinite(idx))
|
|
1632
|
+
return NaN;
|
|
1633
|
+
const genesis = bot?.manager?._genesis;
|
|
1634
|
+
if (!Array.isArray(genesis?.priceLevels) || genesis.priceLevels.length === 0)
|
|
1635
|
+
return NaN;
|
|
1636
|
+
const lvl = Number(math.priceForSlot(idx, genesis));
|
|
1637
|
+
return (Number.isFinite(lvl) && lvl > 0) ? lvl : NaN;
|
|
1638
|
+
}
|
|
1639
|
+
catch {
|
|
1640
|
+
return NaN;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Record one GRID-PRICE-INVARIANT check for an emitted order price.
|
|
1645
|
+
*
|
|
1646
|
+
* BLOCKING: returns false when the price is a genuine off-grid mismatch, so the
|
|
1647
|
+
* caller must SKIP the emission. A price that is not a slot's genesis level is
|
|
1648
|
+
* not a valid grid price, so placing it is the failure this guard exists to
|
|
1649
|
+
* prevent — rejecting is the point, not a side effect.
|
|
1650
|
+
*
|
|
1651
|
+
* Fails OPEN on anything unjudgeable (no genesis, unparseable id, non-finite
|
|
1652
|
+
* price, checker error): those are metadata problems, not off-grid prices, and
|
|
1653
|
+
* blocking on them would halt legitimate trading. Only 'off-grid-price' blocks.
|
|
1654
|
+
*
|
|
1655
|
+
* A missing slotId is counted as unchecked rather than falling back to another
|
|
1656
|
+
* id: for a rotation UPDATE the id must be the DESTINATION slot (newPrice is
|
|
1657
|
+
* that slot's price), so substituting the source would flag every legitimate
|
|
1658
|
+
* relocation. Callers pass the id they actually mean, or nothing.
|
|
1659
|
+
*
|
|
1660
|
+
* @returns {boolean} true when the caller MAY emit; false when it must skip
|
|
1661
|
+
*/
|
|
1662
|
+
function recordGridPriceInvariantCheck(bot, slotId, price, stats, site) {
|
|
1663
|
+
try {
|
|
1664
|
+
if (slotId == null || slotId === '') {
|
|
1665
|
+
stats.unchecked++;
|
|
1666
|
+
return true;
|
|
1667
|
+
}
|
|
1668
|
+
const inv = checkGridPriceInvariant(slotId, price, bot?.manager?._genesis);
|
|
1669
|
+
if (inv.reason !== 'ok' && inv.reason !== 'off-grid-price') {
|
|
1670
|
+
stats.unchecked++;
|
|
1671
|
+
return true;
|
|
1672
|
+
}
|
|
1673
|
+
stats.checked++;
|
|
1674
|
+
if (inv.ok) {
|
|
1675
|
+
// A CLEAN check clears the run for this slot: escalation must mean
|
|
1676
|
+
// "rejected N consecutive batches", not "rejected N times ever".
|
|
1677
|
+
// Without this reset a slot rejected once an hour would accumulate
|
|
1678
|
+
// to the threshold over a day and fire a resync it never earned.
|
|
1679
|
+
const streakMap = getInvariantRejectStreak(bot);
|
|
1680
|
+
if (streakMap.has(String(slotId)))
|
|
1681
|
+
streakMap.delete(String(slotId));
|
|
1682
|
+
return true;
|
|
1683
|
+
}
|
|
1684
|
+
stats.violated++;
|
|
1685
|
+
orderUtils.reportGridPriceInvariant(bot?.manager, slotId, price, site);
|
|
1686
|
+
considerGridPriceInvariantEscalation(bot, slotId, price, inv, site);
|
|
1687
|
+
return false;
|
|
1688
|
+
}
|
|
1689
|
+
catch {
|
|
1690
|
+
// A checker failure must never block a broadcast.
|
|
1691
|
+
return true;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Per-slot count of CONSECUTIVE batches that rejected this slot's emission as
|
|
1696
|
+
* off-grid, scoped to the BOT rather than the module.
|
|
1697
|
+
*
|
|
1698
|
+
* Scope matters because the monolithic runtime (`dexbot.ts`, the `dexbot` bin)
|
|
1699
|
+
* constructs EVERY active bot in one process, so a module-level map would pool
|
|
1700
|
+
* unrelated bots' rejections: one bot rejecting a slot twice would leave the
|
|
1701
|
+
* next bot at the threshold on its FIRST rejection and fire a spurious
|
|
1702
|
+
* structural resync (reload, possibly a full grid reset) on a healthy bot.
|
|
1703
|
+
* Keeping it on the bot also keeps the count from outliving the resync that
|
|
1704
|
+
* repairs the slot, and lets tests start from a clean slate.
|
|
1705
|
+
*
|
|
1706
|
+
* @param {any} bot
|
|
1707
|
+
* @returns {Map<string, number>}
|
|
1708
|
+
*/
|
|
1709
|
+
function getInvariantRejectStreak(bot) {
|
|
1710
|
+
if (!(bot?._gridPriceInvariantRejectStreak instanceof Map)) {
|
|
1711
|
+
bot._gridPriceInvariantRejectStreak = new Map();
|
|
1712
|
+
}
|
|
1713
|
+
return bot._gridPriceInvariantRejectStreak;
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Escalate a PERSISTENT off-grid rejection to a structural resync.
|
|
1717
|
+
*
|
|
1718
|
+
* A single rejection is handled correctly by skipping the emission and warning;
|
|
1719
|
+
* the next cycle re-plans. But if the corruption lives in-process (the planner
|
|
1720
|
+
* carries `candidate.price` straight from `manager.orders`), the next cycle
|
|
1721
|
+
* re-plans from the SAME bad `slot.price`, is rejected identically, and warns
|
|
1722
|
+
* again -- forever. The slot is dead while the bot looks healthy.
|
|
1723
|
+
*
|
|
1724
|
+
* After `GRID_PRICE_INVARIANT_RESYNC_THRESHOLD` consecutive rejecting batches
|
|
1725
|
+
* for one slot, ask for the structural resync that repairs it (loadGrid derives
|
|
1726
|
+
* slot prices from the genesis ladder). Fire-and-forget: the resync is already
|
|
1727
|
+
* debounced (`_structuralGridResyncRunning`/`Timer`) and batch-in-flight aware,
|
|
1728
|
+
* so repeats inside the cooldown are cheap and safe.
|
|
1729
|
+
*
|
|
1730
|
+
* @returns {number} the slot's current consecutive-rejection streak
|
|
1731
|
+
*/
|
|
1732
|
+
function considerGridPriceInvariantEscalation(bot, slotId, price, inv, site) {
|
|
1733
|
+
const key = String(slotId);
|
|
1734
|
+
const streakMap = getInvariantRejectStreak(bot);
|
|
1735
|
+
const streak = (streakMap.get(key) || 0) + 1;
|
|
1736
|
+
streakMap.set(key, streak);
|
|
1737
|
+
const threshold = Number(constantsModule.TIMING?.GRID_PRICE_INVARIANT_RESYNC_THRESHOLD) > 0
|
|
1738
|
+
? Number(constantsModule.TIMING.GRID_PRICE_INVARIANT_RESYNC_THRESHOLD)
|
|
1739
|
+
: 3;
|
|
1740
|
+
if (streak < threshold)
|
|
1741
|
+
return streak;
|
|
1742
|
+
if (typeof bot?.manager?.requestStructuralGridResync !== 'function') {
|
|
1743
|
+
bot?.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] ${slotId} rejected ${streak}x consecutively but ` +
|
|
1744
|
+
`requestStructuralGridResync is unavailable; slot stays unhealed until restart`, 'error');
|
|
1745
|
+
return streak;
|
|
1746
|
+
}
|
|
1747
|
+
// Dedicated cooldown key (NOT BOUNDARY_HOLD_RESYNC_COOLDOWN_MS: the watchdogs
|
|
1748
|
+
// must tune independently). Once per cooldown window is enough -- the streak
|
|
1749
|
+
// keeps counting so a later window escalates again if still unhealed.
|
|
1750
|
+
const cooldownMs = Number(constantsModule.TIMING?.GRID_PRICE_INVARIANT_RESYNC_COOLDOWN_MS) > 0
|
|
1751
|
+
? Number(constantsModule.TIMING.GRID_PRICE_INVARIANT_RESYNC_COOLDOWN_MS)
|
|
1752
|
+
: 15 * 60 * 1000;
|
|
1753
|
+
const now = Date.now();
|
|
1754
|
+
const lastAt = Number(bot?._lastGridPriceInvariantResyncAt) || 0;
|
|
1755
|
+
if (now - lastAt < cooldownMs)
|
|
1756
|
+
return streak;
|
|
1757
|
+
bot._lastGridPriceInvariantResyncAt = now;
|
|
1758
|
+
const expected = inv?.expected != null ? Number(inv.expected) : NaN;
|
|
1759
|
+
const actual = Number(price);
|
|
1760
|
+
bot?.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] ${slotId} (${site}) rejected ${streak} consecutive batch(es) at ` +
|
|
1761
|
+
`${Number.isFinite(actual) ? Format.formatPrice6(actual) : 'n/a'} ` +
|
|
1762
|
+
`(genesis ${Number.isFinite(expected) ? Format.formatPrice6(expected) : 'n/a'}); ` +
|
|
1763
|
+
`requesting structural resync to repair the slot`, 'error');
|
|
1764
|
+
try {
|
|
1765
|
+
const res = bot.manager.requestStructuralGridResync('grid-price-invariant-violation', {
|
|
1766
|
+
slotId: String(slotId),
|
|
1767
|
+
expected: Number.isFinite(expected) ? expected : null,
|
|
1768
|
+
actual: Number.isFinite(actual) ? actual : null,
|
|
1769
|
+
site,
|
|
1770
|
+
streak,
|
|
1771
|
+
});
|
|
1772
|
+
res?.catch?.((err) => {
|
|
1773
|
+
bot.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] Structural resync request failed: ${getErrorMessage(err)}`, 'error');
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
catch (err) {
|
|
1777
|
+
bot.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] Structural resync request failed: ${getErrorMessage(err)}`, 'error');
|
|
1778
|
+
}
|
|
1779
|
+
return streak;
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Emit the per-batch GRID-PRICE-INVARIANT summary. Quiet when the batch had no
|
|
1783
|
+
* checkable emission; warn when any off-grid emission was REJECTED, else info.
|
|
1784
|
+
* A rejected emission is logged per-site by reportGridPriceInvariant; this is
|
|
1785
|
+
* the aggregate so a batch that silently placed fewer orders than planned is
|
|
1786
|
+
* explained without reading every line.
|
|
1787
|
+
*/
|
|
1788
|
+
function logGridPriceInvariantSummary(bot, stats, site) {
|
|
1789
|
+
try {
|
|
1790
|
+
if (!stats || stats.checked === 0)
|
|
1791
|
+
return;
|
|
1792
|
+
bot?.manager?.logger?.log?.(`[GRID-PRICE-INVARIANT] site=${site} checked=${stats.checked} ` +
|
|
1793
|
+
`violated=${stats.violated} unchecked=${stats.unchecked}`, stats.violated > 0 ? 'warn' : 'info');
|
|
1794
|
+
}
|
|
1795
|
+
catch { /* summary is best-effort */ }
|
|
1796
|
+
}
|
|
1612
1797
|
/**
|
|
1613
1798
|
* LAST-FILL-GUARD helper — pivot ± halfIncrement (replaces price-tolerance).
|
|
1614
1799
|
* last fill @x with increment i: BUY < x*(1 - i/2/100), SELL > x*(1 + i/2/100)
|
|
@@ -1756,6 +1941,14 @@ function refreshLastFillPivotFromQueue(bot) {
|
|
|
1756
1941
|
* Resolve the grid increment percent for the LAST-FILL guard in one place so
|
|
1757
1942
|
* every check site and the batch summary use (and print) the same value.
|
|
1758
1943
|
* Falls back to the default 0.5 when unset/invalid.
|
|
1944
|
+
*
|
|
1945
|
+
* CALL CONTRACT: this takes the BOT (`{ manager }`), not the increment or the
|
|
1946
|
+
* manager. The lookup order below is `bot.manager.config` first, which works
|
|
1947
|
+
* only because `OrderManager` exposes its own `config`. Callers that pass a
|
|
1948
|
+
* bare manager instead of a bot get the `bot.config` / DEFAULT_CONFIG
|
|
1949
|
+
* fallbacks and silently lose the manager's tuning, so pass the bot.
|
|
1950
|
+
* (The manager-first order is deliberate: the guard must use the same
|
|
1951
|
+
* increment the grid was built with, not whatever the bot-level config holds.)
|
|
1759
1952
|
* @param {import('./dexbot_class.js').DEXBot} bot
|
|
1760
1953
|
* @returns {number} Positive increment percent
|
|
1761
1954
|
*/
|
|
@@ -2074,11 +2267,20 @@ function applyRotationTransitionsToWorkingGrid(bot, workingGrid, executedContext
|
|
|
2074
2267
|
* CANCELs modify existing orders and cannot be reliably distinguished from
|
|
2075
2268
|
* "not yet visible" state by simple polling.
|
|
2076
2269
|
*
|
|
2270
|
+
* @param {Object} [options]
|
|
2271
|
+
* @param {number} [options.maxPollRetries] - Poll attempts before reconciliation fallback
|
|
2272
|
+
* @param {number} [options.pollIntervalMs] - Delay between polls; tests pass 0
|
|
2273
|
+
* to skip the production 1.5s pacing without changing the poll semantics
|
|
2077
2274
|
* @returns {{ allConfirmed: boolean, confirmed: Array, unconfirmed: Array }}
|
|
2078
2275
|
*/
|
|
2079
2276
|
async function pollChainForConfirmation(bot, opContexts, options = {}) {
|
|
2080
2277
|
const maxPollRetries = options.maxPollRetries || 4;
|
|
2081
|
-
|
|
2278
|
+
// Test seam (see updateOrdersOnChainBatchCOW): bot._testPollIntervalMs
|
|
2279
|
+
// overrides the production pacing when set by the tests. An explicit 0 is
|
|
2280
|
+
// honored; null/undefined falls through to the production 1.5s default.
|
|
2281
|
+
const explicitPollMs = options.pollIntervalMs;
|
|
2282
|
+
const seamMs = bot?._testPollIntervalMs;
|
|
2283
|
+
const pollIntervalMs = resolveSeamMs(explicitPollMs, resolveSeamMs(seamMs, 1500));
|
|
2082
2284
|
// Only CREATE operations can be confirmed by polling (they appear as new orders on chain)
|
|
2083
2285
|
const createContexts = opContexts.filter((ctx) => ctx && ctx.kind === 'create' && ctx.finalInts && ctx.order);
|
|
2084
2286
|
if (createContexts.length === 0) {
|
|
@@ -2300,7 +2502,7 @@ function restoreSkippedUpdateSlotsInWorkingGrid(bot, workingGrid, skippedSlotIds
|
|
|
2300
2502
|
* skipped as already-consistent); handled=false when the caller must proceed
|
|
2301
2503
|
* with the original plan.
|
|
2302
2504
|
*/
|
|
2303
|
-
async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard) {
|
|
2505
|
+
async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard, seamPollIntervalMs) {
|
|
2304
2506
|
const canReplan = replanDepth < STALE_PLAN_REPLAN_LIMIT
|
|
2305
2507
|
&& Array.isArray(cowResult.fills) && cowResult.fills.length > 0;
|
|
2306
2508
|
if (!canReplan) {
|
|
@@ -2356,7 +2558,11 @@ async function replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard)
|
|
|
2356
2558
|
return {
|
|
2357
2559
|
handled: true,
|
|
2358
2560
|
result: await updateOrdersOnChainBatchCOW(bot, replanned, {
|
|
2359
|
-
replanDepth: replanDepth + 1
|
|
2561
|
+
replanDepth: replanDepth + 1,
|
|
2562
|
+
// Carry the seam through the recursion explicitly: the inner
|
|
2563
|
+
// frame re-sets and re-restores it, so it must not depend on
|
|
2564
|
+
// the outer frame's side-channel value surviving.
|
|
2565
|
+
...(seamPollIntervalMs != null ? { pollIntervalMs: seamPollIntervalMs } : {})
|
|
2360
2566
|
}),
|
|
2361
2567
|
};
|
|
2362
2568
|
}
|
|
@@ -2710,8 +2916,58 @@ async function runPreBroadcastGuards(bot, cowResult) {
|
|
|
2710
2916
|
}
|
|
2711
2917
|
return { proceed: true, crossingCandidates, intraBatchCandidates };
|
|
2712
2918
|
}
|
|
2919
|
+
function restoreTestPollIntervalSeam(bot, prev) {
|
|
2920
|
+
try {
|
|
2921
|
+
if (prev === undefined)
|
|
2922
|
+
delete bot._testPollIntervalMs;
|
|
2923
|
+
else
|
|
2924
|
+
bot._testPollIntervalMs = prev;
|
|
2925
|
+
}
|
|
2926
|
+
catch { /* seam restore must never break the batch */ }
|
|
2927
|
+
}
|
|
2713
2928
|
async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
2714
2929
|
const replanDepth = Number.isFinite(Number(options?.replanDepth)) ? Number(options.replanDepth) : 0;
|
|
2930
|
+
// Test seam: options.pollIntervalMs overrides the production 1.5s pacing
|
|
2931
|
+
// in pollChainForConfirmation (missing-create path below) so tests do
|
|
2932
|
+
// not sleep on wall-clock time. Held on the bot only for the duration of
|
|
2933
|
+
// this call (see the wrapper's finally) so the inner missing-create
|
|
2934
|
+
// branch and any re-plan recursion pick it up without changing the
|
|
2935
|
+
// production call signature used by the runtime.
|
|
2936
|
+
const seamPollIntervalMs = resolveSeamMsOrNull(options?.pollIntervalMs);
|
|
2937
|
+
const prevSeamPollIntervalMs = bot?._testPollIntervalMs;
|
|
2938
|
+
if (seamPollIntervalMs != null) {
|
|
2939
|
+
bot._testPollIntervalMs = seamPollIntervalMs;
|
|
2940
|
+
}
|
|
2941
|
+
// Expose the resolved interval using the same precedence the missing-create
|
|
2942
|
+
// poll path applies (explicit option > bot seam > production 1500ms).
|
|
2943
|
+
// Recorded in the wrapper rather than in pollChainForConfirmation so it is
|
|
2944
|
+
// observable on every batch exit, including the pre-broadcast guard
|
|
2945
|
+
// refusals that never reach the poll (a `||`-vs-`??` regression here is
|
|
2946
|
+
// otherwise invisible: the only effect is a slower poll).
|
|
2947
|
+
bot._lastResolvedPollIntervalMs = resolveSeamMs(seamPollIntervalMs, resolveSeamMs(bot?._testPollIntervalMs, 1500));
|
|
2948
|
+
// The seam override must not survive this call: every exit path (dry run,
|
|
2949
|
+
// entry/pre-broadcast single-flight aborts, guard refusals, re-plan
|
|
2950
|
+
// recursion, throws) funnels through the body() finally below, so no exit
|
|
2951
|
+
// can leak bot._testPollIntervalMs onto the bot.
|
|
2952
|
+
try {
|
|
2953
|
+
return await updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seamPollIntervalMs ?? undefined);
|
|
2954
|
+
}
|
|
2955
|
+
finally {
|
|
2956
|
+
// Restore only when this frame actually wrote a seam. The entry
|
|
2957
|
+
// single-flight await (inside body()) lets a second, seam-less batch
|
|
2958
|
+
// run concurrently: an unguarded restore would delete the seam owned
|
|
2959
|
+
// by an in-flight sibling call (captured prev === undefined), and that
|
|
2960
|
+
// sibling's pollChainForConfirmation — which reads the bot field only,
|
|
2961
|
+
// since it takes no pollIntervalMs option at its call sites — would
|
|
2962
|
+
// silently fall back to the production 1500ms pacing. Guarding keeps
|
|
2963
|
+
// the write-ownership scoped to this call; the saved prev still makes
|
|
2964
|
+
// the nested re-plan recursion LIFO-correct.
|
|
2965
|
+
if (seamPollIntervalMs != null) {
|
|
2966
|
+
restoreTestPollIntervalSeam(bot, prevSeamPollIntervalMs);
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
async function updateOrdersOnChainBatchCOWBody(bot, cowResult, replanDepth, seamPollIntervalMs) {
|
|
2715
2971
|
bot._currentCycleId = (Number.isFinite(Number(bot._currentCycleId)) ? Number(bot._currentCycleId) : 0) + 1;
|
|
2716
2972
|
const { workingGrid, workingIndexes, workingBoundary, actions } = cowResult;
|
|
2717
2973
|
// Boundary-hold value: computed pre-broadcast after the skip-restore and
|
|
@@ -2798,7 +3054,11 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2798
3054
|
// Per-batch LAST-FILL-GUARD disposition counters. Per-action pass lines
|
|
2799
3055
|
// would spam big batches, so the guard emits one batch summary instead
|
|
2800
3056
|
// (see the summary after the action loop below).
|
|
2801
|
-
const lastFillGuardStats = { checked: 0, passed: 0, skipped: 0, bypassed: 0 };
|
|
3057
|
+
const lastFillGuardStats = { checked: 0, passed: 0, skipped: 0, bypassed: 0, pivotOffGrid: 0 };
|
|
3058
|
+
// Per-batch GRID-PRICE-INVARIANT counters (BLOCKING). Tracks emitted prices
|
|
3059
|
+
// that are not the genesis level for their slot — such emissions are
|
|
3060
|
+
// rejected, not placed. One batch summary.
|
|
3061
|
+
const gridPriceInvariantStats = { checked: 0, violated: 0, unchecked: 0 };
|
|
2802
3062
|
// Whether any guard check in this batch refreshed the pivot from
|
|
2803
3063
|
// still-queued fills — reported in the batch summary so a pivot change
|
|
2804
3064
|
// that altered a guard decision is visible at info, not just debug.
|
|
@@ -2810,7 +3070,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2810
3070
|
const clampedUpdateSlotIds = new Set();
|
|
2811
3071
|
// orderId -> operations index of its cancel op. A crossing re-pricing
|
|
2812
3072
|
// update is only safe when the crossed order's cancel was already queued
|
|
2813
|
-
// at an earlier position: ops broadcast in
|
|
3073
|
+
// at an earlier position: ops broadcast in gap-slot-sized chunks,
|
|
2814
3074
|
// so an earlier index means the cancel confirms on chain (same or earlier
|
|
2815
3075
|
// chunk, applied sequentially) before the crossing order lands.
|
|
2816
3076
|
const cancelOpIndexByOrderId = new Map();
|
|
@@ -2890,13 +3150,21 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2890
3150
|
const priceDrift = Number.isFinite(plannedPrice) && Number.isFinite(livePrice)
|
|
2891
3151
|
? Math.abs(livePrice - plannedPrice)
|
|
2892
3152
|
: 0;
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
3153
|
+
// The planned price is emitted as-is. A pre-broadcast
|
|
3154
|
+
// substitution with liveSlot.price used to rewrite it here
|
|
3155
|
+
// at `debug` level, which meant a slot whose price had been
|
|
3156
|
+
// mutated off its genesis level got re-broadcast under a
|
|
3157
|
+
// different number than the plan validated (crossing,
|
|
3158
|
+
// collision and last-fill guards all ran on `createPrice`).
|
|
3159
|
+
// slot.price is derived from the genesis ladder, not
|
|
3160
|
+
// authoritative, so a divergence is a signal to report —
|
|
3161
|
+
// never a value to adopt. Reported at `warn`: a divergence
|
|
3162
|
+
// here is a writer bug, not routine freshness.
|
|
3163
|
+
const effectiveOrder = order;
|
|
2896
3164
|
if (priceDrift > 0) {
|
|
2897
|
-
bot.manager.logger.log(`[COW] Pre-broadcast price
|
|
2898
|
-
`
|
|
2899
|
-
`(
|
|
3165
|
+
bot.manager.logger.log(`[COW] Pre-broadcast price drift on slot ${order.id}: ` +
|
|
3166
|
+
`planned=${plannedPrice} live=${livePrice} (diff=${priceDrift}); ` +
|
|
3167
|
+
`emitting planned price (live slot price is derived from genesis and is not authoritative).`, 'warn');
|
|
2900
3168
|
}
|
|
2901
3169
|
const createPrice = effectiveOrder.price;
|
|
2902
3170
|
const precision = order.type === ORDER_TYPES.SELL ? bot.manager.assets.assetA.precision : bot.manager.assets.assetB.precision;
|
|
@@ -2912,13 +3180,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2912
3180
|
}
|
|
2913
3181
|
// CROSSING-PLACEMENT GUARD (create variant): the batch-level
|
|
2914
3182
|
// validators (validateCreateTargetSlots, detectCrossedBookPlan)
|
|
2915
|
-
// simulated the PLANNED price
|
|
2916
|
-
//
|
|
2917
|
-
//
|
|
2918
|
-
//
|
|
2919
|
-
//
|
|
2920
|
-
//
|
|
2921
|
-
// self-trade (production
|
|
3183
|
+
// simulated the PLANNED price. That is now the price emitted
|
|
3184
|
+
// (the pre-broadcast substitution was removed), but the guard
|
|
3185
|
+
// is retained as defence-in-depth: it re-checks on the FINAL
|
|
3186
|
+
// price against live and chain-side orders not already
|
|
3187
|
+
// cancelled at an earlier op position — an opposite-side
|
|
3188
|
+
// order cancelled in a later chunk would otherwise coexist
|
|
3189
|
+
// with this create mid-broadcast and self-trade (production
|
|
3190
|
+
// incident class).
|
|
2922
3191
|
const createCrossed = findCrossedOrder(crossingCandidates, createPrice, order.type, bot.manager.assets, (o) => orderUtils.isCrossingCheckCandidate(o, null, cancelOpIndexByOrderId));
|
|
2923
3192
|
const intraBatchCrossed = createCrossed ? null : findCrossedOrder(intraBatchCandidates, createPrice, order.type, bot.manager.assets);
|
|
2924
3193
|
const effectiveCrossed = createCrossed || intraBatchCrossed;
|
|
@@ -2959,6 +3228,16 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2959
3228
|
}
|
|
2960
3229
|
catch (_e) { /* guard is best-effort */ }
|
|
2961
3230
|
const args = buildCreateOrderArgs(effectiveOrder, assetA, assetB);
|
|
3231
|
+
// GRID-PRICE-INVARIANT (blocking): the CREATE price must be
|
|
3232
|
+
// the genesis level for this slot. A mismatch means state
|
|
3233
|
+
// corruption upstream, so the emission is skipped rather
|
|
3234
|
+
// than placed — the next reconcile cycle re-plans. See
|
|
3235
|
+
// docs/GRID_PRICE_INVARIANT.md.
|
|
3236
|
+
if (!recordGridPriceInvariantCheck(bot, order.id, createPrice, gridPriceInvariantStats, 'CREATE')) {
|
|
3237
|
+
if (order.id)
|
|
3238
|
+
skippedCreateSlotIds.add(order.id);
|
|
3239
|
+
continue;
|
|
3240
|
+
}
|
|
2962
3241
|
const buildResult = await chainOrders.buildCreateOrderOp(bot.account, args.amountToSell, args.sellAssetId, args.minToReceive, args.receiveAssetId, null);
|
|
2963
3242
|
if (!buildResult) {
|
|
2964
3243
|
bot.manager.logger.log(`Skipping create op for ${action.id}: amounts would round to 0 on blockchain`, 'warn');
|
|
@@ -2985,9 +3264,29 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
2985
3264
|
if (action.newGridId && action.newGridId !== action.id) {
|
|
2986
3265
|
const masterOrder = bot.manager.orders.get(action.id);
|
|
2987
3266
|
const orderType = action.order?.type || masterOrder?.type;
|
|
2988
|
-
|
|
3267
|
+
// ROTATION PRICE IS THE DESTINATION'S GENESIS LEVEL.
|
|
3268
|
+
//
|
|
3269
|
+
// A rotation re-prices to the destination slot, so the
|
|
3270
|
+
// destination's level is the authoritative price — not
|
|
3271
|
+
// action.newPrice, which the planner copies from
|
|
3272
|
+
// hole.order.price and which is therefore only as sound
|
|
3273
|
+
// as whatever wrote that object. Deriving it here means a
|
|
3274
|
+
// planner bug cannot produce a mis-priced UPDATE even if
|
|
3275
|
+
// the invariant check below were bypassed; the check
|
|
3276
|
+
// stays as the backstop that catches a missing genesis
|
|
3277
|
+
// ladder (where it fails open) rather than the only
|
|
3278
|
+
// thing standing between a bad plan and a live order.
|
|
3279
|
+
const derivedNewPrice = deriveRotationPrice(bot, action.newGridId);
|
|
3280
|
+
const plannedNewPrice = Number.isFinite(Number(action.newPrice))
|
|
2989
3281
|
? Number(action.newPrice)
|
|
2990
3282
|
: Number(action.order?.price);
|
|
3283
|
+
let newPrice = Number.isFinite(derivedNewPrice) ? derivedNewPrice : plannedNewPrice;
|
|
3284
|
+
if (Number.isFinite(derivedNewPrice) && Number.isFinite(plannedNewPrice)
|
|
3285
|
+
&& Math.abs(derivedNewPrice - plannedNewPrice) > Math.max(1e-12, Math.abs(derivedNewPrice) * 1e-9)) {
|
|
3286
|
+
bot.manager.logger.log(`[GRID-PRICE-INVARIANT] Rotation ${action.id} -> ${action.newGridId}: planned price ${Format.formatPrice6(plannedNewPrice)} ` +
|
|
3287
|
+
`differs from the destination's genesis level ${Format.formatPrice6(derivedNewPrice)} — ` +
|
|
3288
|
+
`emitting the genesis level`, 'warn');
|
|
3289
|
+
}
|
|
2991
3290
|
const newSize = plannedUpdateSize(action);
|
|
2992
3291
|
if (!masterOrder || !action.orderId || !orderType || !Number.isFinite(newPrice) || newSize <= 0) {
|
|
2993
3292
|
continue;
|
|
@@ -3194,6 +3493,20 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3194
3493
|
}
|
|
3195
3494
|
catch (_e) { /* best-effort */ }
|
|
3196
3495
|
const { amountToSell, minToReceive } = buildCreateOrderArgs({ type: orderType, size: newSize, price: newPrice }, assetA, assetB);
|
|
3496
|
+
// GRID-PRICE-INVARIANT (blocking): the emitted price
|
|
3497
|
+
// is action.newPrice, which every planner derives from the
|
|
3498
|
+
// object named by action.newGridId (verified: grid.ts:1867,
|
|
3499
|
+
// utils/system.ts:1195, validate.ts:600/746). Pass the
|
|
3500
|
+
// destination id only — no source fallback, so a missing id
|
|
3501
|
+
// counts as unchecked instead of checking the wrong slot.
|
|
3502
|
+
if (!recordGridPriceInvariantCheck(bot, action.newGridId, newPrice, gridPriceInvariantStats, 'UPDATE')) {
|
|
3503
|
+
skippedUpdateCount++;
|
|
3504
|
+
if (action.id)
|
|
3505
|
+
skippedUpdateSlotIds.add(action.id);
|
|
3506
|
+
if (action.newGridId)
|
|
3507
|
+
skippedUpdateSlotIds.add(action.newGridId);
|
|
3508
|
+
continue;
|
|
3509
|
+
}
|
|
3197
3510
|
const buildResult = await chainOrders.buildUpdateOrderOp(bot.account, action.orderId, { amountToSell, minToReceive, newPrice, orderType }, masterOrder.rawOnChain || null);
|
|
3198
3511
|
if (!buildResult) {
|
|
3199
3512
|
skippedUpdateCount++;
|
|
@@ -3261,10 +3574,14 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3261
3574
|
const priceDrift = Number.isFinite(plannedPrice) && Number.isFinite(livePrice)
|
|
3262
3575
|
? Math.abs(livePrice - plannedPrice)
|
|
3263
3576
|
: 0;
|
|
3264
|
-
const fbPrice =
|
|
3577
|
+
const fbPrice = plannedPrice;
|
|
3265
3578
|
if (priceDrift > 0) {
|
|
3579
|
+
// Same rationale as the primary CREATE path: emit
|
|
3580
|
+
// the planned price and report the divergence
|
|
3581
|
+
// rather than adopting a derived live price.
|
|
3266
3582
|
bot.manager.logger.log(`[COW] CREATE fallback price drift for ${action.id} -> ${targetSlotId}: ` +
|
|
3267
|
-
`planned=${plannedPrice} live=${livePrice} (diff=${priceDrift})
|
|
3583
|
+
`planned=${plannedPrice} live=${livePrice} (diff=${priceDrift}); ` +
|
|
3584
|
+
`emitting planned price.`, 'warn');
|
|
3268
3585
|
}
|
|
3269
3586
|
const sizeCheck = validateOrderSizeForExecution(bot, fbSize, fbType, fbOrder, fbSize);
|
|
3270
3587
|
if (!sizeCheck.isValid) {
|
|
@@ -3321,6 +3638,10 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3321
3638
|
}
|
|
3322
3639
|
catch (_fbGuardErr) { /* guard is best-effort */ }
|
|
3323
3640
|
const fbArgs = buildCreateOrderArgs({ type: fbType, size: fbSize, price: fbPrice }, assetA, assetB);
|
|
3641
|
+
// GRID-PRICE-INVARIANT (blocking): fallback CREATE.
|
|
3642
|
+
if (!recordGridPriceInvariantCheck(bot, targetSlotId, fbPrice, gridPriceInvariantStats, 'CREATE-FALLBACK')) {
|
|
3643
|
+
continue;
|
|
3644
|
+
}
|
|
3324
3645
|
const fbResult = await chainOrders.buildCreateOrderOp(bot.account, fbArgs.amountToSell, fbArgs.sellAssetId, fbArgs.minToReceive, fbArgs.receiveAssetId, null);
|
|
3325
3646
|
if (fbResult) {
|
|
3326
3647
|
operations.push(fbResult.op);
|
|
@@ -3376,13 +3697,35 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3376
3697
|
? `active+bypassed(${batchOrigin || 'unknown'})`
|
|
3377
3698
|
: 'active');
|
|
3378
3699
|
const pivotStr = cold ? 'none' : `${Format.formatPrice6(Number(sumPivotRaw))}(${sumType})`;
|
|
3379
|
-
|
|
3700
|
+
// Report when the pivot used for this batch was NOT a ladder
|
|
3701
|
+
// level: that is the precondition for a pivoted ratchet, and
|
|
3702
|
+
// the value alone does not reveal it.
|
|
3703
|
+
const batchPivot = cold ? null : resolveOnGridPivot(bot.manager, sumPivotRaw);
|
|
3704
|
+
const pivotGridStr = cold
|
|
3705
|
+
? ''
|
|
3706
|
+
: (batchPivot && batchPivot.idx != null
|
|
3707
|
+
? ` pivotSlot=${batchPivot.idx}${batchPivot.snapped ? '(snapped)' : ''}`
|
|
3708
|
+
: ' pivotOffGrid=true');
|
|
3709
|
+
bot.manager.logger.log(`[LAST-FILL-GUARD] mode=${mode} pivot=${pivotStr}${pivotGridStr} inc=${sumInc}% ` +
|
|
3380
3710
|
`pivotRefreshed=${lastFillGuardPivotRefreshed} ` +
|
|
3381
3711
|
`checked=${lastFillGuardStats.checked} passed=${lastFillGuardStats.passed} ` +
|
|
3382
|
-
`skipped=${lastFillGuardStats.skipped} bypassed=${lastFillGuardStats.bypassed}
|
|
3712
|
+
`skipped=${lastFillGuardStats.skipped} bypassed=${lastFillGuardStats.bypassed}` +
|
|
3713
|
+
// Per-action off-ladder count. The batch-level
|
|
3714
|
+
// `pivotOffGrid=true` flag above only says the pivot was
|
|
3715
|
+
// off-grid; this says HOW MANY guarded probes judged a
|
|
3716
|
+
// placement against an unsnapped pivot, which is the
|
|
3717
|
+
// quantity that grows during the ratchet this guard
|
|
3718
|
+
// exists to catch. Omitted (not `0`) when never set, so
|
|
3719
|
+
// "no off-grid probes" stays distinguishable from
|
|
3720
|
+
// "counter unavailable".
|
|
3721
|
+
(Number(lastFillGuardStats.pivotOffGrid) > 0
|
|
3722
|
+
? ` pivotOffGrid=${lastFillGuardStats.pivotOffGrid}`
|
|
3723
|
+
: ''), cold || (batchPivot && batchPivot.idx == null) ? 'warn' : 'info');
|
|
3383
3724
|
}
|
|
3384
3725
|
}
|
|
3385
3726
|
catch { /* summary is best-effort */ }
|
|
3727
|
+
// Batch-level GRID-PRICE-INVARIANT summary (rejected emissions).
|
|
3728
|
+
logGridPriceInvariantSummary(bot, gridPriceInvariantStats, 'COW');
|
|
3386
3729
|
if (skippedUpdateCount > 0) {
|
|
3387
3730
|
restoreSkippedUpdateSlotsInWorkingGrid(bot, workingGrid, skippedUpdateSlotIds, skippedUpdateCount);
|
|
3388
3731
|
}
|
|
@@ -3466,7 +3809,7 @@ async function updateOrdersOnChainBatchCOW(bot, cowResult, options = {}) {
|
|
|
3466
3809
|
});
|
|
3467
3810
|
if (!preBroadcastGuard.canCommit) {
|
|
3468
3811
|
// Bounded re-plan + proceed — policy documented on replanStaleBatch.
|
|
3469
|
-
const replan = await replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard);
|
|
3812
|
+
const replan = await replanStaleBatch(bot, cowResult, replanDepth, preBroadcastGuard, seamPollIntervalMs);
|
|
3470
3813
|
if (replan.handled) {
|
|
3471
3814
|
return replan.result;
|
|
3472
3815
|
}
|
|
@@ -3997,6 +4340,68 @@ async function recoverRefusedCommit(bot, chainOrders, logPrefix, adoptOpts, cont
|
|
|
3997
4340
|
await persistGridAndClearPendingBroadcasts(bot, logPrefix);
|
|
3998
4341
|
return successReturn ?? { executed: false, hadRotation: false, commitRefused: true, ...extraReturn };
|
|
3999
4342
|
}
|
|
4343
|
+
/**
|
|
4344
|
+
* Resolve the LAST-FILL guard pivot to an ON-GRID price.
|
|
4345
|
+
*
|
|
4346
|
+
* The pivot was a raw fill price, and nothing validated it. That made it a
|
|
4347
|
+
* ratchet input: any bad pivot (a corrupt fill price, an adopted off-grid
|
|
4348
|
+
* order) shifted every subsequent threshold, so an off-market buy could read
|
|
4349
|
+
* as legitimate. A price is only meaningful relative to the ladder it trades
|
|
4350
|
+
* on, so the pivot is converted to its nearest slot index and back to that
|
|
4351
|
+
* slot's genesis price: the result is by construction a real grid level and
|
|
4352
|
+
* cannot drift off the ladder.
|
|
4353
|
+
*
|
|
4354
|
+
* Falls back to the raw price when genesis is unavailable (pre-genesis
|
|
4355
|
+
* startup), so the guard degrades to its previous behaviour rather than
|
|
4356
|
+
* silently disabling. Never throws.
|
|
4357
|
+
*
|
|
4358
|
+
* @param {any} manager
|
|
4359
|
+
* @param {number|null|undefined} rawPrice
|
|
4360
|
+
* @returns {{price: number|null, snapped: boolean, idx: number|null, nearestDrift: number|null}}
|
|
4361
|
+
*/
|
|
4362
|
+
function resolveOnGridPivot(manager, rawPrice) {
|
|
4363
|
+
const price = Number(rawPrice);
|
|
4364
|
+
if (!Number.isFinite(price) || price <= 0)
|
|
4365
|
+
return { price: null, snapped: false, idx: null, nearestDrift: null };
|
|
4366
|
+
try {
|
|
4367
|
+
const genesis = manager?._genesis;
|
|
4368
|
+
const levels = genesis?.priceLevels;
|
|
4369
|
+
if (!Array.isArray(levels) || levels.length === 0)
|
|
4370
|
+
return { price, snapped: false, idx: null, nearestDrift: null };
|
|
4371
|
+
const idx = math.slotIndexForPrice(price, genesis);
|
|
4372
|
+
if (!Number.isFinite(idx))
|
|
4373
|
+
return { price, snapped: false, idx: null, nearestDrift: null };
|
|
4374
|
+
// Only accept a genuine ladder level and only a nearest match. A price
|
|
4375
|
+
// that is wildly off-ladder (an adopted orphan far outside the grid)
|
|
4376
|
+
// must NOT be silently rewritten onto an edge slot -- that would make a
|
|
4377
|
+
// bad pivot look like a legitimate grid fill. Leave it snapped=false and
|
|
4378
|
+
// let the caller decide.
|
|
4379
|
+
const candidate = Number(math.priceForSlot(idx, genesis));
|
|
4380
|
+
if (!Number.isFinite(candidate) || candidate <= 0)
|
|
4381
|
+
return { price, snapped: false, idx: null, nearestDrift: null };
|
|
4382
|
+
// Reject a snap that moves the pivot by more than one increment: any
|
|
4383
|
+
// in-grid fill is within half an increment of its slot price, so a
|
|
4384
|
+
// larger move means the pivot itself is not a real fill price.
|
|
4385
|
+
const drift = Math.abs(candidate - price) / price;
|
|
4386
|
+
// resolveLastFillGuardIncrement takes a BOT-shaped argument and reads
|
|
4387
|
+
// `bot.manager.config` first. resolveOnGridPivot only ever has the
|
|
4388
|
+
// manager (no bot in scope), so wrap it to satisfy that contract.
|
|
4389
|
+
// Passing the manager directly would silently skip its own tuning and
|
|
4390
|
+
// fall through to DEFAULT_CONFIG, making the snap tolerance wrong for
|
|
4391
|
+
// any grid not built on the default increment.
|
|
4392
|
+
const increment = resolveLastFillGuardIncrement({ manager }) / 100;
|
|
4393
|
+
const maxDrift = Number.isFinite(increment) && increment > 0 ? increment : 0.005;
|
|
4394
|
+
// nearestDrift is reported even when the snap is refused, so a caller
|
|
4395
|
+
// can tell a near-miss (rounding, one increment out) from a price that
|
|
4396
|
+
// is nowhere near the ladder (a genuinely corrupt pivot).
|
|
4397
|
+
if (drift > maxDrift)
|
|
4398
|
+
return { price, snapped: false, idx: null, nearestDrift: drift };
|
|
4399
|
+
return { price: candidate, snapped: drift > 0, idx, nearestDrift: drift };
|
|
4400
|
+
}
|
|
4401
|
+
catch {
|
|
4402
|
+
return { price, snapped: false, idx: null, nearestDrift: null };
|
|
4403
|
+
}
|
|
4404
|
+
}
|
|
4000
4405
|
/**
|
|
4001
4406
|
* Run the last-fill guard probe: optionally refresh the pivot from
|
|
4002
4407
|
* still-queued fills, read the durable pivot, and evaluate
|
|
@@ -4027,7 +4432,38 @@ function runLastFillGuardCheck(bot, price, size, type, stats, skipRefresh = fals
|
|
|
4027
4432
|
const lastPrice = bot.manager?._lastFilledPrice;
|
|
4028
4433
|
const lastType = bot.manager?._lastFilledType;
|
|
4029
4434
|
const inc = resolveLastFillGuardIncrement(bot);
|
|
4030
|
-
|
|
4435
|
+
// Validate the pivot onto the ladder before use (see resolveOnGridPivot).
|
|
4436
|
+
// Reported once per probe at warn when the raw pivot was NOT a grid level:
|
|
4437
|
+
// that is the ratchet precondition, and it is otherwise invisible because
|
|
4438
|
+
// the pivot is only ever logged by value.
|
|
4439
|
+
const onGrid = resolveOnGridPivot(bot.manager, lastPrice);
|
|
4440
|
+
if (lastPrice != null && onGrid.idx == null && Number.isFinite(Number(lastPrice))) {
|
|
4441
|
+
try {
|
|
4442
|
+
// A persistently off-ladder pivot is exactly the corruption case, so
|
|
4443
|
+
// this branch would otherwise warn once per guarded action per batch
|
|
4444
|
+
// on top of the batch summary (which already reports pivotOffGrid).
|
|
4445
|
+
// Warn once per distinct pivot value per batch: repeated identical
|
|
4446
|
+
// pivots are the same condition, and a CHANGED pivot still warns.
|
|
4447
|
+
const warnedKey = `lastFillPivotWarned:${bot?._currentCycleId ?? 'na'}`;
|
|
4448
|
+
const alreadyWarned = bot[warnedKey];
|
|
4449
|
+
if (alreadyWarned !== Number(lastPrice)) {
|
|
4450
|
+
bot[warnedKey] = Number(lastPrice);
|
|
4451
|
+
// Distinguish "close to a level but too far to snap" from "nowhere
|
|
4452
|
+
// near the ladder". The former is a rounding/drift artifact; the
|
|
4453
|
+
// latter means the pivot itself is not a real fill price.
|
|
4454
|
+
const far = onGrid.nearestDrift == null || onGrid.nearestDrift > 0.02;
|
|
4455
|
+
const kind = far ? 'off-ladder' : 'near-ladder'
|
|
4456
|
+
+ (onGrid.nearestDrift != null ? ` (${(onGrid.nearestDrift * 100).toFixed(4)}% from the nearest level)` : '');
|
|
4457
|
+
bot.manager?.logger?.log?.(`[LAST-FILL-GUARD] Pivot ${Format.formatPrice6(Number(lastPrice))} is not a grid level ` +
|
|
4458
|
+
`(${kind}); using raw value. A non-grid pivot can ` +
|
|
4459
|
+
`misjudge which placements are off-market. ` +
|
|
4460
|
+
`(reported once per batch per pivot value)`, 'warn');
|
|
4461
|
+
}
|
|
4462
|
+
}
|
|
4463
|
+
catch { /* logging is best-effort */ }
|
|
4464
|
+
stats.pivotOffGrid = (stats.pivotOffGrid || 0) + 1;
|
|
4465
|
+
}
|
|
4466
|
+
const check = isLastFillGuardBlocked(price, size, type, onGrid.price, lastType, inc);
|
|
4031
4467
|
stats.checked++;
|
|
4032
4468
|
return { check, refreshed };
|
|
4033
4469
|
}
|
|
@@ -4273,7 +4709,7 @@ async function processBatchResults(bot, result, opContexts) {
|
|
|
4273
4709
|
updateOperationCount
|
|
4274
4710
|
};
|
|
4275
4711
|
}
|
|
4276
|
-
export { isLastFillGuardBlocked, refreshLastFillPivotFromQueue, buildOutsideInPairGroupsForOrders, buildOutsideInPairGroupsForCreateEntries, extractOperationResults, findMissingCreateResultContexts, markMissingCreateResultsAsStructuralBlocker, formatUnmatchedChainOrderForLog, recordPendingBroadcast, clearPendingBroadcasts, clearPendingBroadcastsForSlots, popPushedWorkingGrid, buildChainOrderFingerprint, normalizeChainOrderForPendingMatch, findChainOrderForSlot, reconcileAfterUncertainBroadcast, reconcileAfterUncertainBroadcastImpl, autoCancelOneUnmatchedOrphan, shouldExecuteCreatePairMode, executeWithRetryOnUncertain, executeChunkedWithRetryOnUncertain, formatPartialBroadcastSummary, executeOperationsWithStrategy, validateOperationFunds, resolveIdealSizeForValidation, validateOrderSizeForExecution, buildActionsFromPlan, buildCowResultFromPlan, restoreSkippedUpdateSlotsInWorkingGrid, applyRotationTransitionsToWorkingGrid, pollChainForConfirmation, updateOrdersOnChainBatchCOW, processBatchResults, adoptPlacedBatchFromChain, resolveRefillBoundaryHold, toRefillSlotIdSet, trackBoundaryHold };
|
|
4712
|
+
export { isLastFillGuardBlocked, resolveOnGridPivot, checkGridPriceInvariant, deriveRotationPrice, refreshLastFillPivotFromQueue, buildOutsideInPairGroupsForOrders, buildOutsideInPairGroupsForCreateEntries, extractOperationResults, findMissingCreateResultContexts, markMissingCreateResultsAsStructuralBlocker, formatUnmatchedChainOrderForLog, recordPendingBroadcast, clearPendingBroadcasts, clearPendingBroadcastsForSlots, popPushedWorkingGrid, buildChainOrderFingerprint, normalizeChainOrderForPendingMatch, findChainOrderForSlot, reconcileAfterUncertainBroadcast, reconcileAfterUncertainBroadcastImpl, autoCancelOneUnmatchedOrphan, shouldExecuteCreatePairMode, executeWithRetryOnUncertain, executeChunkedWithRetryOnUncertain, formatPartialBroadcastSummary, executeOperationsWithStrategy, validateOperationFunds, resolveIdealSizeForValidation, validateOrderSizeForExecution, buildActionsFromPlan, buildCowResultFromPlan, restoreSkippedUpdateSlotsInWorkingGrid, applyRotationTransitionsToWorkingGrid, pollChainForConfirmation, updateOrdersOnChainBatchCOW, processBatchResults, adoptPlacedBatchFromChain, resolveRefillBoundaryHold, toRefillSlotIdSet, trackBoundaryHold };
|
|
4277
4713
|
// Exported for regression tests (issue #23 sibling): the uncertain-broadcast
|
|
4278
4714
|
// discard path must never drop a placement silently when master lost the slot.
|
|
4279
4715
|
export { restoreDiscardedCreates };
|