diff --git a/src/features/positions/components/rebalance/rebalance-modal.tsx b/src/features/positions/components/rebalance/rebalance-modal.tsx index 14efccd5..06a84cc1 100644 --- a/src/features/positions/components/rebalance/rebalance-modal.tsx +++ b/src/features/positions/components/rebalance/rebalance-modal.tsx @@ -36,6 +36,7 @@ import { FiTrash2 } from 'react-icons/fi'; import { AllocationCell } from '../allocation-cell'; import { FromMarketsTable } from '../from-markets-table'; import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/features/markets/components/market-identity'; +import { RiskIndicator } from '@/features/markets/components/risk-indicator'; import { RebalanceActionInput } from './rebalance-action-input'; import { RebalanceCart } from './rebalance-cart'; @@ -143,6 +144,30 @@ function PreviewSection({ title, rows }: { title: string; rows: PreviewRow[] }) ); } +function formatAmountForSmartConstraintLog(value: bigint, decimals: number): { raw: string; formatted: string } { + return { + raw: value.toString(), + formatted: formatUnits(value, decimals), + }; +} + +function getSmartConstraintWarning(plan: SmartRebalancePlan | null): { title: string; detail: string } | null { + const violations = plan?.diagnostics.constraintViolations ?? []; + if (violations.length === 0) return null; + + const reasons = new Set(violations.map((violation) => violation.reason)); + + if (!reasons.has('locked-liquidity')) { + return null; + } + + return { + title: 'Some max-allocation limits could not be fully satisfied.', + detail: + 'One or more positions could not be reduced far enough because of current withdrawable liquidity. Check the console for per-market details.', + }; +} + export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, isRefetching }: RebalanceModalProps) { const [mode, setMode] = useState('smart'); @@ -292,6 +317,30 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, }) .then((plan) => { if (id !== calcIdRef.current) return; + + if (plan && (plan.diagnostics.constraintViolations.length > 0 || plan.diagnostics.unallocatedAmount > 0n)) { + console.warn('[smart-rebalance] unmet max-allocation constraints', { + calcId: id, + chainId: groupedPosition.chainId, + loanAssetSymbol: groupedPosition.loanAssetSymbol, + totalPool: formatAmountForSmartConstraintLog(plan.totalPool, groupedPosition.loanAssetDecimals), + totalMoved: formatAmountForSmartConstraintLog(plan.totalMoved, groupedPosition.loanAssetDecimals), + unallocatedAmount: formatAmountForSmartConstraintLog(plan.diagnostics.unallocatedAmount, groupedPosition.loanAssetDecimals), + violations: plan.diagnostics.constraintViolations.map((violation) => ({ + uniqueKey: violation.uniqueKey, + collateralSymbol: violation.collateralSymbol, + maxAllocationPercent: violation.maxAllocationBps / 100, + reason: violation.reason, + currentAmount: formatAmountForSmartConstraintLog(violation.currentAmount, groupedPosition.loanAssetDecimals), + targetAmount: formatAmountForSmartConstraintLog(violation.targetAmount, groupedPosition.loanAssetDecimals), + maxAllowedAmount: formatAmountForSmartConstraintLog(violation.maxAllowedAmount, groupedPosition.loanAssetDecimals), + excessAmount: formatAmountForSmartConstraintLog(violation.excessAmount, groupedPosition.loanAssetDecimals), + maxWithdrawable: formatAmountForSmartConstraintLog(violation.maxWithdrawable, groupedPosition.loanAssetDecimals), + lockedAmount: formatAmountForSmartConstraintLog(violation.lockedAmount, groupedPosition.loanAssetDecimals), + })), + }); + } + setSmartPlan(plan); }) .catch((error: unknown) => { @@ -336,10 +385,15 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, const smartCurrentWeightedRate = isAprDisplay ? smartCurrentWeightedApr : smartCurrentWeightedApy; const smartProjectedWeightedRate = isAprDisplay ? smartProjectedWeightedApr : smartProjectedWeightedApy; const smartWeightedRateDiff = smartProjectedWeightedRate - smartCurrentWeightedRate; + const smartNetWithdrawal = smartPlan?.diagnostics.unallocatedAmount ?? 0n; const smartCapitalMovedPreview = useMemo( () => formatTokenAmountPreview(smartTotalMoved, groupedPosition.loanAssetDecimals), [groupedPosition.loanAssetDecimals, smartTotalMoved], ); + const smartNetWithdrawalPreview = useMemo( + () => formatTokenAmountPreview(smartNetWithdrawal, groupedPosition.loanAssetDecimals), + [groupedPosition.loanAssetDecimals, smartNetWithdrawal], + ); const smartFeePreview = useMemo( () => (smartFeeAmount == null ? null : formatTokenAmountPreview(smartFeeAmount, groupedPosition.loanAssetDecimals)), [groupedPosition.loanAssetDecimals, smartFeeAmount], @@ -376,6 +430,14 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, label: 'Capital Moved', value: fmtAmount(smartTotalMoved), }); + if (smartNetWithdrawal > 0n) { + items.push({ + id: 'net-withdrawal', + label: 'Net Withdrawal', + value: fmtAmount(smartNetWithdrawal), + detail: 'returned to wallet', + }); + } if (smartFeePreview != null) { items.push({ id: 'fee', @@ -395,6 +457,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, estimatedDailyEarningsUsd, smartFeePreview, smartFeeSummaryDetail, + smartNetWithdrawal, smartPlan, smartProjectedWeightedRate, smartTotalMoved, @@ -503,26 +566,7 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, }); }, [currentSupplyByMarket, groupedPosition.loanAssetDecimals, marketByKey, smartPlan, smartSelectedMarketKeys]); - const constraintViolations = useMemo(() => { - if (!smartPlan) return []; - - const deltaByMarket = new Map(smartPlan.deltas.map((delta) => [delta.market.uniqueKey, delta])); - const violations: { uniqueKey: string; maxAllocationBps: number }[] = []; - - for (const [uniqueKey, maxAllocationBps] of Object.entries(debouncedSmartMaxAllocationBps)) { - if (maxAllocationBps >= 10_000) continue; - - const delta = deltaByMarket.get(uniqueKey); - const targetAmount = delta?.targetAmount ?? currentSupplyByMarket.get(uniqueKey) ?? 0n; - const maxAllowedAmount = (smartPlan.totalPool * BigInt(maxAllocationBps)) / 10_000n; - - if (targetAmount > maxAllowedAmount) { - violations.push({ uniqueKey, maxAllocationBps }); - } - } - - return violations; - }, [currentSupplyByMarket, debouncedSmartMaxAllocationBps, smartPlan]); + const smartConstraintWarning = useMemo(() => getSmartConstraintWarning(smartPlan), [smartPlan]); const isSmartWithdrawOnly = useMemo(() => { if (!smartPlan || smartTotalMoved === 0n) return false; @@ -862,6 +906,30 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, ), }); + if (smartNetWithdrawal > 0n) { + rows.push({ + id: 'net-withdrawal', + label: 'Net Withdrawal', + value: ( + + + {smartNetWithdrawalPreview.compact} + + + + ), + }); + } + return rows; }, [ estimatedDailyEarningsUsd, @@ -871,8 +939,9 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, rateLabel, smartCapitalMovedPreview, smartCurrentWeightedRate, + smartNetWithdrawal, + smartNetWithdrawalPreview, smartProjectedWeightedRate, - smartTotalMoved, smartWeightedRateDiff, ]); @@ -1134,9 +1203,18 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, {!isSmartCalculating && smartPlan && smartTotalMoved > 0n && !isSmartFeeReady && (
Waiting for loan asset USD price to enforce the smart rebalance fee cap.
)} - {!isSmartCalculating && constraintViolations.length > 0 && ( -
- Some max-allocation limits could not be fully satisfied due to current market liquidity/capacity. + {!isSmartCalculating && smartConstraintWarning != null && ( +
+
+ +
+
+
{smartConstraintWarning.title}
+
{smartConstraintWarning.detail}
+
)} diff --git a/src/features/positions/smart-rebalance/engine.ts b/src/features/positions/smart-rebalance/engine.ts index 746dfe9e..7b4bbf09 100644 --- a/src/features/positions/smart-rebalance/engine.ts +++ b/src/features/positions/smart-rebalance/engine.ts @@ -1,5 +1,12 @@ import type { Market as BlueMarket } from '@morpho-org/blue-sdk'; -import type { SmartRebalanceConstraintMap, SmartRebalanceDelta, SmartRebalanceEngineInput, SmartRebalanceEngineOutput } from './types'; +import type { + SmartRebalanceConstraintMap, + SmartRebalanceConstraintViolation, + SmartRebalanceConstraintViolationReason, + SmartRebalanceDelta, + SmartRebalanceEngineInput, + SmartRebalanceEngineOutput, +} from './types'; const MAX_CHUNKS = 100n; const APY_SCALE = 1_000_000_000_000n; @@ -79,11 +86,13 @@ type CleanStateResult = { maxAllocationMap: Map; allocations: Map; marketMap: Map; + lockedAmountMap: Map; }; type ChunkAllocationState = { allocations: Map; marketMap: Map; + unallocatedAmount: bigint; }; function cleanStates( @@ -96,6 +105,7 @@ function cleanStates( const maxAllocationMap = new Map(); const allocations = new Map(); const marketMap = new Map(); + const lockedAmountMap = new Map(); let runningPrincipal = principal; @@ -109,6 +119,7 @@ function cleanStates( movableKeys.push(entry.uniqueKey); allocations.set(entry.uniqueKey, lockedAmount); marketMap.set(entry.uniqueKey, marketAfter); + lockedAmountMap.set(entry.uniqueKey, lockedAmount); runningPrincipal += withdrawnAmount; } @@ -118,6 +129,7 @@ function cleanStates( maxAllocationMap, allocations, marketMap, + lockedAmountMap, }; } @@ -197,13 +209,18 @@ function calculateAllocation( marketMap: Map, maxAllocationMap: Map, ): ChunkAllocationState { + let unallocatedAmount = 0n; + for (const chunk of chunks) { let remainingChunk = chunk; if (remainingChunk <= 0n) continue; while (remainingChunk > 0n) { const best = findBestSupplyTarget(remainingChunk, movableKeys, uniqueKeys, allocations, marketMap, maxAllocationMap); - if (!best) break; + if (!best) { + unallocatedAmount += remainingChunk; + break; + } allocations.set(best.uniqueKey, (allocations.get(best.uniqueKey) ?? 0n) + best.amount); marketMap.set(best.uniqueKey, best.marketAfter); @@ -214,6 +231,7 @@ function calculateAllocation( return { allocations, marketMap, + unallocatedAmount, }; } @@ -248,6 +266,82 @@ function sumTotalMoved(deltas: SmartRebalanceDelta[]): bigint { }, 0n); } +function hasExplicitMaxAllocationConstraint( + entries: SmartRebalanceEngineInput['entries'], + constraints: SmartRebalanceConstraintMap | undefined, +): boolean { + return entries.some((entry) => { + const maxAllocationBps = clampBps(constraints?.[entry.uniqueKey]?.maxAllocationBps); + return maxAllocationBps !== undefined && maxAllocationBps < 10_000; + }); +} + +function buildDiagnostics({ + entries, + constraints, + totalPool, + allocations, + maxAllocationMap, + lockedAmountMap, + unallocatedAmount, +}: { + entries: SmartRebalanceEngineInput['entries']; + constraints: SmartRebalanceConstraintMap | undefined; + totalPool: bigint; + allocations: Map; + maxAllocationMap: Map; + lockedAmountMap: Map; + unallocatedAmount: bigint; +}): SmartRebalanceEngineOutput['diagnostics'] { + const hasUnboundedSelectedMarket = entries.some((entry) => maxAllocationMap.get(entry.uniqueKey) === undefined); + const totalSelectedCapacity = hasUnboundedSelectedMarket + ? null + : entries.reduce((sum, entry) => sum + (maxAllocationMap.get(entry.uniqueKey) ?? 0n), 0n); + const selectedCapacityShortfall = + totalSelectedCapacity !== null && totalSelectedCapacity < totalPool ? totalPool - totalSelectedCapacity : 0n; + + const constraintViolations: SmartRebalanceConstraintViolation[] = []; + + for (const entry of entries) { + const maxAllocationBps = clampBps(constraints?.[entry.uniqueKey]?.maxAllocationBps); + if (maxAllocationBps === undefined || maxAllocationBps >= 10_000) continue; + + const maxAllowedAmount = maxAllocationMap.get(entry.uniqueKey); + if (maxAllowedAmount === undefined) continue; + + const targetAmount = allocations.get(entry.uniqueKey) ?? entry.currentSupply; + if (targetAmount <= maxAllowedAmount) continue; + + const lockedAmount = lockedAmountMap.get(entry.uniqueKey) ?? 0n; + const requiredReduction = entry.currentSupply > maxAllowedAmount ? entry.currentSupply - maxAllowedAmount : 0n; + + let reason: SmartRebalanceConstraintViolationReason = 'unknown'; + if (requiredReduction > entry.maxWithdrawable) { + reason = 'locked-liquidity'; + } else if (selectedCapacityShortfall > 0n || unallocatedAmount > 0n) { + reason = 'selected-capacity'; + } + + constraintViolations.push({ + uniqueKey: entry.uniqueKey, + collateralSymbol: entry.market.collateralAsset?.symbol ?? 'N/A', + maxAllocationBps, + currentAmount: entry.currentSupply, + targetAmount, + maxAllowedAmount, + excessAmount: targetAmount - maxAllowedAmount, + maxWithdrawable: entry.maxWithdrawable, + lockedAmount, + reason, + }); + } + + return { + constraintViolations, + unallocatedAmount, + }; +} + /** * Pure smart-rebalance optimizer. * @@ -269,6 +363,7 @@ export function planRebalance(input: SmartRebalanceEngineInput): SmartRebalanceE if (totalPool <= 0n) return null; const uniqueKeys = entries.map((entry) => entry.uniqueKey); + const hasExplicitConstraints = hasExplicitMaxAllocationConstraint(entries, constraints); // 1. Simulate extra liquidity and start state: // - attempt best-effort withdrawal from each selected market @@ -293,6 +388,15 @@ export function planRebalance(input: SmartRebalanceEngineInput): SmartRebalanceE currentWeightedApy: objectiveToWeightedApy(currentObjective, totalPool), projectedWeightedApy: objectiveToWeightedApy(projectedObjective, totalPool), totalMoved: sumTotalMoved(deltas), + diagnostics: buildDiagnostics({ + entries, + constraints, + totalPool, + allocations: cleaned.allocations, + maxAllocationMap: cleaned.maxAllocationMap, + lockedAmountMap: cleaned.lockedAmountMap, + unallocatedAmount: 0n, + }), }; } @@ -319,8 +423,9 @@ export function planRebalance(input: SmartRebalanceEngineInput): SmartRebalanceE ); const projectedObjective = computeObjective(uniqueKeys, allocated.allocations, allocated.marketMap); - // Reliability guard: never return a plan that is worse than the current weighted objective. - if (projectedObjective < currentObjective) { + // Reliability guard: for unconstrained auto-optimization, never return a plan that is worse than the current weighted objective. + // Explicit max-allocation caps are user intent and should still produce the best feasible constrained plan even if the weighted rate falls. + if (!hasExplicitConstraints && projectedObjective < currentObjective) { const noOpAllocations = new Map(entries.map((entry) => [entry.uniqueKey, entry.currentSupply])); const noOpMarkets = new Map(entries.map((entry) => [entry.uniqueKey, entry.baselineMarket])); const noOpDeltas = buildDeltas(entries, noOpAllocations, noOpMarkets); @@ -335,6 +440,15 @@ export function planRebalance(input: SmartRebalanceEngineInput): SmartRebalanceE currentWeightedApy: objectiveToWeightedApy(currentObjective, totalPool), projectedWeightedApy: objectiveToWeightedApy(currentObjective, totalPool), totalMoved: 0n, + diagnostics: buildDiagnostics({ + entries, + constraints, + totalPool, + allocations: noOpAllocations, + maxAllocationMap: cleaned.maxAllocationMap, + lockedAmountMap: cleaned.lockedAmountMap, + unallocatedAmount: 0n, + }), }; } @@ -350,6 +464,15 @@ export function planRebalance(input: SmartRebalanceEngineInput): SmartRebalanceE currentWeightedApy: objectiveToWeightedApy(currentObjective, totalPool), projectedWeightedApy: objectiveToWeightedApy(projectedObjective, totalPool), totalMoved, + diagnostics: buildDiagnostics({ + entries, + constraints, + totalPool, + allocations: allocated.allocations, + maxAllocationMap: cleaned.maxAllocationMap, + lockedAmountMap: cleaned.lockedAmountMap, + unallocatedAmount: allocated.unallocatedAmount, + }), }; } diff --git a/src/features/positions/smart-rebalance/types.ts b/src/features/positions/smart-rebalance/types.ts index 4f66780a..16fd5c7e 100644 --- a/src/features/positions/smart-rebalance/types.ts +++ b/src/features/positions/smart-rebalance/types.ts @@ -25,6 +25,26 @@ export type SmartRebalanceConstraintMap = Record< } >; +export type SmartRebalanceConstraintViolationReason = 'locked-liquidity' | 'selected-capacity' | 'unknown'; + +export type SmartRebalanceConstraintViolation = { + uniqueKey: string; + collateralSymbol: string; + maxAllocationBps: number; + currentAmount: bigint; + targetAmount: bigint; + maxAllowedAmount: bigint; + excessAmount: bigint; + maxWithdrawable: bigint; + lockedAmount: bigint; + reason: SmartRebalanceConstraintViolationReason; +}; + +export type SmartRebalanceDiagnostics = { + constraintViolations: SmartRebalanceConstraintViolation[]; + unallocatedAmount: bigint; +}; + export type SmartRebalanceEngineInput = { entries: SmartRebalanceEngineEntry[]; constraints?: SmartRebalanceConstraintMap; @@ -51,4 +71,5 @@ export type SmartRebalanceEngineOutput = { currentWeightedApy: number; projectedWeightedApy: number; totalMoved: bigint; + diagnostics: SmartRebalanceDiagnostics; };