diff --git a/package-lock.json b/package-lock.json index 113e3f6..2724ea7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4448,6 +4448,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2d1efd3..046f4d3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,8 +62,10 @@ model User { displayName String? email String? @unique avatarUrl String? - riskTolerance Int @default(5) - isActive Boolean @default(true) + riskTolerance Int @default(5) + rebalanceStrategy String? // 'MAX_YIELD' | 'TARGET_ALLOCATION' | null (defaults to MAX_YIELD) + strategyConfig Json? // e.g. { "targetAllocations": { "Blend": 50, "Stellar DEX": 30, "Luma": 20 } } + isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/agent/loop.ts b/src/agent/loop.ts index f0ab294..b90ea4b 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -16,7 +16,7 @@ import { recordRebalanceTriggered, recordDbOperation, recordBackgroundJob, - recordExternalServiceError + recordExternalServiceError, } from '../utils/metrics'; let isRunning = false; @@ -95,26 +95,45 @@ async function rebalanceCheckJob(): Promise { return; } - const byProtocol = new Map(); + // Group by (protocol, strategy) so users with different strategies + // are evaluated independently + type PositionWithUser = typeof positions[number]; + const byProtocolAndStrategy = new Map(); for (const pos of positions) { - const key = pos.protocolName; - if (!byProtocol.has(key)) { - byProtocol.set(key, []); + const strategy = (pos.user as any).rebalanceStrategy || 'DEFAULT'; + const key = `${pos.protocolName}:${strategy}`; + if (!byProtocolAndStrategy.has(key)) { + byProtocolAndStrategy.set(key, []); } - byProtocol.get(key)!.push(pos); + byProtocolAndStrategy.get(key)!.push(pos); } let rebalancesTriggered = 0; const thresholds = getThresholds(); - for (const [protocol, protocolPositions] of byProtocol.entries()) { + for (const [key, protocolPositions] of byProtocolAndStrategy.entries()) { + const [protocol, strategyKey] = key.split(':'); + const strategyName = strategyKey === 'DEFAULT' ? undefined : strategyKey; + + // Build per-user strategy preferences + const userStrategyPreferences = strategyName + ? protocolPositions.map((p: any) => ({ + userId: p.userId, + strategyName: p.user.rebalanceStrategy || null, + targetAllocations: p.user.strategyConfig?.targetAllocations || undefined, + riskTolerance: p.user.riskTolerance, + })) + : undefined; + const result = await executeRebalanceIfNeeded( protocol, protocolPositions.map((p: any) => ({ id: p.id, amount: p.currentValue.toString(), + userId: p.userId, })), - thresholds + thresholds, + userStrategyPreferences, ); if (result) { diff --git a/src/agent/router.ts b/src/agent/router.ts index 7ce9034..ec3be7b 100644 --- a/src/agent/router.ts +++ b/src/agent/router.ts @@ -4,9 +4,10 @@ import { logger } from '../utils/logger'; import { getCorrelationId } from '../utils/correlation'; -import { ProtocolComparison, RebalanceDetails, RebalanceThresholds } from './types'; +import { ProtocolComparison, RebalanceDetails, RebalanceThresholds, RebalanceStrategy, UserStrategyPreferences } from './types'; import { scanAllProtocols, getCurrentOnChainApy } from './scanner'; import { triggerRebalance as submitRebalance } from '../stellar/contract'; +import { MaxYieldStrategy, TargetAllocationStrategy } from './strategies'; import db from '../db'; const DEFAULT_THRESHOLDS: RebalanceThresholds = { @@ -128,6 +129,7 @@ export async function triggerRebalance( toProtocol: string, amount: string, positionIds: string[] = [], + strategyInfo?: { name: string; reasoning: string; deviationTrigger?: string }, ): Promise { const startTime = Date.now(); @@ -215,11 +217,19 @@ export async function triggerRebalance( seen.add(key); await logAgentAction('REBALANCE', 'SUCCESS', { rebalanceDetail, + strategyName: strategyInfo?.name, + reasoning: strategyInfo?.reasoning, + deviationTrigger: strategyInfo?.deviationTrigger, }, pos.userId, pos.id); } } else { // No positions linked – log as system-level (userId stays null) - await logAgentAction('REBALANCE', 'SUCCESS', { rebalanceDetail }); + await logAgentAction('REBALANCE', 'SUCCESS', { + rebalanceDetail, + strategyName: strategyInfo?.name, + reasoning: strategyInfo?.reasoning, + deviationTrigger: strategyInfo?.deviationTrigger, + }); } logger.info('Rebalance successful', { @@ -257,11 +267,11 @@ export async function triggerRebalance( */ export async function executeRebalanceIfNeeded( currentProtocol: string, - userPositions: Array<{ id: string; amount: string }>, - thresholds?: RebalanceThresholds + userPositions: Array<{ id: string; amount: string; userId?: string }>, + thresholds?: RebalanceThresholds, + userStrategyPreferences?: UserStrategyPreferences[], ): Promise { try { - // Sum all user positions FIRST to account for costs const totalAmount = userPositions .reduce( (sum, pos) => sum + BigInt(pos.amount), @@ -269,8 +279,60 @@ export async function executeRebalanceIfNeeded( ) .toString(); - // FIXED: Pass totalAmount to compareProtocols so it can account for transaction costs - const comparison = await compareProtocols(currentProtocol, totalAmount, thresholds); + const effectiveThresholds = thresholds ?? getThresholds(); + + // Use strategy engine when user preferences are present + if (userStrategyPreferences && userStrategyPreferences.length > 0) { + const currentApy = await getCurrentOnChainApy(currentProtocol); + if (!currentApy) { + logger.warn(`Cannot get current APY for ${currentProtocol}`); + return null; + } + + const allProtocols = await scanAllProtocols(); + if (allProtocols.length === 0) { + logger.warn('No protocols available for comparison'); + return null; + } + + const preferredStrategy = userStrategyPreferences[0]?.strategyName; + const strategy: RebalanceStrategy = + preferredStrategy === 'TARGET_ALLOCATION' + ? new TargetAllocationStrategy() + : new MaxYieldStrategy(); + + const decision = await strategy.analyze({ + currentProtocol, + totalAmount, + currentApy, + availableProtocols: allProtocols, + thresholds: effectiveThresholds, + userStrategyPreferences, + }); + + if (!decision.shouldRebalance) { + logger.info('No rebalance needed (strategy)', { + strategy: strategy.name, + reasoning: decision.reasoning, + }); + return null; + } + + return await triggerRebalance( + currentProtocol, + decision.targetProtocol, + totalAmount, + userPositions.map(pos => pos.id), + { + name: strategy.name, + reasoning: decision.reasoning, + deviationTrigger: decision.deviationTrigger, + }, + ); + } + + // Default: existing compareProtocols flow (backward compatible) + const comparison = await compareProtocols(currentProtocol, totalAmount, effectiveThresholds); if (!comparison || !comparison.shouldRebalance) { logger.info('No rebalance needed', { @@ -286,6 +348,11 @@ export async function executeRebalanceIfNeeded( comparison.best.name, totalAmount, userPositions.map(pos => pos.id), + { + name: 'MAX_YIELD', + reasoning: `Moving from ${currentProtocol} to ${comparison.best.name} — net gain ${comparison.improvement.toFixed(2)}% after costs`, + deviationTrigger: `APY delta: ${(comparison.best.apy - (comparison.current.apy)).toFixed(2)}%`, + }, ); } catch (error) { logger.error('Rebalance execution check failed', { diff --git a/src/agent/strategies.ts b/src/agent/strategies.ts new file mode 100644 index 0000000..84c79e1 --- /dev/null +++ b/src/agent/strategies.ts @@ -0,0 +1,189 @@ +import { + RebalanceStrategy, + StrategyName, + StrategyDecision, + StrategyParams, +} from './types'; +import { logger } from '../utils/logger'; + +function estimateRebalanceCosts( + amount: string, + maxGasPercent: number +): { gasFeePercent: number; slippagePercent: number; totalCostPercent: number } { + const gasEstimateUSD = 0.50; + const amountUSD = parseInt(amount) / 1e18; + const gasFeePercent = amountUSD > 0 ? (gasEstimateUSD / amountUSD) * 100 : 0; + const slippagePercent = Math.min(maxGasPercent * 0.5, 0.25); + + return { + gasFeePercent: Math.min(gasFeePercent, maxGasPercent), + slippagePercent, + totalCostPercent: Math.min(gasFeePercent + slippagePercent, maxGasPercent), + }; +} + +export class MaxYieldStrategy implements RebalanceStrategy { + readonly name: StrategyName = 'MAX_YIELD'; + + async analyze(params: StrategyParams): Promise { + const { currentProtocol, totalAmount, currentApy, availableProtocols, thresholds } = params; + + if (availableProtocols.length === 0) { + return { + shouldRebalance: false, + targetProtocol: currentProtocol, + reasoning: 'No protocols available for comparison', + }; + } + + const bestProtocol = availableProtocols[0]; + + if (bestProtocol.name === currentProtocol) { + return { + shouldRebalance: false, + targetProtocol: currentProtocol, + reasoning: `Already on the highest-yielding protocol (${currentProtocol} at ${currentApy.toFixed(2)}%)`, + }; + } + + const rawImprovement = bestProtocol.apy - currentApy; + const costs = estimateRebalanceCosts(totalAmount, thresholds.maxGasPercent); + const netImprovement = rawImprovement - costs.totalCostPercent; + + const shouldRebalance = + netImprovement > thresholds.minimumImprovement && + costs.totalCostPercent < thresholds.maxGasPercent; + + if (shouldRebalance) { + logger.info('MaxYieldStrategy: rebalance recommended', { + from: currentProtocol, + to: bestProtocol.name, + currentApy, + bestApy: bestProtocol.apy, + rawImprovement: rawImprovement.toFixed(2), + netImprovement: netImprovement.toFixed(2), + gasCost: costs.gasFeePercent.toFixed(4), + slippage: costs.slippagePercent.toFixed(4), + }); + } + + return { + shouldRebalance, + targetProtocol: shouldRebalance ? bestProtocol.name : currentProtocol, + reasoning: shouldRebalance + ? `Moving from ${currentProtocol} (${currentApy.toFixed(2)}%) to ${bestProtocol.name} (${bestProtocol.apy.toFixed(2)}%) — net gain ${netImprovement.toFixed(2)}% after gas/slippage` + : `Net improvement ${netImprovement.toFixed(2)}% below threshold ${thresholds.minimumImprovement}%`, + deviationTrigger: shouldRebalance ? `APY delta: ${rawImprovement.toFixed(2)}%` : undefined, + details: { + currentApy, + bestApy: bestProtocol.apy, + bestProtocol: bestProtocol.name, + rawImprovement, + netImprovement, + gasFeePercent: costs.gasFeePercent, + slippagePercent: costs.slippagePercent, + totalCostPercent: costs.totalCostPercent, + }, + }; + } +} + +export class TargetAllocationStrategy implements RebalanceStrategy { + readonly name: StrategyName = 'TARGET_ALLOCATION'; + + private readonly targetDeviationThreshold = 0.2; + + async analyze(params: StrategyParams): Promise { + const { currentProtocol, totalAmount, currentApy, availableProtocols, thresholds, userStrategyPreferences } = params; + + const relevantPrefs = userStrategyPreferences.filter(p => p.targetAllocations && Object.keys(p.targetAllocations!).length > 0); + if (relevantPrefs.length === 0) { + return { + shouldRebalance: false, + targetProtocol: currentProtocol, + reasoning: 'No target allocations configured for these users', + }; + } + + const pref = relevantPrefs[0]; + const targets = pref.targetAllocations!; + const currentTarget = targets[currentProtocol]; + + if (currentTarget === undefined) { + return { + shouldRebalance: false, + targetProtocol: currentProtocol, + reasoning: `No target allocation set for ${currentProtocol}`, + }; + } + + const totalTarget = Object.values(targets).reduce((sum, v) => sum + v, 0); + const targetShare = totalTarget > 0 ? currentTarget / totalTarget : 0; + + const bestTargetProtocol = Object.entries(targets) + .filter(([name]) => name !== currentProtocol) + .sort(([, a], [, b]) => b - a); + + if (bestTargetProtocol.length === 0) { + return { + shouldRebalance: false, + targetProtocol: currentProtocol, + reasoning: `Only one protocol configured in targets — no rebalance target available`, + }; + } + + const [highestTargetProtocol, highestTarget] = bestTargetProtocol[0]; + const ratio = highestTarget > 0 ? currentTarget / highestTarget : 1; + + if (ratio < 1 - this.targetDeviationThreshold) { + const costs = estimateRebalanceCosts(totalAmount, thresholds.maxGasPercent); + + if (costs.totalCostPercent >= thresholds.maxGasPercent || totalAmount === '0') { + return { + shouldRebalance: false, + targetProtocol: currentProtocol, + reasoning: `Rebalance from ${currentProtocol} to ${highestTargetProtocol} would exceed max gas cost`, + }; + } + + logger.info('TargetAllocationStrategy: rebalance recommended', { + from: currentProtocol, + to: highestTargetProtocol, + currentTarget: `${currentTarget}%`, + highestTarget: `${highestTarget}%`, + ratio: ratio.toFixed(2), + gasCost: costs.gasFeePercent.toFixed(4), + slippage: costs.slippagePercent.toFixed(4), + }); + + return { + shouldRebalance: true, + targetProtocol: highestTargetProtocol, + reasoning: `Target allocation for ${currentProtocol} (${currentTarget}%) is significantly below ${highestTargetProtocol} (${highestTarget}%) — rebalancing to preferred protocol`, + deviationTrigger: `Target ratio ${ratio.toFixed(2)} below threshold`, + details: { + currentProtocol, + currentTarget, + highestTargetProtocol, + highestTarget, + ratio, + targets, + totalCostPercent: costs.totalCostPercent, + }, + }; + } + + return { + shouldRebalance: false, + targetProtocol: currentProtocol, + reasoning: `Target allocation for ${currentProtocol} (${currentTarget}%) is within acceptable range of highest target ${highestTargetProtocol} (${highestTarget}%)`, + details: { + currentProtocol, + currentTarget, + highestTargetProtocol, + highestTarget, + ratio, + }, + }; + } +} diff --git a/src/agent/types.ts b/src/agent/types.ts index f1fd4cb..d3fb9bf 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -73,3 +73,34 @@ export interface RebalanceThresholds { minimumImprovement: number; // 0.5% default maxGasPercent: number; // 0.1% default } + +export type StrategyName = 'MAX_YIELD' | 'TARGET_ALLOCATION'; + +export interface StrategyDecision { + shouldRebalance: boolean; + targetProtocol: string; + reasoning: string; + deviationTrigger?: string; + details?: Record; +} + +export interface StrategyParams { + currentProtocol: string; + totalAmount: string; + currentApy: number; + availableProtocols: YieldProtocol[]; + thresholds: RebalanceThresholds; + userStrategyPreferences: UserStrategyPreferences[]; +} + +export interface RebalanceStrategy { + readonly name: StrategyName; + analyze(params: StrategyParams): Promise; +} + +export interface UserStrategyPreferences { + userId: string; + strategyName?: StrategyName | null; + targetAllocations?: Record; + riskTolerance?: number; +} diff --git a/tests/unit/agent/strategies.test.ts b/tests/unit/agent/strategies.test.ts new file mode 100644 index 0000000..ae63edb --- /dev/null +++ b/tests/unit/agent/strategies.test.ts @@ -0,0 +1,239 @@ +import { MaxYieldStrategy, TargetAllocationStrategy } from '../../../src/agent/strategies'; +import { StrategyParams, YieldProtocol } from '../../../src/agent/types'; + +jest.mock('../../../src/utils/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +function makeProtocol(overrides: Partial = {}): YieldProtocol { + return { + name: 'TestProtocol', + apy: 5.0, + assetSymbol: 'USDC', + lastUpdated: new Date(), + isAvailable: true, + ...overrides, + }; +} + +const defaultThresholds = { + minimumImprovement: 0.5, + maxGasPercent: 0.1, +}; + +describe('MaxYieldStrategy', () => { + const strategy = new MaxYieldStrategy(); + + it('returns strategy name as MAX_YIELD', () => { + expect(strategy.name).toBe('MAX_YIELD'); + }); + + it('recommends rebalance when a better protocol exists and net gain exceeds threshold', async () => { + // Use 10000 USDC so gas costs (~$0.50) are negligible (0.005%) + const params: StrategyParams = { + currentProtocol: 'Blend', + totalAmount: '10000000000000000000000', + currentApy: 3.0, + availableProtocols: [ + makeProtocol({ name: 'Luma', apy: 8.0 }), + makeProtocol({ name: 'Blend', apy: 3.0 }), + ], + thresholds: defaultThresholds, + userStrategyPreferences: [], + }; + + const decision = await strategy.analyze(params); + expect(decision.shouldRebalance).toBe(true); + expect(decision.targetProtocol).toBe('Luma'); + expect(decision.reasoning).toContain('Luma'); + expect(decision.deviationTrigger).toContain('APY delta'); + expect(decision.details).toBeDefined(); + }); + + it('does NOT rebalance when current protocol is already the best', async () => { + const params: StrategyParams = { + currentProtocol: 'Blend', + totalAmount: '10000000000000000000', + currentApy: 8.0, + availableProtocols: [ + makeProtocol({ name: 'Blend', apy: 8.0 }), + makeProtocol({ name: 'Luma', apy: 5.0 }), + ], + thresholds: defaultThresholds, + userStrategyPreferences: [], + }; + + const decision = await strategy.analyze(params); + expect(decision.shouldRebalance).toBe(false); + expect(decision.targetProtocol).toBe('Blend'); + expect(decision.reasoning).toContain('Already on the highest-yielding'); + }); + + it('does NOT rebalance when net improvement is below threshold', async () => { + const params: StrategyParams = { + currentProtocol: 'Blend', + totalAmount: '10000000000000000000', + currentApy: 7.8, + availableProtocols: [ + makeProtocol({ name: 'Luma', apy: 8.0 }), + makeProtocol({ name: 'Blend', apy: 7.8 }), + ], + thresholds: defaultThresholds, + userStrategyPreferences: [], + }; + + const decision = await strategy.analyze(params); + expect(decision.shouldRebalance).toBe(false); + expect(decision.reasoning).toContain('below threshold'); + }); + + it('does NOT rebalance when no protocols are available', async () => { + const params: StrategyParams = { + currentProtocol: 'Blend', + totalAmount: '10000000000000000000', + currentApy: 5.0, + availableProtocols: [], + thresholds: defaultThresholds, + userStrategyPreferences: [], + }; + + const decision = await strategy.analyze(params); + expect(decision.shouldRebalance).toBe(false); + expect(decision.reasoning).toContain('No protocols available'); + }); + + it('handles very small amounts without crashing', async () => { + const params: StrategyParams = { + currentProtocol: 'Blend', + totalAmount: '1', + currentApy: 3.0, + availableProtocols: [ + makeProtocol({ name: 'Luma', apy: 8.0 }), + ], + thresholds: defaultThresholds, + userStrategyPreferences: [], + }; + + const decision = await strategy.analyze(params); + expect(decision.shouldRebalance).toBe(false); + expect(decision.reasoning).toBeDefined(); + }); +}); + +describe('TargetAllocationStrategy', () => { + const strategy = new TargetAllocationStrategy(); + + it('returns strategy name as TARGET_ALLOCATION', () => { + expect(strategy.name).toBe('TARGET_ALLOCATION'); + }); + + it('recommends rebalance when protocol has significantly lower target than the preferred protocol', async () => { + const params: StrategyParams = { + currentProtocol: 'Blend', + totalAmount: '10000000000000000000000', + currentApy: 5.0, + availableProtocols: [ + makeProtocol({ name: 'Luma', apy: 6.0 }), + makeProtocol({ name: 'Blend', apy: 5.0 }), + ], + thresholds: defaultThresholds, + userStrategyPreferences: [ + { + userId: 'user-1', + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 30, 'Stellar DEX': 40, Luma: 30 }, + }, + ], + }; + + const decision = await strategy.analyze(params); + expect(decision.shouldRebalance).toBe(true); + expect(decision.targetProtocol).toBe('Stellar DEX'); + expect(decision.reasoning).toContain('significantly below'); + expect(decision.deviationTrigger).toContain('Target ratio'); + expect(decision.details).toBeDefined(); + }); + + it('does NOT rebalance when targets are within acceptable range of each other', async () => { + const params: StrategyParams = { + currentProtocol: 'Blend', + totalAmount: '10000000000000000000', + currentApy: 5.0, + availableProtocols: [ + makeProtocol({ name: 'Luma', apy: 6.0 }), + makeProtocol({ name: 'Blend', apy: 5.0 }), + ], + thresholds: defaultThresholds, + userStrategyPreferences: [ + { + userId: 'user-1', + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 33, 'Stellar DEX': 33, Luma: 34 }, + }, + ], + }; + + const decision = await strategy.analyze(params); + expect(decision.shouldRebalance).toBe(false); + expect(decision.reasoning).toContain('within acceptable range'); + }); + + it('does NOT rebalance when no target allocations configured', async () => { + const params: StrategyParams = { + currentProtocol: 'Blend', + totalAmount: '10000000000000000000', + currentApy: 5.0, + availableProtocols: [ + makeProtocol({ name: 'Luma', apy: 6.0 }), + ], + thresholds: defaultThresholds, + userStrategyPreferences: [ + { userId: 'user-1', strategyName: 'TARGET_ALLOCATION' }, + ], + }; + + const decision = await strategy.analyze(params); + expect(decision.shouldRebalance).toBe(false); + expect(decision.reasoning).toContain('No target allocations configured'); + }); + + it('does NOT rebalance when no preferences match', async () => { + const params: StrategyParams = { + currentProtocol: 'Blend', + totalAmount: '10000000000000000000', + currentApy: 5.0, + availableProtocols: [ + makeProtocol({ name: 'Luma', apy: 6.0 }), + ], + thresholds: defaultThresholds, + userStrategyPreferences: [], + }; + + const decision = await strategy.analyze(params); + expect(decision.shouldRebalance).toBe(false); + expect(decision.reasoning).toContain('No target allocations configured'); + }); + + it('does NOT rebalance when current protocol has no target', async () => { + const params: StrategyParams = { + currentProtocol: 'UnknownProtocol', + totalAmount: '10000000000000000000', + currentApy: 5.0, + availableProtocols: [ + makeProtocol({ name: 'Luma', apy: 6.0 }), + ], + thresholds: defaultThresholds, + userStrategyPreferences: [ + { + userId: 'user-1', + strategyName: 'TARGET_ALLOCATION', + targetAllocations: { Blend: 50, Luma: 50 }, + }, + ], + }; + + const decision = await strategy.analyze(params); + expect(decision.shouldRebalance).toBe(false); + expect(decision.reasoning).toContain('No target allocation set'); + }); +});