Skip to content

Commit 05df6e7

Browse files
committed
feat: add maxBorrowAmountPerOperation and oversized-deal splitter
Adds a per-operation borrow cap to prevent single-credit-accept operations from consuming too much of the total maxBorrowAmount at once, making repay/reborrow cycles easier with less liquidity per operation. ## maxBorrowAmountPerOperation (new config field) File: modules/types.ts - New optional field on LendingEntryBase (shared by MPA and creditOffer). - Enforced in buildCreditOfferAcceptOperation: any single borrow exceeding the limit is rejected with a descriptive error. - isMaxBorrowAmountError widened to match both ceiling and per-op patterns. File: modules/bot_settings.ts - Validation: must be a positive number. ## MPA planner capping File: modules/cr_planner.ts - buildDebtFirstCrPlan clamps debtDelta by maxBorrowAmountPerOperation on top of the total maxBorrowAmount cap. ## _splitOversizedCreditDeals (new feature) File: modules/credit_runtime.ts - Phase 0 of _runCreditMaintenance: scans creditOffer deals where individual debt exceeds maxBorrowAmountPerOperation. - Splits oversized deals into N equal pieces via atomic repay+reborrow transactions, keeping total debt unchanged. - min_deal_amount guard: skips deals where a piece would be below the offer's minimum deal size. - MAX_PIECES_PER_CYCLE (48, from TIMING constant) prevents unbounded cycles that would exceed the watchdog interval. - 6s settle delay between pieces with shutdown abort (T1). - _splitInFlight concurrency guard (T2) prevents runMaintenance / runCreditWatchdog collisions. - Canonical settle-delay resolution matching dexbot_maintenance_runtime.ts (T3). Note: per-bot TIMING.BLOCKCHAIN_SETTLE_DELAY_MS in bots.json is no longer honoured for split pacing. File: tests/test_credit_runtime.ts - 6 new tests: per-op rejection, offer-selection capping, correct split arithmetic, within-limit skip, no-limit skip, error-pattern match. ## Risk/edge-case notes - T3 settleDelay resolution changed from bot.config.TIMING to the imported TIMING constant — see CHANGELOG. - Offer min_deal_amount is cached; stale values are possible if the offer is edited on-chain mid-split (rare). - T1 shutdown check fires once at the start of each 6s wait; worst case is one extra piece of post-shutdown activity (matches existing pattern). ## Validation - npm run typecheck: clean - npm test: all 38 credit runtime tests pass, full suite clean
1 parent 2a29fb4 commit 05df6e7

6 files changed

Lines changed: 685 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ All notable changes to this project will be documented in this file.
99
- **Fix**: correct AMA4 slowPeriod from 107.4 to 102.4 — aligns AMA4 with the same λ=0.0025 refit applied to AMA1-3 in v1.1.3. Fixes stale slowPeriod references in market_adapter/README.md, test fixtures, and TradingView chart generator (`modules/constants.ts`, `market_adapter/README.md`, `tests/test_market_adapter_log_format.ts`, `tests/test_market_adapter_logic.ts`, `analysis/tradingview/tradingview_uplot_chart_generator.ts`).
1010
- **Feat**: narrow optimizer slow search range from 50-200 to 40-160 — focuses the geometric grid on the range where fitted winners consistently land, reducing wasted evaluations at uncompetitive extremes (`analysis/ama_fitting/optimizer_high_resolution.ts`).
1111
- **Docs**: sync v1.1.3 release notes with amaS% retune (0.085→0.08), update DYNAMIC_WEIGHT_RESEARCH.md knob table, correct EVOLUTION.md commit count for v1.1.3 (2→4) and summary to include the amaS% retune (`CHANGELOG.md`, `analysis/trend_detection/DYNAMIC_WEIGHT_RESEARCH.md`, `docs/EVOLUTION.md`).
12+
- **Feat**: add `maxBorrowAmountPerOperation` config field and `_splitOversizedCreditDeals` — when a credit deal exceeds `maxBorrowAmountPerOperation`, maintenance splits it into equal pieces via repay+reborrow cycles with 6s spacing (`modules/types.ts`, `modules/bot_settings.ts`, `modules/credit_runtime.ts`, `modules/cr_planner.ts`).
13+
- **Fix**: settle-delay resolution for credit splits now reads from the `TIMING` constant instead of `bot.config.TIMING.BLOCKCHAIN_SETTLE_DELAY_MS` — consistent with `dexbot_maintenance_runtime.ts`. Any per-bot `TIMING.BLOCKCHAIN_SETTLE_DELAY_MS` override in `bots.json` is no longer honoured for split pacing (`modules/credit_runtime.ts`).
1214

1315
## [1.1.3] - 2026-07-13 - AMA Refit λ=0.0025, amaS% Retune, tsx Compatibility, Doc Fixes
1416

modules/bot_settings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,13 @@ function validateBotEntry(b: any, i: number, src: string): string | null {
139139
}
140140
}
141141

142+
// maxBorrowAmountPerOperation: optional, must be a fixed positive number
143+
if ('maxBorrowAmountPerOperation' in item) {
144+
if (!isPositiveNumber(item.maxBorrowAmountPerOperation)) {
145+
problems.push(`debtPolicy.lending[${idx}].maxBorrowAmountPerOperation must be a positive number`);
146+
}
147+
}
148+
142149
// maxCollateralAmount: optional, positive number or percentage
143150
if ('maxCollateralAmount' in item && !isPositiveNumberOrPercent(item.maxCollateralAmount)) {
144151
problems.push(`debtPolicy.lending[${idx}].maxCollateralAmount must be a positive number or percentage`);

modules/cr_planner.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ interface DebtFirstCrPlanOptions {
1919
maxCollateralRatio?: number;
2020
targetCollateralRatio?: number;
2121
maxBorrowAmount?: number;
22+
maxBorrowAmountPerOperation?: number;
2223
maxCollateralAmount?: number | string;
2324
collateralLimitReferenceAmount?: number;
2425
minCollateralIncreaseThreshold?: number | string;
@@ -162,6 +163,7 @@ function buildDebtFirstCrPlan({
162163
maxCollateralRatio,
163164
targetCollateralRatio,
164165
maxBorrowAmount,
166+
maxBorrowAmountPerOperation,
165167
maxCollateralAmount,
166168
collateralLimitReferenceAmount,
167169
minCollateralIncreaseThreshold,
@@ -224,7 +226,14 @@ function buildDebtFirstCrPlan({
224226

225227
const targetDebt = (currentCollateralAmount as number) / ((feedPrice as number) * (desiredCr as number));
226228
const rawDebtDelta = targetDebt - (currentDebtAmount as number);
227-
const debtDelta = clampIncreaseToTotalMax(rawDebtDelta, currentDebtAmount, maxBorrowAmount);
229+
let debtDelta = clampIncreaseToTotalMax(rawDebtDelta, currentDebtAmount, maxBorrowAmount);
230+
// Also clamp by per-operation limit
231+
if (debtDelta > 0) {
232+
const limit = positiveOrNull(maxBorrowAmountPerOperation);
233+
if (limit !== null) {
234+
debtDelta = Math.min(debtDelta, limit);
235+
}
236+
}
228237
const projectedDebt = Math.max(0, (currentDebtAmount as number) + debtDelta);
229238
const targetCollateral = (desiredCr as number) * (feedPrice as number) * projectedDebt;
230239
let collateralDelta = targetCollateral - (currentCollateralAmount as number);

modules/credit_runtime.ts

Lines changed: 166 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const {
1717
resolveMinCollateralIncreaseThreshold,
1818
resolveTargetCollateralRatio,
1919
} = require('./cr_planner');
20-
const { FEE_PARAMETERS, DEFAULT_TARGET_CR } = require('./constants');
20+
const { FEE_PARAMETERS, DEFAULT_TARGET_CR, TIMING } = require('./constants');
2121
const { roundToDecimals } = require('./utils/math_utils');
2222
const { PATHS } = require('./paths');
2323
const { readJSON } = require('./utils/fs_utils');
@@ -138,7 +138,7 @@ function isDeterministicMpaDebtBalanceError(err, plan) {
138138

139139
function isMaxBorrowAmountError(err) {
140140
const message = String(err?.message || err || '');
141-
return /would exceed maxBorrowAmount/.test(message);
141+
return /would exceed maxBorrowAmount/.test(message) || /exceeds maxBorrowAmountPerOperation/.test(message);
142142
}
143143

144144
function normalizeCollateralMap(acceptableCollateral) {
@@ -282,6 +282,7 @@ class CreditRuntime {
282282
_maintenanceInFlight: boolean;
283283
_watchdogInFlight: boolean;
284284
_reborrowsInFlight: boolean;
285+
_splitInFlight: boolean;
285286

286287
constructor(bot, options = {}) {
287288
this.bot = bot || {};
@@ -303,6 +304,7 @@ class CreditRuntime {
303304
this._maintenanceInFlight = false;
304305
this._watchdogInFlight = false;
305306
this._reborrowsInFlight = false;
307+
this._splitInFlight = false;
306308
}
307309

308310
_createDefaultState() {
@@ -1435,6 +1437,7 @@ class CreditRuntime {
14351437
maxCollateralRatio: lendingItem.maxCollateralRatio,
14361438
targetCollateralRatio: lendingItem.targetCollateralRatio,
14371439
maxBorrowAmount: lendingItem.maxBorrowAmount,
1440+
maxBorrowAmountPerOperation: lendingItem.maxBorrowAmountPerOperation,
14381441
maxCollateralAmount: posState.assignedCollateralBudget ?? lendingItem.maxCollateralAmount,
14391442
collateralLimitReferenceAmount: posState.currentCollateralFundsTotal,
14401443
minCollateralIncreaseThreshold: lendingItem.minCollateralIncreaseThreshold,
@@ -1624,6 +1627,15 @@ class CreditRuntime {
16241627
}
16251628
}
16261629

1630+
// Enforce per-operation borrow limit
1631+
const maxPerOp = positiveOrNull(policy?.maxBorrowAmountPerOperation);
1632+
if (maxPerOp !== null && Number.isFinite(borrowInt) && borrowInt > 0) {
1633+
const borrowFloat = blockchainToFloat(borrowInt, debtAsset.precision);
1634+
if (Number.isFinite(borrowFloat) && borrowFloat > maxPerOp) {
1635+
throw new Error(`borrowAmount ${borrowFloat} exceeds maxBorrowAmountPerOperation ${maxPerOp}`);
1636+
}
1637+
}
1638+
16271639
if (!Number.isFinite(requiredCollateralInt) || requiredCollateralInt <= 0) {
16281640
throw new Error('Unable to determine collateral amount for credit offer');
16291641
}
@@ -2207,6 +2219,12 @@ class CreditRuntime {
22072219
? Number(remainingBorrowCapacity)
22082220
: null;
22092221

2222+
// Apply per-operation borrow limit on top of remaining capacity
2223+
const maxPerOp = positiveOrNull(policy?.maxBorrowAmountPerOperation);
2224+
const effectiveBorrowCapacity = finiteRemainingBorrowCapacity !== null && maxPerOp !== null
2225+
? Math.min(finiteRemainingBorrowCapacity, maxPerOp)
2226+
: finiteRemainingBorrowCapacity ?? maxPerOp;
2227+
22102228
for (const offerId of allowedOfferIds) {
22112229
const offer = await this._getOfferById(offerId);
22122230
if (offer?.id && !seen.has(String(offer.id))) {
@@ -2241,7 +2259,7 @@ class CreditRuntime {
22412259
autoRepay,
22422260
specificPolicy: policy,
22432261
};
2244-
if (accountId && debtAsset && collateralAsset && finiteRemainingBorrowCapacity !== null) {
2262+
if (accountId && debtAsset && collateralAsset && effectiveBorrowCapacity !== null) {
22452263
const collateralSpec = normalizeAmountSpec(collateralAmount);
22462264
const collateralReferenceAmount = isPercentageAmountSpec(collateralSpec)
22472265
? await this._getCollateralPercentageBase(accountId, collateralAsset.id)
@@ -2258,10 +2276,10 @@ class CreditRuntime {
22582276
collateralAsset
22592277
);
22602278
const desiredBorrowAmount = blockchainToFloat(desiredBorrowInt, debtAsset.precision);
2261-
if (Number.isFinite(desiredBorrowAmount) && desiredBorrowAmount > finiteRemainingBorrowCapacity) {
2279+
if (Number.isFinite(desiredBorrowAmount) && desiredBorrowAmount > effectiveBorrowCapacity) {
22622280
acceptArgs = {
22632281
offer,
2264-
borrowAmount: finiteRemainingBorrowCapacity,
2282+
borrowAmount: effectiveBorrowCapacity,
22652283
collateralAmount: { assetId: collateralAsset.id },
22662284
autoRepay,
22672285
specificPolicy: policy,
@@ -2276,12 +2294,12 @@ class CreditRuntime {
22762294
if (!isMaxBorrowAmountError(err)) {
22772295
throw err;
22782296
}
2279-
if (finiteRemainingBorrowCapacity === null) {
2297+
if (effectiveBorrowCapacity === null) {
22802298
throw err;
22812299
}
22822300
op = await this.buildCreditOfferAcceptOperation({
22832301
offer,
2284-
borrowAmount: finiteRemainingBorrowCapacity,
2302+
borrowAmount: effectiveBorrowCapacity,
22852303
collateralAmount: { assetId: collateralAssetId },
22862304
autoRepay,
22872305
specificPolicy: policy,
@@ -2372,6 +2390,137 @@ class CreditRuntime {
23722390
};
23732391
}
23742392

2393+
async _splitOversizedCreditDeals(lendingItem, assetId, posState, runtimeContext: Record<string, any> = {}) {
2394+
const maxPerOp = positiveOrNull(lendingItem.maxBorrowAmountPerOperation);
2395+
if (maxPerOp === null) return null;
2396+
2397+
// T2: Concurrency guard — prevent concurrent splits from runMaintenance / watchdog
2398+
if (this._splitInFlight) return { skipped: true, reason: 'split in flight' };
2399+
this._splitInFlight = true;
2400+
try {
2401+
return await this._doSplitOversizedCreditDeals(lendingItem, assetId, posState, runtimeContext);
2402+
} finally {
2403+
this._splitInFlight = false;
2404+
}
2405+
}
2406+
2407+
async _doSplitOversizedCreditDeals(lendingItem, assetId, posState, runtimeContext: Record<string, any> = {}) {
2408+
const maxPerOp = positiveOrNull(lendingItem.maxBorrowAmountPerOperation);
2409+
if (maxPerOp === null) return null;
2410+
2411+
const debtAsset = await this._resolveAsset(assetId);
2412+
const collateralAsset = await this._resolveAsset(lendingItem.collateralAsset);
2413+
if (!debtAsset || !collateralAsset) return null;
2414+
2415+
const deals = Array.isArray(posState?.creditDeals) ? posState.creditDeals : [];
2416+
const oversized = deals.filter((d) => {
2417+
const debt = blockchainAmountToFloat(d?.debtAmount, debtAsset);
2418+
return Number.isFinite(debt) && debt > maxPerOp;
2419+
});
2420+
if (oversized.length === 0) return null;
2421+
2422+
// T3: Use canonical settle-delay resolution matching dexbot_maintenance_runtime.ts
2423+
const settleDelay = Number.isFinite(TIMING.BLOCKCHAIN_SETTLE_DELAY_MS)
2424+
? Math.max(0, TIMING.BLOCKCHAIN_SETTLE_DELAY_MS)
2425+
: 6_000;
2426+
2427+
// T4: Hard cap on pieces per cycle so the watchdog interval is never exceeded
2428+
const MAX_PIECES_PER_CYCLE = Number.isFinite(TIMING.CREDIT_DEAL_SPLIT_MAX_PIECES)
2429+
? Math.max(0, TIMING.CREDIT_DEAL_SPLIT_MAX_PIECES)
2430+
: 48;
2431+
2432+
const configuredCollateralAssetId = collateralAsset.id;
2433+
const posKey = configuredCollateralAssetId
2434+
? this._positionKey(assetId, configuredCollateralAssetId)
2435+
: assetId;
2436+
let prevPieceAt = 0;
2437+
let totalPiecesThisCycle = 0;
2438+
2439+
for (const deal of oversized) {
2440+
const dealId = String(deal.id);
2441+
let currentDeal = deal;
2442+
2443+
const dealDebt = blockchainAmountToFloat(currentDeal.debtAmount, debtAsset);
2444+
if (!Number.isFinite(dealDebt) || dealDebt <= maxPerOp) continue;
2445+
2446+
// Check min_deal_amount on the offer to avoid reborrows that would fail.
2447+
// Note: _getOfferById caches the offer for the runtime lifetime — if the
2448+
// offer's min_deal_amount changes on-chain mid-split, the guard uses the
2449+
// stale cached value. Risk is low in practice (offers rarely change this).
2450+
const dealOffer = await this._getOfferById(parseDealSummary(currentDeal)?.offerId);
2451+
const minDealAmount = toFiniteNumber(dealOffer?.min_deal_amount, null);
2452+
const numPieces = Math.ceil(dealDebt / maxPerOp);
2453+
const pieceAmount = roundToDecimals(dealDebt / numPieces, debtAsset.precision);
2454+
if (minDealAmount !== null && pieceAmount < blockchainToFloat(minDealAmount, debtAsset.precision)) {
2455+
this.warn(`credit runtime: cannot split deal ${dealId} — piece amount ${pieceAmount} below min_deal_amount ${blockchainToFloat(minDealAmount, debtAsset.precision)} for offer ${dealOffer?.id}`);
2456+
continue;
2457+
}
2458+
2459+
const dealPieces = Math.min(numPieces - 1, MAX_PIECES_PER_CYCLE - totalPiecesThisCycle);
2460+
if (dealPieces <= 0) {
2461+
const remainingDeals = oversized.length - oversized.indexOf(deal) - 1;
2462+
this.warn(`credit runtime: hit cap (${MAX_PIECES_PER_CYCLE}); deferring deal ${dealId} and ${remainingDeals} other deal(s)`);
2463+
break;
2464+
}
2465+
if (dealPieces < numPieces - 1) {
2466+
this.warn(`credit runtime: splitting only ${dealPieces} of ${numPieces - 1} pieces for deal ${dealId} this cycle (cap: ${MAX_PIECES_PER_CYCLE})`);
2467+
}
2468+
2469+
for (let i = 0; i < dealPieces; i++) {
2470+
// T1: Abort on shutdown during settle delay
2471+
if (prevPieceAt > 0) {
2472+
await new Promise((resolve, reject) => {
2473+
const t = setTimeout(resolve, settleDelay);
2474+
if (this.bot?._shuttingDown) {
2475+
clearTimeout(t);
2476+
reject(new Error('shutting down'));
2477+
}
2478+
});
2479+
}
2480+
2481+
// Re-fetch deal from current state (may have been refreshed by repayCreditDeal)
2482+
const pos = this.state.positions?.[posKey];
2483+
const refreshed = Array.isArray(pos?.creditDeals)
2484+
? pos.creditDeals.find((d) => String(d.id) === dealId)
2485+
: null;
2486+
if (!refreshed) {
2487+
this.warn(`credit runtime: deal ${dealId} (asset ${assetId}) vanished during restructure`);
2488+
break;
2489+
}
2490+
currentDeal = refreshed;
2491+
2492+
const remaining = blockchainAmountToFloat(currentDeal.debtAmount, debtAsset);
2493+
if (!Number.isFinite(remaining) || remaining <= maxPerOp) break;
2494+
2495+
const currentPiece = Math.min(pieceAmount, remaining - maxPerOp);
2496+
if (currentPiece <= 0) break;
2497+
2498+
this.log(`credit runtime: splitting deal ${dealId}: repaying ${currentPiece} of ${remaining} debt (piece ${i + 1}/${dealPieces})`);
2499+
2500+
await this.repayCreditDeal(currentDeal, currentPiece, {
2501+
autoReborrow: true,
2502+
specificPolicy: lendingItem,
2503+
fillLockAlreadyHeld: runtimeContext?.options?.fillLockAlreadyHeld === true,
2504+
});
2505+
2506+
prevPieceAt = Date.now();
2507+
totalPiecesThisCycle++;
2508+
}
2509+
}
2510+
2511+
if (prevPieceAt > 0) {
2512+
// N3: skip heavy refresh on cap-exit — repayCreditDeal already called refreshState
2513+
if (totalPiecesThisCycle < MAX_PIECES_PER_CYCLE) {
2514+
await this.refreshCreditState({}, lendingItem);
2515+
}
2516+
const gridResult = await this._checkGridMaintenanceAfterCreditUpdate('credit restructure', {
2517+
fillLockAlreadyHeld: runtimeContext?.options?.fillLockAlreadyHeld === true,
2518+
});
2519+
return { action: 'restructured', gridMaintenanceResult: gridResult };
2520+
}
2521+
return null;
2522+
}
2523+
23752524
async _getDealById(dealId) {
23762525
if (!dealId) return null;
23772526
const deals = Array.isArray(this.state.creditDeals) ? this.state.creditDeals : [];
@@ -2644,6 +2793,16 @@ class CreditRuntime {
26442793
const posState = this.state.positions[posKey];
26452794
if (!posState) return null;
26462795

2796+
// Phase 0: Split oversized credit deals that exceed maxBorrowAmountPerOperation
2797+
try {
2798+
const splitResult = await this._splitOversizedCreditDeals(lendingItem, assetId, posState, runtimeContext);
2799+
if (splitResult) {
2800+
this.log(`credit runtime: restructured oversized deals for ${assetId}`);
2801+
}
2802+
} catch (err: any) {
2803+
this.warn(`credit runtime: deal restructuring failed: ${err.message}`);
2804+
}
2805+
26472806
let activeDealIds = new Set((posState.creditDeals || []).map((d) => String(d?.id)).filter(Boolean));
26482807

26492808
for (const deal of (posState.creditDeals || [])) {

modules/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,12 @@ export interface LendingEntryBase {
10051005
ratio?: number;
10061006
outputWeight?: number;
10071007
maxBorrowAmount?: number;
1008+
/** Max debt per single borrow operation (per-op cap, independent of
1009+
* maxBorrowAmount's total ceiling). When a deal exceeds this, maintenance
1010+
* splits it into equal pieces ≤ this value via repay+reborrow cycles with
1011+
* 6s spacing. Only applies to `creditOffer` type; `mpa` uses the same
1012+
* field to cap one-shot debt increases in CR-adjustment plans. */
1013+
maxBorrowAmountPerOperation?: number;
10081014
maxCollateralAmount?: number | string;
10091015
minCollateralIncreaseThreshold?: number | string;
10101016
maxCollateralRatio?: number;

0 commit comments

Comments
 (0)