diff --git a/client/src/components/dashboard/ApyDispersionPanel.tsx b/client/src/components/dashboard/ApyDispersionPanel.tsx new file mode 100644 index 000000000..b583a0a27 --- /dev/null +++ b/client/src/components/dashboard/ApyDispersionPanel.tsx @@ -0,0 +1,186 @@ +import { useState, useEffect } from 'react'; +import { BarChart3, AlertTriangle, RefreshCw, CheckCircle, MinusCircle, XCircle, Info } from 'lucide-react'; + +interface DispersionSource { + provider: string; + apy: number; + tvlUsd: number; + deviationFromMean: number; +} + +interface ApyDispersionResult { + strategyId: string; + strategyName: string; + providerCount: number; + meanApy: number; + medianApy: number; + minApy: number; + maxApy: number; + range: number; + variance: number; + stdDev: number; + coefficientOfVariation: number; + dispersionLevel: 'low' | 'moderate' | 'high' | 'critical'; + confidenceSignal: 'high' | 'reduced' | 'low' | 'warning'; + sources: DispersionSource[]; + warning: string | null; +} + +const DISPERSION_CONFIG: Record = { + low: { color: 'text-green-400', bg: 'bg-green-500/15', icon: CheckCircle }, + moderate: { color: 'text-yellow-400', bg: 'bg-yellow-500/15', icon: MinusCircle }, + high: { color: 'text-orange-400', bg: 'bg-orange-500/15', icon: AlertTriangle }, + critical: { color: 'text-red-400', bg: 'bg-red-500/15', icon: XCircle }, +}; + +function formatTvl(value: number): string { + if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M`; + if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}K`; + return `$${value.toLocaleString()}`; +} + +export default function ApyDispersionPanel({ strategyId = 'blend-usdc', strategyName = 'Blend USDC' }: { strategyId?: string; strategyName?: string }) { + const [dispersion, setDispersion] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchDispersion = async () => { + setLoading(true); + try { + const res = await fetch('/api/risk/dispersion/compute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + strategyId, + strategyName, + inputs: [ + { provider: 'DeFiLlama', apy: 6.5, tvlUsd: 10_000_000, fetchedAt: new Date().toISOString() }, + { provider: 'YieldWatch', apy: 6.8, tvlUsd: 8_000_000, fetchedAt: new Date().toISOString() }, + { provider: 'StellarExpert', apy: 6.3, tvlUsd: 9_000_000, fetchedAt: new Date().toISOString() }, + ], + }), + }); + const data = await res.json(); + setDispersion(data.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch dispersion data'); + } finally { + setLoading(false); + } + }; + + void fetchDispersion(); + }, [strategyId, strategyName]); + + if (loading) { + return ( +
+
+ +
+
+ ); + } + + if (error || !dispersion) { + return ( +
+
+ +

{error || 'No dispersion data'}

+
+
+ ); + } + + const levelConfig = DISPERSION_CONFIG[dispersion.dispersionLevel] ?? DISPERSION_CONFIG.low; + const LevelIcon = levelConfig.icon; + + return ( +
+
+
+ +

+ APY Dispersion +

+
+
+ + + {dispersion.dispersionLevel} + + + {dispersion.providerCount} sources + +
+
+ + {dispersion.warning && ( +
+ +

{dispersion.warning}

+
+ )} + +
+
+

Mean

+

{dispersion.meanApy.toFixed(2)}%

+
+
+

Median

+

{dispersion.medianApy.toFixed(2)}%

+
+
+

Range

+

{dispersion.range.toFixed(2)}%

+
+
+

Std Dev

+

{dispersion.stdDev.toFixed(3)}

+
+
+ +
+ {dispersion.sources.map((source) => ( +
+ {source.provider} +
+ {source.apy.toFixed(2)}% + {formatTvl(source.tvlUsd)} + = 0 ? 'text-green-400' : 'text-red-400' + }`} + > + {source.deviationFromMean >= 0 ? '+' : ''}{source.deviationFromMean.toFixed(2)} + +
+
+ ))} +
+ +
+ Confidence Signal + + {dispersion.confidenceSignal} + +
+
+ ); +} diff --git a/client/src/components/dashboard/RiskPreferenceDriftIndicator.tsx b/client/src/components/dashboard/RiskPreferenceDriftIndicator.tsx new file mode 100644 index 000000000..ae4d3735b --- /dev/null +++ b/client/src/components/dashboard/RiskPreferenceDriftIndicator.tsx @@ -0,0 +1,167 @@ +import { useState, useEffect } from 'react'; +import { AlertTriangle, ShieldCheck, TrendingUp, Activity, Droplets, RefreshCw } from 'lucide-react'; + +interface DriftDimension { + dimension: string; + actualValue: number; + thresholdValue: number; + deviationPct: number; + isDrifting: boolean; +} + +interface DriftResult { + userId: string; + statedPreference: string; + overallDriftPct: number; + isDrifting: boolean; + dimensions: DriftDimension[]; + message: string; +} + +const PREFERENCE_COLORS: Record = { + conservative: 'from-blue-500/80 to-teal-600/80', + balanced: 'from-amber-500/80 to-orange-600/80', + aggressive: 'from-red-500/80 to-rose-600/80', +}; + +const DIMENSION_ICONS: Record = { + concentration: TrendingUp, + volatility: Activity, + liquidity: Droplets, +}; + +export default function RiskPreferenceDriftIndicator({ walletAddress }: { walletAddress: string }) { + const [driftResult, setDriftResult] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchDrift = async () => { + setLoading(true); + try { + const res = await fetch('/api/risk/drift/detect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: walletAddress, + statedPreference: 'balanced', + positions: [ + { protocol: 'Blend', weightPct: 40, volatilityPct: 6, liquidityUsd: 1_000_000 }, + { protocol: 'Soroswap', weightPct: 35, volatilityPct: 15, liquidityUsd: 300_000 }, + { protocol: 'DeFindex', weightPct: 25, volatilityPct: 8, liquidityUsd: 500_000 }, + ], + }), + }); + const data = await res.json(); + setDriftResult(data.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch drift data'); + } finally { + setLoading(false); + } + }; + + void fetchDrift(); + }, [walletAddress]); + + if (loading) { + return ( +
+
+ +
+
+ ); + } + + if (error || !driftResult) { + return ( +
+
+ +

{error || 'No drift data available'}

+
+
+ ); + } + + return ( +
+
+
+ {driftResult.isDrifting ? ( + + ) : ( + + )} +

+ Risk Preference Drift +

+
+ + {driftResult.statedPreference} + +
+ +
+ {driftResult.message} +
+ +
+ {driftResult.dimensions.map((dim) => { + const Icon = DIMENSION_ICONS[dim.dimension] ?? Activity; + return ( +
+
+ + {dim.dimension} +
+
+ + {dim.actualValue}{dim.dimension === 'liquidity' ? '' : '%'} + + / + + {dim.thresholdValue}{dim.dimension === 'liquidity' ? '' : '%'} + + {dim.isDrifting && ( + + +{Math.round(Math.abs(dim.deviationPct))}% + + )} +
+
+ ); + })} +
+ + {driftResult.overallDriftPct > 0 && ( +
+
+ Overall Drift + + {driftResult.overallDriftPct}% + +
+
+
+
+
+ )} +
+ ); +} diff --git a/server/src/__tests__/apyDispersionService.test.ts b/server/src/__tests__/apyDispersionService.test.ts new file mode 100644 index 000000000..bdd4fe6d0 --- /dev/null +++ b/server/src/__tests__/apyDispersionService.test.ts @@ -0,0 +1,147 @@ +import { ApyDispersionService, type ProviderApyInput } from '../services/apyDispersionService'; + +describe('ApyDispersionService', () => { + let service: ApyDispersionService; + + beforeEach(() => { + service = new ApyDispersionService(); + }); + + describe('low-dispersion scenarios', () => { + it('should return low dispersion when providers closely agree', () => { + const inputs: ProviderApyInput[] = [ + { provider: 'DeFiLlama', apy: 6.5, tvlUsd: 10_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + { provider: 'YieldWatch', apy: 6.4, tvlUsd: 8_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + { provider: 'StellarExpert', apy: 6.6, tvlUsd: 9_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + ]; + + const result = service.computeDispersion('blend-usdc', 'Blend USDC', inputs); + + expect(result.dispersionLevel).toBe('low'); + expect(result.confidenceSignal).toBe('high'); + expect(result.providerCount).toBe(3); + expect(result.warning).toBeNull(); + expect(result.meanApy).toBeCloseTo(6.5, 1); + }); + + it('should handle single provider input gracefully', () => { + const inputs: ProviderApyInput[] = [ + { provider: 'DeFiLlama', apy: 8.0, tvlUsd: 5_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + ]; + + const result = service.computeDispersion('soroswap-xlm', 'Soroswap XLM', inputs); + + expect(result.dispersionLevel).toBe('low'); + expect(result.confidenceSignal).toBe('warning'); + expect(result.providerCount).toBe(1); + expect(result.warning).toBeNull(); + }); + }); + + describe('high-dispersion scenarios', () => { + it('should return high dispersion when providers strongly disagree', () => { + const inputs: ProviderApyInput[] = [ + { provider: 'DeFiLlama', apy: 5.0, tvlUsd: 10_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + { provider: 'YieldWatch', apy: 7.5, tvlUsd: 8_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + { provider: 'StellarExpert', apy: 4.0, tvlUsd: 9_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + ]; + + const result = service.computeDispersion('blend-usdc', 'Blend USDC', inputs); + + expect(result.dispersionLevel).toBe('high'); + expect(result.confidenceSignal).toBe('low'); + expect(result.coefficientOfVariation).toBeGreaterThan(0.15); + expect(result.warning).toContain('High APY dispersion'); + }); + + it('should detect critical dispersion', () => { + const inputs: ProviderApyInput[] = [ + { provider: 'ProviderA', apy: 2.0, tvlUsd: 1_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + { provider: 'ProviderB', apy: 20.0, tvlUsd: 500_000, fetchedAt: '2026-05-26T00:00:00Z' }, + ]; + + const result = service.computeDispersion('volatile-pool', 'Volatile Pool', inputs); + + expect(result.dispersionLevel).toBe('critical'); + expect(result.confidenceSignal).toBe('warning'); + expect(result.warning).toContain('Critical APY dispersion'); + }); + }); + + describe('moderate dispersion', () => { + it('should detect moderate dispersion', () => { + const inputs: ProviderApyInput[] = [ + { provider: 'DeFiLlama', apy: 8.0, tvlUsd: 10_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + { provider: 'YieldWatch', apy: 9.5, tvlUsd: 8_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + ]; + + const result = service.computeDispersion('moderate-pool', 'Moderate Pool', inputs); + + expect(result.dispersionLevel).toBe('moderate'); + expect(result.warning).toContain('Moderate APY dispersion'); + }); + }); + + describe('edge cases', () => { + it('should handle empty inputs', () => { + const result = service.computeDispersion('empty', 'Empty Strategy', []); + + expect(result.providerCount).toBe(0); + expect(result.meanApy).toBe(0); + expect(result.warning).toBe('No provider inputs available for dispersion analysis.'); + }); + + it('should report correct statistics', () => { + const inputs: ProviderApyInput[] = [ + { provider: 'A', apy: 10, tvlUsd: 1_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + { provider: 'B', apy: 12, tvlUsd: 2_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + { provider: 'C', apy: 14, tvlUsd: 3_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + ]; + + const result = service.computeDispersion('stat-test', 'Stat Test', inputs); + + expect(result.minApy).toBe(10); + expect(result.maxApy).toBe(14); + expect(result.range).toBe(4); + expect(result.meanApy).toBe(12); + expect(result.medianApy).toBe(12); + }); + + it('should compute per-source deviation', () => { + const inputs: ProviderApyInput[] = [ + { provider: 'A', apy: 8, tvlUsd: 1_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + { provider: 'B', apy: 10, tvlUsd: 2_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + ]; + + const result = service.computeDispersion('dev-test', 'Dev Test', inputs); + + expect(result.sources).toHaveLength(2); + expect(result.sources[0].deviationFromMean).toBe(-1); + expect(result.sources[1].deviationFromMean).toBe(1); + }); + }); + + describe('config updates', () => { + it('should allow custom thresholds', () => { + const customService = new ApyDispersionService({ + lowCvThreshold: 0.01, + moderateCvThreshold: 0.05, + highCvThreshold: 0.10, + }); + + const inputs: ProviderApyInput[] = [ + { provider: 'A', apy: 6.5, tvlUsd: 1_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + { provider: 'B', apy: 6.8, tvlUsd: 2_000_000, fetchedAt: '2026-05-26T00:00:00Z' }, + ]; + + const result = customService.computeDispersion('custom', 'Custom', inputs); + + expect(result.dispersionLevel).not.toBe('low'); + }); + + it('should update config at runtime', () => { + service.updateConfig({ lowCvThreshold: 0.02 }); + expect(service.getConfig().lowCvThreshold).toBe(0.02); + }); + }); +}); diff --git a/server/src/__tests__/riskPreferenceDriftService.test.ts b/server/src/__tests__/riskPreferenceDriftService.test.ts new file mode 100644 index 000000000..b9cd1b182 --- /dev/null +++ b/server/src/__tests__/riskPreferenceDriftService.test.ts @@ -0,0 +1,243 @@ +import { RiskPreferenceDriftService, type UserRiskProfile, type PortfolioBehavior } from '../services/riskPreferenceDriftService'; + +describe('RiskPreferenceDriftService', () => { + let service: RiskPreferenceDriftService; + + const conservativeProfile: UserRiskProfile = { + userId: 'user-1', + statedPreference: 'conservative', + maxConcentrationPct: 25, + maxVolatilityPct: 8, + minLiquidityUsd: 500_000, + }; + + const balancedProfile: UserRiskProfile = { + userId: 'user-2', + statedPreference: 'balanced', + maxConcentrationPct: 40, + maxVolatilityPct: 18, + minLiquidityUsd: 200_000, + }; + + const aggressiveProfile: UserRiskProfile = { + userId: 'user-3', + statedPreference: 'aggressive', + maxConcentrationPct: 60, + maxVolatilityPct: 35, + minLiquidityUsd: 50_000, + }; + + beforeEach(() => { + service = new RiskPreferenceDriftService(); + }); + + describe('conservative drift cases', () => { + it('should not detect drift when conservative portfolio is within bounds', () => { + const behavior: PortfolioBehavior = { + currentConcentrationPct: 20, + currentVolatilityPct: 5, + currentLiquidityUsd: 1_000_000, + positions: [ + { protocol: 'Blend', weightPct: 20, volatilityPct: 5, liquidityUsd: 1_000_000 }, + { protocol: 'DeFindex', weightPct: 20, volatilityPct: 3, liquidityUsd: 2_000_000 }, + { protocol: 'Soroswap', weightPct: 20, volatilityPct: 6, liquidityUsd: 800_000 }, + { protocol: 'Aquarius', weightPct: 20, volatilityPct: 4, liquidityUsd: 1_500_000 }, + { protocol: 'Blend-2', weightPct: 20, volatilityPct: 5, liquidityUsd: 1_200_000 }, + ], + }; + + const result = service.detectDrift(conservativeProfile, behavior); + + expect(result.isDrifting).toBe(false); + expect(result.overallDriftPct).toBe(0); + expect(result.message).toContain('aligns'); + }); + + it('should detect concentration drift for conservative', () => { + const behavior: PortfolioBehavior = { + currentConcentrationPct: 50, + currentVolatilityPct: 5, + currentLiquidityUsd: 1_000_000, + positions: [ + { protocol: 'Soroswap', weightPct: 50, volatilityPct: 12, liquidityUsd: 500_000 }, + { protocol: 'Blend', weightPct: 50, volatilityPct: 5, liquidityUsd: 1_000_000 }, + ], + }; + + const result = service.detectDrift(conservativeProfile, behavior); + + expect(result.isDrifting).toBe(true); + expect(result.dimensions.some(d => d.dimension === 'concentration' && d.isDrifting)).toBe(true); + }); + + it('should detect volatility drift for conservative', () => { + const behavior: PortfolioBehavior = { + currentConcentrationPct: 20, + currentVolatilityPct: 15, + currentLiquidityUsd: 1_000_000, + positions: [ + { protocol: 'Soroswap', weightPct: 50, volatilityPct: 20, liquidityUsd: 500_000 }, + { protocol: 'Blend', weightPct: 50, volatilityPct: 10, liquidityUsd: 1_000_000 }, + ], + }; + + const result = service.detectDrift(conservativeProfile, behavior); + + expect(result.isDrifting).toBe(true); + expect(result.dimensions.some(d => d.dimension === 'volatility' && d.isDrifting)).toBe(true); + }); + + it('should detect liquidity drift for conservative', () => { + const behavior: PortfolioBehavior = { + currentConcentrationPct: 20, + currentVolatilityPct: 5, + currentLiquidityUsd: 50_000, + positions: [ + { protocol: 'Soroswap', weightPct: 100, volatilityPct: 5, liquidityUsd: 50_000 }, + ], + }; + + const result = service.detectDrift(conservativeProfile, behavior); + + expect(result.isDrifting).toBe(true); + expect(result.dimensions.some(d => d.dimension === 'liquidity' && d.isDrifting)).toBe(true); + }); + }); + + describe('balanced drift cases', () => { + it('should not detect drift when balanced portfolio is within bounds', () => { + const behavior: PortfolioBehavior = { + currentConcentrationPct: 35, + currentVolatilityPct: 12, + currentLiquidityUsd: 500_000, + positions: [ + { protocol: 'Blend', weightPct: 35, volatilityPct: 8, liquidityUsd: 500_000 }, + { protocol: 'Soroswap', weightPct: 35, volatilityPct: 15, liquidityUsd: 300_000 }, + { protocol: 'DeFindex', weightPct: 30, volatilityPct: 10, liquidityUsd: 400_000 }, + ], + }; + + const result = service.detectDrift(balancedProfile, behavior); + + expect(result.isDrifting).toBe(false); + expect(result.overallDriftPct).toBe(0); + }); + + it('should detect concentration drift for balanced', () => { + const behavior: PortfolioBehavior = { + currentConcentrationPct: 70, + currentVolatilityPct: 12, + currentLiquidityUsd: 500_000, + positions: [ + { protocol: 'Soroswap', weightPct: 70, volatilityPct: 15, liquidityUsd: 300_000 }, + { protocol: 'Blend', weightPct: 30, volatilityPct: 5, liquidityUsd: 1_000_000 }, + ], + }; + + const result = service.detectDrift(balancedProfile, behavior); + + expect(result.isDrifting).toBe(true); + expect(result.dimensions.some(d => d.dimension === 'concentration' && d.isDrifting)).toBe(true); + }); + }); + + describe('aggressive drift cases', () => { + it('should not detect drift when aggressive portfolio is within bounds', () => { + const behavior: PortfolioBehavior = { + currentConcentrationPct: 55, + currentVolatilityPct: 30, + currentLiquidityUsd: 100_000, + positions: [ + { protocol: 'Soroswap', weightPct: 55, volatilityPct: 32, liquidityUsd: 100_000 }, + { protocol: 'Aquarius', weightPct: 45, volatilityPct: 28, liquidityUsd: 80_000 }, + ], + }; + + const result = service.detectDrift(aggressiveProfile, behavior); + + expect(result.isDrifting).toBe(false); + expect(result.overallDriftPct).toBe(0); + }); + + it('should detect volatility drift for aggressive', () => { + const behavior: PortfolioBehavior = { + currentConcentrationPct: 50, + currentVolatilityPct: 45, + currentLiquidityUsd: 100_000, + positions: [ + { protocol: 'Soroswap', weightPct: 50, volatilityPct: 50, liquidityUsd: 100_000 }, + { protocol: 'Aquarius', weightPct: 50, volatilityPct: 40, liquidityUsd: 80_000 }, + ], + }; + + const result = service.detectDrift(aggressiveProfile, behavior); + + expect(result.isDrifting).toBe(true); + expect(result.dimensions.some(d => d.dimension === 'volatility' && d.isDrifting)).toBe(true); + }); + + it('should provide correct message for drifting portfolios', () => { + const behavior: PortfolioBehavior = { + currentConcentrationPct: 80, + currentVolatilityPct: 40, + currentLiquidityUsd: 10_000, + positions: [ + { protocol: 'Soroswap', weightPct: 80, volatilityPct: 40, liquidityUsd: 10_000 }, + ], + }; + + const result = service.detectDrift(aggressiveProfile, behavior); + + expect(result.isDrifting).toBe(true); + expect(result.message).toContain('Detected drift'); + expect(result.message).toContain('aggressive'); + expect(result.dimensions.filter(d => d.isDrifting).length).toBeGreaterThan(0); + }); + }); + + describe('edge cases', () => { + it('should handle empty positions', () => { + const behavior: PortfolioBehavior = { + currentConcentrationPct: 0, + currentVolatilityPct: 0, + currentLiquidityUsd: 0, + positions: [], + }; + + const result = service.detectDrift(conservativeProfile, behavior); + + expect(result.isDrifting).toBe(false); + expect(result.overallDriftPct).toBe(0); + }); + + it('should return correct thresholds for each preference', () => { + const conservativeThresholds = service.getThresholdsForPreference('conservative'); + expect(conservativeThresholds.maxConcentrationPct).toBe(25); + expect(conservativeThresholds.maxVolatilityPct).toBe(8); + expect(conservativeThresholds.minLiquidityUsd).toBe(500_000); + + const balancedThresholds = service.getThresholdsForPreference('balanced'); + expect(balancedThresholds.maxConcentrationPct).toBe(40); + + const aggressiveThresholds = service.getThresholdsForPreference('aggressive'); + expect(aggressiveThresholds.maxConcentrationPct).toBe(60); + }); + + it('should compute overallDriftPct correctly', () => { + const behavior: PortfolioBehavior = { + currentConcentrationPct: 50, + currentVolatilityPct: 25, + currentLiquidityUsd: 10_000, + positions: [ + { protocol: 'Soroswap', weightPct: 50, volatilityPct: 25, liquidityUsd: 10_000 }, + { protocol: 'Blend', weightPct: 50, volatilityPct: 5, liquidityUsd: 1_000_000 }, + ], + }; + + const result = service.detectDrift(conservativeProfile, behavior); + + expect(result.overallDriftPct).toBeGreaterThan(0); + expect(result.overallDriftPct).toBeLessThanOrEqual(100); + }); + }); +}); diff --git a/server/src/__tests__/strategyCandidateQueueService.test.ts b/server/src/__tests__/strategyCandidateQueueService.test.ts new file mode 100644 index 000000000..ef103bb2f --- /dev/null +++ b/server/src/__tests__/strategyCandidateQueueService.test.ts @@ -0,0 +1,233 @@ +import { StrategyCandidateQueueService, type StrategyCandidate } from '../services/strategyCandidateQueueService'; + +describe('StrategyCandidateQueueService', () => { + let service: StrategyCandidateQueueService; + + const highQualityCandidate: StrategyCandidate = { + id: 'candidate-1', + name: 'Blend USDC High Yield', + strategyType: 'lending', + expectedUpsidePct: 25, + confidenceScore: 90, + urgencyScore: 80, + resourceCost: 100, + createdAt: new Date().toISOString(), + }; + + const mediumQualityCandidate: StrategyCandidate = { + id: 'candidate-2', + name: 'Soroswap LP', + strategyType: 'dex-lp', + expectedUpsidePct: 12, + confidenceScore: 70, + urgencyScore: 50, + resourceCost: 80, + createdAt: new Date().toISOString(), + }; + + const lowQualityCandidate: StrategyCandidate = { + id: 'candidate-3', + name: 'Speculative Farm', + strategyType: 'farm', + expectedUpsidePct: 2, + confidenceScore: 10, + urgencyScore: 5, + resourceCost: 200, + createdAt: new Date().toISOString(), + }; + + beforeEach(() => { + service = new StrategyCandidateQueueService(); + }); + + describe('queue ordering', () => { + it('should rank higher priority candidates first', () => { + service.enqueue(mediumQualityCandidate); + service.enqueue(highQualityCandidate); + service.enqueue(lowQualityCandidate); + + const queue = service.getPrioritizedQueue(); + + expect(queue.length).toBe(3); + expect(queue[0].id).toBe('candidate-1'); + expect(queue[1].id).toBe('candidate-2'); + expect(queue[2].id).toBe('candidate-3'); + }); + + it('should compute priority scores correctly', () => { + service.enqueue(highQualityCandidate); + const queue = service.getPrioritizedQueue(); + + expect(queue[0].priorityScore).toBeGreaterThan(0); + expect(queue[0].rank).toBe(1); + }); + + it('should provide meaningful justifications', () => { + service.enqueue(highQualityCandidate); + const queue = service.getPrioritizedQueue(); + + expect(queue[0].justification).toContain('high upside'); + expect(queue[0].justification).toContain('strong confidence'); + expect(queue[0].justification).toContain('urgent'); + }); + }); + + describe('starvation prevention', () => { + it('should track starvation count', () => { + const starvationConfig = { + starvationPreventionEnabled: true, + starvationTimeToLiveMs: 0, + maxQueueSize: 100, + }; + + service = new StrategyCandidateQueueService(starvationConfig); + service.enqueue(mediumQualityCandidate); + service.enqueue(highQualityCandidate); + + const queue = service.getPrioritizedQueue(); + const state = service.getQueueState(); + + expect(state.starvationCount).toBeGreaterThanOrEqual(0); + expect(queue.length).toBe(2); + }); + + it('should prevent low-quality candidates from blocking queue', () => { + for (let i = 0; i < 10; i++) { + service.enqueue({ + id: `low-quality-${i}`, + name: `Low Quality ${i}`, + strategyType: 'farm', + expectedUpsidePct: 1, + confidenceScore: 5, + urgencyScore: 3, + resourceCost: 100, + createdAt: new Date().toISOString(), + }); + } + + const added = service.enqueue(highQualityCandidate); + expect(added).toBe(true); + + const queue = service.getPrioritizedQueue(); + expect(queue[0].id).toBe(highQualityCandidate.id); + }); + + it('should reject low-quality candidates when queue is full', () => { + const smallQueue = new StrategyCandidateQueueService({ maxQueueSize: 3 }); + + smallQueue.enqueue(highQualityCandidate); + smallQueue.enqueue(mediumQualityCandidate); + smallQueue.enqueue({ + id: 'candidate-4', + name: 'Another', + strategyType: 'lending', + expectedUpsidePct: 15, + confidenceScore: 80, + urgencyScore: 60, + resourceCost: 90, + createdAt: new Date().toISOString(), + }); + + const rejected = smallQueue.enqueue(lowQualityCandidate); + expect(rejected).toBe(false); + }); + }); + + describe('processing pipeline', () => { + it('should return next candidate for processing', () => { + service.enqueue(highQualityCandidate); + const next = service.nextForProcessing(); + + expect(next).not.toBeNull(); + expect(next!.id).toBe(highQualityCandidate.id); + }); + + it('should approve high quality candidates', () => { + service.enqueue(highQualityCandidate); + const result = service.approve(highQualityCandidate.id); + + expect(result.approved).toBe(true); + expect(result.reason).toContain('approved'); + }); + + it('should reject low quality candidates', () => { + service.enqueue(lowQualityCandidate); + const result = service.approve(lowQualityCandidate.id); + + expect(result.approved).toBe(false); + expect(result.reason).toContain('below minimum'); + }); + + it('should allow explicit rejection with reason', () => { + service.enqueue(highQualityCandidate); + const result = service.reject(highQualityCandidate.id, 'Manual override by operator'); + + expect(result.approved).toBe(false); + expect(result.reason).toBe('Manual override by operator'); + }); + + it('should return null when queue is empty', () => { + const next = service.nextForProcessing(); + expect(next).toBeNull(); + }); + + it('should return failure for non-existent candidate approval', () => { + const result = service.approve('non-existent'); + expect(result.approved).toBe(false); + expect(result.reason).toContain('not found'); + }); + }); + + describe('queue state tracking', () => { + it('should track total enqueued and processed', () => { + service.enqueue(highQualityCandidate); + service.enqueue(mediumQualityCandidate); + + service.approve(highQualityCandidate.id); + + const state = service.getQueueState(); + expect(state.totalEnqueued).toBe(2); + expect(state.totalProcessed).toBe(1); + expect(state.currentQueueSize).toBe(1); + }); + + it('should prevent duplicate enqueue', () => { + const first = service.enqueue(highQualityCandidate); + const second = service.enqueue(highQualityCandidate); + + expect(first).toBe(true); + expect(second).toBe(false); + }); + }); + + describe('candidate qualification', () => { + it('should qualify high quality candidates', () => { + expect(service.isCandidateQualified(highQualityCandidate)).toBe(true); + }); + + it('should disqualify low quality candidates', () => { + expect(service.isCandidateQualified(lowQualityCandidate)).toBe(false); + }); + }); + + describe('config updates', () => { + it('should allow config updates', () => { + service.updateConfig({ minPriorityToProcess: 20, upsideWeight: 0.5 }); + const config = service.getConfig(); + expect(config.minPriorityToProcess).toBe(20); + expect(config.upsideWeight).toBe(0.5); + }); + }); + + describe('clear', () => { + it('should clear all candidates', () => { + service.enqueue(highQualityCandidate); + service.enqueue(mediumQualityCandidate); + + service.clear(); + + expect(service.getQueueState().currentQueueSize).toBe(0); + expect(service.getPrioritizedQueue().length).toBe(0); + }); + }); +}); diff --git a/server/src/__tests__/stressMatrixService.test.ts b/server/src/__tests__/stressMatrixService.test.ts new file mode 100644 index 000000000..9efa12435 --- /dev/null +++ b/server/src/__tests__/stressMatrixService.test.ts @@ -0,0 +1,133 @@ +import { StressMatrixService, type StressScenario } from '../services/stressMatrixService'; + +describe('StressMatrixService', () => { + describe('scenario generation', () => { + it('should generate default scenarios', () => { + const service = new StressMatrixService(); + const scenarios = service.getScenarios(); + + expect(scenarios.length).toBeGreaterThan(0); + expect(scenarios.some(s => s.id === 'multi-factor-meltdown')).toBe(true); + expect(scenarios.some(s => s.id === 'liquidity-crisis')).toBe(true); + expect(scenarios.some(s => s.id === 'oracle-manipulation')).toBe(true); + expect(scenarios.some(s => s.id === 'trust-collapse')).toBe(true); + expect(scenarios.some(s => s.id === 'confidence-crisis')).toBe(true); + expect(scenarios.some(s => s.id === 'mild-downturn')).toBe(true); + }); + + it('should add custom scenarios', () => { + const service = new StressMatrixService(); + const customScenario: StressScenario = { + id: 'custom-flash-crash', + name: 'Custom Flash Crash', + description: 'Rapid market crash scenario', + factors: { liquidity: 80, trust: 60, confidence: 70, oracle: 40 }, + }; + + service.addScenario(customScenario); + const scenarios = service.getScenarios(); + expect(scenarios.some(s => s.id === 'custom-flash-crash')).toBe(true); + }); + + it('should remove scenarios', () => { + const service = new StressMatrixService(); + const removed = service.removeScenario('mild-downturn'); + expect(removed).toBe(true); + expect(service.getScenarios().some(s => s.id === 'mild-downturn')).toBe(false); + }); + + it('should return false when removing non-existent scenario', () => { + const service = new StressMatrixService(); + const removed = service.removeScenario('non-existent'); + expect(removed).toBe(false); + }); + }); + + describe('guardrail activation correctness', () => { + it('should have no activations under mild downturn', () => { + const service = new StressMatrixService(); + const result = service.runMatrix(); + const mild = result.scenarios.find(s => s.scenarioId === 'mild-downturn'); + + expect(mild).toBeDefined(); + expect(mild!.guardrailActivations.length).toBe(0); + expect(mild!.summary.status).toBe('all-passed'); + }); + + it('should activate guardrails under multi-factor meltdown', () => { + const service = new StressMatrixService(); + const result = service.runMatrix(); + const meltdown = result.scenarios.find(s => s.scenarioId === 'multi-factor-meltdown'); + + expect(meltdown).toBeDefined(); + expect(meltdown!.guardrailActivations.length).toBeGreaterThan(0); + expect(meltdown!.summary.status).toBe('some-blocked'); + }); + + it('should detect oracle manipulation activates oracle-adjacent guardrails', () => { + const service = new StressMatrixService(); + const result = service.runMatrix(); + const oracleScenario = result.scenarios.find(s => s.scenarioId === 'oracle-manipulation'); + + expect(oracleScenario).toBeDefined(); + expect(oracleScenario!.guardrailActivations.length).toBeGreaterThan(0); + }); + + it('should detect trust collapse activates pause condition', () => { + const service = new StressMatrixService(); + const result = service.runMatrix(); + const trustScenario = result.scenarios.find(s => s.scenarioId === 'trust-collapse'); + + expect(trustScenario).toBeDefined(); + const pauseActivations = trustScenario!.guardrailActivations.filter( + a => a.ruleType === 'pause-condition', + ); + expect(pauseActivations.length).toBeGreaterThan(0); + }); + + it('should correctly compute pass rate', () => { + const service = new StressMatrixService(); + const result = service.runMatrix(); + + for (const scenario of result.scenarios) { + const { totalGuardrails, blockedCount } = scenario.summary; + const expectedPassRate = totalGuardrails > 0 + ? ((totalGuardrails - blockedCount) / totalGuardrails) * 100 + : 0; + expect(scenario.summary.passRate).toBeCloseTo(expectedPassRate, 0); + } + }); + }); + + describe('matrix summary', () => { + it('should provide correct summary totals', () => { + const service = new StressMatrixService(); + const result = service.runMatrix(); + + expect(result.summary.totalScenarios).toBe(result.scenarios.length); + expect(result.generatedAt).toBeDefined(); + expect(new Date(result.generatedAt).getTime()).toBeLessThanOrEqual(Date.now()); + }); + + it('should identify most triggered guardrails', () => { + const service = new StressMatrixService(); + const result = service.runMatrix(); + + expect(result.summary.mostTriggeredGuardrails.length).toBeGreaterThan(0); + for (const guardrail of result.summary.mostTriggeredGuardrails) { + expect(guardrail.triggerCount).toBeGreaterThan(0); + expect(guardrail.ruleId).toBeDefined(); + } + }); + }); + + describe('config updates', () => { + it('should update config at runtime', () => { + const service = new StressMatrixService(); + service.updateConfig({ trustThreshold: 50, liquidityStressMultiplier: 0.5 }); + const config = service.getConfig(); + expect(config.trustThreshold).toBe(50); + expect(config.liquidityStressMultiplier).toBe(0.5); + }); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 174cbb751..12f6fdbc0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -37,6 +37,8 @@ import treasuryRouter from "./routes/treasury"; import governanceRouter from "./routes/governance"; import presetsRouter from "./routes/presets"; import analyticsRouter from "./routes/analytics"; +import riskRouter from "./routes/risk"; +import queueRouter from "./routes/queue"; import { createAuthChallenge, verifyAuthChallenge } from "./utils/stellarAuth"; import { getRecommendationTimeline, @@ -120,6 +122,8 @@ export function createApp() { app.use("/api/governance", governanceRouter); app.use("/api/presets", presetsRouter); app.use("/api/analytics", analyticsRouter); + app.use("/api/risk", riskRouter); + app.use("/api/queue", queueRouter); // Legacy JSON metrics (internal tooling) app.get("/api/metrics", getMetrics); diff --git a/server/src/routes/queue.ts b/server/src/routes/queue.ts new file mode 100644 index 000000000..7f7b67afc --- /dev/null +++ b/server/src/routes/queue.ts @@ -0,0 +1,164 @@ +import { Router, Request, Response } from "express"; +import { strategyCandidateQueueService, type StrategyCandidate } from "../services/strategyCandidateQueueService"; + +const router = Router(); + +/** + * POST /api/queue/candidate/enqueue + * Enqueue a strategy candidate for prioritization. + */ +router.post("/candidate/enqueue", (req: Request, res: Response) => { + try { + const candidate = req.body as StrategyCandidate; + + if (!candidate.id || !candidate.name || !candidate.strategyType) { + res.status(400).json({ error: "Candidate must have id, name, and strategyType" }); + return; + } + + const qualified = strategyCandidateQueueService.isCandidateQualified(candidate); + const added = strategyCandidateQueueService.enqueue(candidate); + + res.json({ + success: true, + data: { + added, + qualified, + candidateId: candidate.id, + queueSize: strategyCandidateQueueService.getQueueState().currentQueueSize, + }, + }); + } catch (error) { + res.status(500).json({ + error: "Failed to enqueue candidate", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * GET /api/queue/candidate/next + * Get the next candidate for processing. + */ +router.get("/candidate/next", (_req: Request, res: Response) => { + try { + const next = strategyCandidateQueueService.nextForProcessing(); + res.json({ success: true, data: next }); + } catch (error) { + res.status(500).json({ + error: "Failed to get next candidate", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * GET /api/queue/candidate/prioritized + * Get the full prioritized queue. + */ +router.get("/candidate/prioritized", (_req: Request, res: Response) => { + try { + const queue = strategyCandidateQueueService.getPrioritizedQueue(); + const state = strategyCandidateQueueService.getQueueState(); + res.json({ success: true, data: { queue, state } }); + } catch (error) { + res.status(500).json({ + error: "Failed to get prioritized queue", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * POST /api/queue/candidate/:candidateId/approve + * Approve a candidate for processing. + */ +router.post("/candidate/:candidateId/approve", (req: Request, res: Response) => { + try { + const { candidateId } = req.params; + const result = strategyCandidateQueueService.approve(candidateId); + res.json({ success: true, data: result }); + } catch (error) { + res.status(500).json({ + error: "Failed to approve candidate", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * POST /api/queue/candidate/:candidateId/reject + * Reject a candidate with a reason. + */ +router.post("/candidate/:candidateId/reject", (req: Request, res: Response) => { + try { + const { candidateId } = req.params; + const { reason } = req.body as { reason?: string }; + + if (!reason) { + res.status(400).json({ error: "reason is required" }); + return; + } + + const result = strategyCandidateQueueService.reject(candidateId, reason); + res.json({ success: true, data: result }); + } catch (error) { + res.status(500).json({ + error: "Failed to reject candidate", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * GET /api/queue/state + * Get current queue state. + */ +router.get("/state", (_req: Request, res: Response) => { + res.json({ + success: true, + data: strategyCandidateQueueService.getQueueState(), + }); +}); + +/** + * GET /api/queue/config + * Get queue configuration. + */ +router.get("/config", (_req: Request, res: Response) => { + res.json({ + success: true, + data: strategyCandidateQueueService.getConfig(), + }); +}); + +/** + * POST /api/queue/config + * Update queue configuration. + */ +router.post("/config", (req: Request, res: Response) => { + try { + const config = req.body; + strategyCandidateQueueService.updateConfig(config); + res.json({ + success: true, + data: strategyCandidateQueueService.getConfig(), + }); + } catch (error) { + res.status(500).json({ + error: "Failed to update queue config", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * POST /api/queue/clear + * Clear all candidates from the queue. + */ +router.post("/clear", (_req: Request, res: Response) => { + strategyCandidateQueueService.clear(); + res.json({ success: true, message: "Queue cleared" }); +}); + +export default router; diff --git a/server/src/routes/risk.ts b/server/src/routes/risk.ts new file mode 100644 index 000000000..af35a7709 --- /dev/null +++ b/server/src/routes/risk.ts @@ -0,0 +1,200 @@ +import { Router, Request, Response } from "express"; +import { riskPreferenceDriftService, type RiskPreference, type UserRiskProfile, type PortfolioBehavior } from "../services/riskPreferenceDriftService"; +import { stressMatrixService } from "../services/stressMatrixService"; +import { apyDispersionService, type ProviderApyInput } from "../services/apyDispersionService"; + +const router = Router(); + +const VALID_PREFERENCES: RiskPreference[] = ["conservative", "balanced", "aggressive"]; + +/** + * POST /api/risk/drift/detect + * Detect risk preference drift for a user's portfolio. + */ +router.post("/drift/detect", (req: Request, res: Response) => { + try { + const { userId, statedPreference, positions } = req.body as { + userId?: string; + statedPreference?: string; + positions?: PortfolioBehavior["positions"]; + }; + + if (!userId) { + res.status(400).json({ error: "userId is required" }); + return; + } + + if (!statedPreference || !VALID_PREFERENCES.includes(statedPreference as RiskPreference)) { + res.status(400).json({ error: `statedPreference must be one of: ${VALID_PREFERENCES.join(", ")}` }); + return; + } + + if (!Array.isArray(positions) || positions.length === 0) { + res.status(400).json({ error: "positions must be a non-empty array" }); + return; + } + + const profile: UserRiskProfile = { + userId, + statedPreference: statedPreference as RiskPreference, + maxConcentrationPct: 0, + maxVolatilityPct: 0, + minLiquidityUsd: 0, + }; + + const behavior: PortfolioBehavior = { + currentConcentrationPct: Math.max(...positions.map(p => p.weightPct)), + currentVolatilityPct: 0, + currentLiquidityUsd: 0, + positions, + }; + + const result = riskPreferenceDriftService.detectDrift(profile, behavior); + res.json({ success: true, data: result }); + } catch (error) { + res.status(500).json({ + error: "Failed to detect risk preference drift", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * GET /api/risk/drift/thresholds/:preference + * Get drift thresholds for a risk preference. + */ +router.get("/drift/thresholds/:preference", (req: Request, res: Response) => { + const { preference } = req.params; + + if (!VALID_PREFERENCES.includes(preference as RiskPreference)) { + res.status(400).json({ error: `preference must be one of: ${VALID_PREFERENCES.join(", ")}` }); + return; + } + + const thresholds = riskPreferenceDriftService.getThresholdsForPreference(preference as RiskPreference); + res.json({ success: true, data: { preference, thresholds } }); +}); + +/** + * POST /api/risk/dispersion/compute + * Compute APY dispersion for a strategy with provider inputs. + */ +router.post("/dispersion/compute", (req: Request, res: Response) => { + try { + const { strategyId, strategyName, inputs } = req.body as { + strategyId?: string; + strategyName?: string; + inputs?: ProviderApyInput[]; + }; + + if (!strategyId) { + res.status(400).json({ error: "strategyId is required" }); + return; + } + + if (!Array.isArray(inputs)) { + res.status(400).json({ error: "inputs must be an array" }); + return; + } + + const result = apyDispersionService.computeDispersion( + strategyId, + strategyName || strategyId, + inputs, + ); + res.json({ success: true, data: result }); + } catch (error) { + res.status(500).json({ + error: "Failed to compute APY dispersion", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * GET /api/risk/dispersion/config + * Get current dispersion configuration. + */ +router.get("/dispersion/config", (_req: Request, res: Response) => { + res.json({ success: true, data: apyDispersionService.getConfig() }); +}); + +/** + * POST /api/risk/dispersion/config + * Update dispersion configuration. + */ +router.post("/dispersion/config", (req: Request, res: Response) => { + try { + const config = req.body; + apyDispersionService.updateConfig(config); + res.json({ success: true, data: apyDispersionService.getConfig() }); + } catch (error) { + res.status(500).json({ + error: "Failed to update dispersion config", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * GET /api/risk/stress-matrix/run + * Run the full stress matrix. + */ +router.get("/stress-matrix/run", (_req: Request, res: Response) => { + try { + const result = stressMatrixService.runMatrix(); + res.json({ success: true, data: result }); + } catch (error) { + res.status(500).json({ + error: "Failed to run stress matrix", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * GET /api/risk/stress-matrix/scenarios + * Get all stress scenarios. + */ +router.get("/stress-matrix/scenarios", (_req: Request, res: Response) => { + res.json({ success: true, data: stressMatrixService.getScenarios() }); +}); + +/** + * POST /api/risk/stress-matrix/scenarios + * Add a custom stress scenario. + */ +router.post("/stress-matrix/scenarios", (req: Request, res: Response) => { + try { + const scenario = req.body; + if (!scenario.id || !scenario.name || !scenario.factors) { + res.status(400).json({ error: "Scenario must have id, name, and factors" }); + return; + } + stressMatrixService.addScenario(scenario); + res.json({ success: true, message: `Scenario ${scenario.id} added` }); + } catch (error) { + res.status(500).json({ + error: "Failed to add scenario", + message: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * DELETE /api/risk/stress-matrix/scenarios/:scenarioId + * Remove a stress scenario. + */ +router.delete("/stress-matrix/scenarios/:scenarioId", (req: Request, res: Response) => { + const { scenarioId } = req.params; + const removed = stressMatrixService.removeScenario(scenarioId); + + if (!removed) { + res.status(404).json({ error: `Scenario ${scenarioId} not found` }); + return; + } + + res.json({ success: true, message: `Scenario ${scenarioId} removed` }); +}); + +export default router; diff --git a/server/src/services/apyDispersionService.ts b/server/src/services/apyDispersionService.ts new file mode 100644 index 000000000..d7f741918 --- /dev/null +++ b/server/src/services/apyDispersionService.ts @@ -0,0 +1,173 @@ +export interface ProviderApyInput { + provider: string; + apy: number; + tvlUsd: number; + fetchedAt: string; +} + +export interface ApyDispersionResult { + strategyId: string; + strategyName: string; + providerCount: number; + apyValues: number[]; + meanApy: number; + medianApy: number; + minApy: number; + maxApy: number; + range: number; + variance: number; + stdDev: number; + coefficientOfVariation: number; + dispersionLevel: 'low' | 'moderate' | 'high' | 'critical'; + confidenceSignal: 'high' | 'reduced' | 'low' | 'warning'; + sources: Array<{ + provider: string; + apy: number; + tvlUsd: number; + deviationFromMean: number; + }>; + warning: string | null; +} + +export interface DispersionConfig { + lowCvThreshold: number; + moderateCvThreshold: number; + highCvThreshold: number; + criticalCvThreshold: number; +} + +const DEFAULT_CONFIG: DispersionConfig = { + lowCvThreshold: 0.05, + moderateCvThreshold: 0.15, + highCvThreshold: 0.30, + criticalCvThreshold: 0.50, +}; + +function mean(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((s, v) => s + v, 0) / values.length; +} + +function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function variance(values: number[], meanVal: number): number { + if (values.length <= 1) return 0; + return values.reduce((s, v) => s + (v - meanVal) ** 2, 0) / values.length; +} + +function stdDev(varianceVal: number): number { + return Math.sqrt(varianceVal); +} + +function roundTo(value: number, digits = 4): number { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function computeDispersionLevel(cv: number, config: DispersionConfig): ApyDispersionResult['dispersionLevel'] { + if (cv <= config.lowCvThreshold) return 'low'; + if (cv <= config.moderateCvThreshold) return 'moderate'; + if (cv <= config.highCvThreshold) return 'high'; + return 'critical'; +} + +function computeConfidenceSignal(dispersionLevel: ApyDispersionResult['dispersionLevel'], providerCount: number): ApyDispersionResult['confidenceSignal'] { + if (dispersionLevel === 'low' && providerCount >= 3) return 'high'; + if (dispersionLevel === 'moderate' && providerCount >= 2) return 'reduced'; + if (dispersionLevel === 'high') return 'low'; + return 'warning'; +} + +function buildWarning(dispersionLevel: ApyDispersionResult['dispersionLevel'], cv: number): string | null { + if (dispersionLevel === 'low') return null; + if (dispersionLevel === 'moderate') return `Moderate APY dispersion detected (CV=${roundTo(cv, 3)}). Consider cross-referencing sources.`; + if (dispersionLevel === 'high') return `High APY dispersion detected (CV=${roundTo(cv, 3)}). Provider disagreement is significant.`; + return `Critical APY dispersion detected (CV=${roundTo(cv, 3)}). Data may be unreliable - investigate provider inputs.`; +} + +export class ApyDispersionService { + private config: DispersionConfig; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + computeDispersion(strategyId: string, strategyName: string, inputs: ProviderApyInput[]): ApyDispersionResult { + if (inputs.length === 0) { + return { + strategyId, + strategyName, + providerCount: 0, + apyValues: [], + meanApy: 0, + medianApy: 0, + minApy: 0, + maxApy: 0, + range: 0, + variance: 0, + stdDev: 0, + coefficientOfVariation: 0, + dispersionLevel: 'low', + confidenceSignal: 'warning', + sources: [], + warning: 'No provider inputs available for dispersion analysis.', + }; + } + + const apyValues = inputs.map(i => i.apy); + const meanApy = mean(apyValues); + const medianApy = median(apyValues); + const minApy = Math.min(...apyValues); + const maxApy = Math.max(...apyValues); + const range = maxApy - minApy; + const varianceVal = variance(apyValues, meanApy); + const stdDevVal = stdDev(varianceVal); + const coefficientOfVariation = meanApy !== 0 ? stdDevVal / Math.abs(meanApy) : 0; + + const dispersionLevel = computeDispersionLevel(coefficientOfVariation, this.config); + const confidenceSignal = computeConfidenceSignal(dispersionLevel, inputs.length); + + const sources = inputs.map(input => ({ + provider: input.provider, + apy: input.apy, + tvlUsd: input.tvlUsd, + deviationFromMean: roundTo(input.apy - meanApy), + })); + + const warning = buildWarning(dispersionLevel, coefficientOfVariation); + + return { + strategyId, + strategyName, + providerCount: inputs.length, + apyValues, + meanApy: roundTo(meanApy), + medianApy: roundTo(medianApy), + minApy, + maxApy, + range: roundTo(range), + variance: roundTo(varianceVal), + stdDev: roundTo(stdDevVal), + coefficientOfVariation: roundTo(coefficientOfVariation), + dispersionLevel, + confidenceSignal, + sources, + warning, + }; + } + + updateConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + getConfig(): DispersionConfig { + return { ...this.config }; + } +} + +export const apyDispersionService = new ApyDispersionService(); diff --git a/server/src/services/riskPreferenceDriftService.ts b/server/src/services/riskPreferenceDriftService.ts new file mode 100644 index 000000000..de8f8685b --- /dev/null +++ b/server/src/services/riskPreferenceDriftService.ts @@ -0,0 +1,156 @@ +export type RiskPreference = 'conservative' | 'balanced' | 'aggressive'; + +export interface UserRiskProfile { + userId: string; + statedPreference: RiskPreference; + maxConcentrationPct: number; + maxVolatilityPct: number; + minLiquidityUsd: number; +} + +export interface PortfolioBehavior { + currentConcentrationPct: number; + currentVolatilityPct: number; + currentLiquidityUsd: number; + positions: Array<{ + protocol: string; + weightPct: number; + volatilityPct: number; + liquidityUsd: number; + }>; +} + +export interface DriftDimension { + dimension: 'concentration' | 'volatility' | 'liquidity'; + actualValue: number; + thresholdValue: number; + deviationPct: number; + isDrifting: boolean; +} + +export interface DriftResult { + userId: string; + statedPreference: RiskPreference; + overallDriftPct: number; + isDrifting: boolean; + dimensions: DriftDimension[]; + message: string; + detectedAt: string; +} + +const PREFERENCE_THRESHOLDS: Record = { + conservative: { maxConcentrationPct: 25, maxVolatilityPct: 8, minLiquidityUsd: 500_000 }, + balanced: { maxConcentrationPct: 40, maxVolatilityPct: 18, minLiquidityUsd: 200_000 }, + aggressive: { maxConcentrationPct: 60, maxVolatilityPct: 35, minLiquidityUsd: 50_000 }, +}; + + +function computeConcentration(positionWeights: number[]): number { + if (positionWeights.length === 0) return 0; + return Math.max(...positionWeights); +} + +function computeWeightedVolatility(positions: PortfolioBehavior['positions']): number { + if (positions.length === 0) return 0; + const totalWeight = positions.reduce((s, p) => s + p.weightPct, 0); + if (totalWeight === 0) return 0; + return positions.reduce((s, p) => s + p.volatilityPct * (p.weightPct / totalWeight), 0); +} + +function computeWeightedLiquidity(positions: PortfolioBehavior['positions']): number { + if (positions.length === 0) return 0; + const totalWeight = positions.reduce((s, p) => s + p.weightPct, 0); + if (totalWeight === 0) return 0; + return positions.reduce((s, p) => s + p.liquidityUsd * (p.weightPct / totalWeight), 0); +} + +function deviationPct(actual: number, threshold: number): number { + if (threshold === 0) return actual > 0 ? 100 : 0; + return ((actual - threshold) / threshold) * 100; +} + +function evaluateDimension( + dimension: 'concentration' | 'volatility' | 'liquidity', + actualValue: number, + thresholdValue: number, + isUpperBound: boolean, +): DriftDimension { + const dev = deviationPct(actualValue, thresholdValue); + const isDrifting = isUpperBound + ? actualValue > thresholdValue + : actualValue < thresholdValue; + return { + dimension, + actualValue: Math.round(actualValue * 100) / 100, + thresholdValue, + deviationPct: Math.round(dev * 100) / 100, + isDrifting, + }; +} + +export class RiskPreferenceDriftService { + detectDrift( + profile: UserRiskProfile, + behavior: PortfolioBehavior, + ): DriftResult { + const thresholds = PREFERENCE_THRESHOLDS[profile.statedPreference]; + + const actualConcentration = behavior.positions.length > 0 + ? computeConcentration(behavior.positions.map(p => p.weightPct)) + : 0; + const actualVolatility = computeWeightedVolatility(behavior.positions); + const actualLiquidity = computeWeightedLiquidity(behavior.positions); + + if (behavior.positions.length === 0) { + return { + userId: profile.userId, + statedPreference: profile.statedPreference, + overallDriftPct: 0, + isDrifting: false, + dimensions: [], + message: `Portfolio aligns with ${profile.statedPreference} risk preference.`, + detectedAt: new Date().toISOString(), + }; + } + + const dimensions: DriftDimension[] = [ + evaluateDimension('concentration', actualConcentration, thresholds.maxConcentrationPct, true), + evaluateDimension('volatility', actualVolatility, thresholds.maxVolatilityPct, true), + evaluateDimension('liquidity', actualLiquidity, thresholds.minLiquidityUsd, false), + ]; + + const driftingDims = dimensions.filter(d => d.isDrifting); + const overallDriftPct = dimensions.length > 0 + ? Math.round((driftingDims.length / dimensions.length) * 100) + : 0; + const isDrifting = driftingDims.length > 0; + + let message: string; + if (!isDrifting) { + message = `Portfolio aligns with ${profile.statedPreference} risk preference.`; + } else { + const driftNames = driftingDims.map(d => d.dimension).join(', '); + message = `Detected drift in ${driftNames}. Portfolio no longer matches ${profile.statedPreference} profile.`; + } + + return { + userId: profile.userId, + statedPreference: profile.statedPreference, + overallDriftPct, + isDrifting, + dimensions, + message, + detectedAt: new Date().toISOString(), + }; + } + + getThresholdsForPreference(preference: RiskPreference) { + return PREFERENCE_THRESHOLDS[preference]; + } +} + +export const riskPreferenceDriftService = new RiskPreferenceDriftService(); diff --git a/server/src/services/strategyCandidateQueueService.ts b/server/src/services/strategyCandidateQueueService.ts new file mode 100644 index 000000000..c382d1009 --- /dev/null +++ b/server/src/services/strategyCandidateQueueService.ts @@ -0,0 +1,281 @@ +export interface StrategyCandidate { + id: string; + name: string; + strategyType: string; + expectedUpsidePct: number; + confidenceScore: number; + urgencyScore: number; + resourceCost: number; + createdAt: string; +} + +export interface PrioritizedCandidate extends StrategyCandidate { + priorityScore: number; + rank: number; + justification: string; +} + +export interface QueueConfig { + upsideWeight: number; + confidenceWeight: number; + urgencyWeight: number; + maxQueueSize: number; + starvationPreventionEnabled: boolean; + starvationTimeToLiveMs: number; + minPriorityToProcess: number; +} + +export interface QueueState { + totalEnqueued: number; + totalProcessed: number; + currentQueueSize: number; + avgWaitTimeMs: number; + starvationCount: number; +} + +export interface ProcessingResult { + candidateId: string; + candidateName: string; + priorityScore: number; + approved: boolean; + reason: string; +} + +const DEFAULT_CONFIG: QueueConfig = { + upsideWeight: 0.4, + confidenceWeight: 0.35, + urgencyWeight: 0.25, + maxQueueSize: 100, + starvationPreventionEnabled: true, + starvationTimeToLiveMs: 300_000, + minPriorityToProcess: 10, +}; + +const MIN_QUALITY_SCORE = 6; + +export class StrategyCandidateQueueService { + private config: QueueConfig; + private candidates: StrategyCandidate[] = []; + private processedIds: Set = new Set(); + private enqueueTimestamps: Map = new Map(); + private queueState: QueueState = { + totalEnqueued: 0, + totalProcessed: 0, + currentQueueSize: 0, + avgWaitTimeMs: 0, + starvationCount: 0, + }; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + enqueue(candidate: StrategyCandidate): boolean { + if (this.processedIds.has(candidate.id)) { + return false; + } + + if (this.candidates.some(c => c.id === candidate.id)) { + return false; + } + + if (this.candidates.length >= this.config.maxQueueSize) { + const lowest = this.candidates.reduce((min, c) => + this.computePriorityScore(c) < this.computePriorityScore(min) ? c : min, + ); + if (this.computePriorityScore(candidate) <= this.computePriorityScore(lowest)) { + return false; + } + this.candidates = this.candidates.filter(c => c.id !== lowest.id); + } + + this.candidates.push(candidate); + this.enqueueTimestamps.set(candidate.id, Date.now()); + this.queueState.totalEnqueued++; + this.queueState.currentQueueSize = this.candidates.length; + + this.recalculateQueueState(); + + if (this.config.starvationPreventionEnabled) { + this.applyStarvationBoost(); + } + + return true; + } + + getPrioritizedQueue(): PrioritizedCandidate[] { + if (this.config.starvationPreventionEnabled) { + this.applyStarvationBoost(); + } + + const scored = this.candidates.map(candidate => { + const priorityScore = this.computePriorityScore(candidate); + return { candidate, priorityScore }; + }); + + scored.sort((a, b) => b.priorityScore - a.priorityScore); + + return scored.map((item, idx) => ({ + ...item.candidate, + priorityScore: Math.round(item.priorityScore * 100) / 100, + rank: idx + 1, + justification: this.buildJustification(item.candidate, item.priorityScore, idx), + })); + } + + nextForProcessing(): PrioritizedCandidate | null { + const queue = this.getPrioritizedQueue(); + if (queue.length === 0) return null; + + const next = queue[0]; + + if (next.priorityScore < this.config.minPriorityToProcess) { + return null; + } + + return next; + } + + approve(candidateId: string): ProcessingResult { + const candidate = this.candidates.find(c => c.id === candidateId); + if (!candidate) { + return { + candidateId, + candidateName: 'Unknown', + priorityScore: 0, + approved: false, + reason: 'Candidate not found in queue', + }; + } + + const priorityScore = this.computePriorityScore(candidate); + + if (priorityScore < this.config.minPriorityToProcess) { + return { + candidateId, + candidateName: candidate.name, + priorityScore: Math.round(priorityScore * 100) / 100, + approved: false, + reason: `Priority score ${Math.round(priorityScore)} below minimum threshold ${this.config.minPriorityToProcess}`, + }; + } + + this.removeFromQueue(candidateId); + this.processedIds.add(candidateId); + this.queueState.totalProcessed++; + + return { + candidateId, + candidateName: candidate.name, + priorityScore: Math.round(priorityScore * 100) / 100, + approved: true, + reason: 'Candidate approved for processing', + }; + } + + reject(candidateId: string, reason: string): ProcessingResult { + const candidate = this.candidates.find(c => c.id === candidateId); + this.removeFromQueue(candidateId); + this.processedIds.add(candidateId); + + return { + candidateId, + candidateName: candidate?.name ?? 'Unknown', + priorityScore: candidate ? this.computePriorityScore(candidate) : 0, + approved: false, + reason, + }; + } + + getQueueState(): QueueState { + return { ...this.queueState }; + } + + getConfig(): QueueConfig { + return { ...this.config }; + } + + updateConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + private computePriorityScore(candidate: StrategyCandidate): number { + const upsideScore = candidate.expectedUpsidePct * this.config.upsideWeight; + const confidenceScore = candidate.confidenceScore * this.config.confidenceWeight; + const urgencyScore = candidate.urgencyScore * this.config.urgencyWeight; + + return upsideScore + confidenceScore + urgencyScore; + } + + private buildJustification(candidate: StrategyCandidate, score: number, rank: number): string { + const parts: string[] = []; + + if (candidate.expectedUpsidePct >= 20) { + parts.push(`high upside (${candidate.expectedUpsidePct}%)`); + } + if (candidate.confidenceScore >= 80) { + parts.push('strong confidence'); + } + if (candidate.urgencyScore >= 70) { + parts.push('urgent'); + } + if (rank === 1) { + parts.push('top ranked'); + } + + return parts.length > 0 + ? `Rank #${rank}: ${parts.join(', ')}` + : `Rank #${rank}: standard priority`; + } + + private applyStarvationBoost(): void { + const now = Date.now(); + let boostedCount = 0; + + for (const candidate of this.candidates) { + const enqueuedAt = this.enqueueTimestamps.get(candidate.id); + if (!enqueuedAt) continue; + + const waitTime = now - enqueuedAt; + if (waitTime > this.config.starvationTimeToLiveMs) { + candidate.urgencyScore = candidate.urgencyScore * (1 + (waitTime / this.config.starvationTimeToLiveMs) * 0.5); + boostedCount++; + } + } + + this.queueState.starvationCount = boostedCount; + } + + private removeFromQueue(candidateId: string): void { + this.candidates = this.candidates.filter(c => c.id !== candidateId); + this.queueState.currentQueueSize = this.candidates.length; + this.enqueueTimestamps.delete(candidateId); + } + + private recalculateQueueState(): void { + const now = Date.now(); + let totalWait = 0; + let count = 0; + + for (const [, timestamp] of this.enqueueTimestamps) { + totalWait += now - timestamp; + count++; + } + + this.queueState.avgWaitTimeMs = count > 0 ? totalWait / count : 0; + this.queueState.currentQueueSize = this.candidates.length; + } + + clear(): void { + this.candidates = []; + this.enqueueTimestamps.clear(); + this.queueState.currentQueueSize = 0; + } + + isCandidateQualified(candidate: StrategyCandidate): boolean { + const score = this.computePriorityScore(candidate); + return score >= MIN_QUALITY_SCORE; + } +} + +export const strategyCandidateQueueService = new StrategyCandidateQueueService(); diff --git a/server/src/services/stressMatrixService.ts b/server/src/services/stressMatrixService.ts new file mode 100644 index 000000000..f4639de96 --- /dev/null +++ b/server/src/services/stressMatrixService.ts @@ -0,0 +1,239 @@ +import { GuardrailsService, type GuardrailContext, type GuardrailEvaluationResult } from './guardrailsService'; + +export type StressFactor = 'liquidity' | 'trust' | 'confidence' | 'oracle'; + +export interface StressScenario { + id: string; + name: string; + description: string; + factors: Partial>; +} + +export interface GuardrailActivation { + ruleId: string; + ruleName: string; + ruleType: string; + activated: boolean; + detail: string; +} + +export interface ScenarioResult { + scenarioId: string; + scenarioName: string; + scenarioDescription: string; + guardrailActivations: GuardrailActivation[]; + summary: { + totalGuardrails: number; + activatedCount: number; + blockedCount: number; + passRate: number; + status: 'all-passed' | 'some-blocked' | 'all-blocked'; + }; + stressFactors: Partial>; +} + +export interface StressMatrixResult { + generatedAt: string; + scenarioCount: number; + scenarios: ScenarioResult[]; + summary: { + totalScenarios: number; + totalGuardrailEvaluations: number; + totalActivations: number; + mostTriggeredGuardrails: Array<{ ruleId: string; ruleName: string; triggerCount: number }>; + }; +} + +export interface StressMatrixConfig { + trustThreshold: number; + confidenceThreshold: number; + oracleThreshold: number; + liquidityStressMultiplier: number; +} + +const DEFAULT_CONFIG: StressMatrixConfig = { + trustThreshold: 30, + confidenceThreshold: 40, + oracleThreshold: 25, + liquidityStressMultiplier: 0.7, +}; + +const DEFAULT_SCENARIOS: StressScenario[] = [ + { + id: 'liquidity-crisis', + name: 'Liquidity Crisis', + description: 'Sharp liquidity drawdown across multiple pools simultaneously', + factors: { liquidity: 85, trust: 40, confidence: 60, oracle: 30 }, + }, + { + id: 'oracle-manipulation', + name: 'Oracle Manipulation', + description: 'Oracle price feeds deviate significantly from expected values', + factors: { oracle: 90, trust: 70, confidence: 80, liquidity: 20 }, + }, + { + id: 'trust-collapse', + name: 'Trust Collapse', + description: 'Major protocol faces exploit causing cascading trust erosion', + factors: { trust: 95, liquidity: 60, confidence: 90, oracle: 50 }, + }, + { + id: 'confidence-crisis', + name: 'Confidence Crisis', + description: 'Sustained negative yield erodes user and operator confidence', + factors: { confidence: 85, liquidity: 40, trust: 50, oracle: 20 }, + }, + { + id: 'multi-factor-meltdown', + name: 'Multi-Factor Meltdown', + description: 'Simultaneous stress across all factors at extreme levels', + factors: { liquidity: 95, trust: 90, confidence: 95, oracle: 85 }, + }, + { + id: 'mild-downturn', + name: 'Mild Downturn', + description: 'Gentle market decline with moderate factor pressure', + factors: { liquidity: 15, trust: 10, confidence: 15, oracle: 5 }, + }, +]; + +function mapStressToGuardrailContext(factors: Partial>, config: StressMatrixConfig): GuardrailContext { + const liquidity = factors.liquidity ?? 0; + const trust = factors.trust ?? 0; + const confidence = factors.confidence ?? 0; + const oracle = factors.oracle ?? 0; + + const concentration = 30 + (trust * 0.3) + (confidence * 0.2); + const slippage = 1 + (liquidity * 0.04) + (oracle * 0.02); + const rawLiquidity = 1_000_000 - (liquidity * 10_000 * config.liquidityStressMultiplier); + const minLiquidityVal = Math.max(10_000, rawLiquidity); + + return { + strategyId: 'stress-matrix', + concentration: Math.min(100, concentration), + slippage: Math.min(20, slippage), + liquidity: minLiquidityVal, + isMarketPaused: trust > config.trustThreshold || oracle > config.oracleThreshold, + }; +} + +export class StressMatrixService { + private guardrailsService: GuardrailsService; + private config: StressMatrixConfig; + private scenarios: StressScenario[]; + + constructor( + guardrailsService?: GuardrailsService, + config: Partial = {}, + scenarios: StressScenario[] = DEFAULT_SCENARIOS, + ) { + this.guardrailsService = guardrailsService ?? new GuardrailsService(); + this.config = { ...DEFAULT_CONFIG, ...config }; + this.scenarios = scenarios.map(s => ({ ...s, factors: { ...s.factors } })); + } + + runMatrix(): StressMatrixResult { + const scenarioResults: ScenarioResult[] = []; + + for (const scenario of this.scenarios) { + const context = mapStressToGuardrailContext(scenario.factors, this.config); + const evaluation: GuardrailEvaluationResult = this.guardrailsService.evaluateGuardrails(context); + + const activations: GuardrailActivation[] = evaluation.blockedRules.length > 0 + ? evaluation.blockedRules.map(rule => ({ + ruleId: rule.id, + ruleName: rule.name, + ruleType: rule.type, + activated: true, + detail: `Blocked by ${rule.name}: ${rule.description}`, + })) + : []; + + const allRules = this.guardrailsService.getAllRules(); + const enabledRules = allRules.filter(r => r.enabled); + const activatedCount = activations.length; + const blockedCount = activations.length; + const totalGuardrails = enabledRules.length; + const passRate = totalGuardrails > 0 ? ((totalGuardrails - blockedCount) / totalGuardrails) * 100 : 0; + + let status: ScenarioResult['summary']['status']; + if (blockedCount === 0) { + status = 'all-passed'; + } else if (blockedCount >= totalGuardrails) { + status = 'all-blocked'; + } else { + status = 'some-blocked'; + } + + scenarioResults.push({ + scenarioId: scenario.id, + scenarioName: scenario.name, + scenarioDescription: scenario.description, + guardrailActivations: activations, + summary: { + totalGuardrails, + activatedCount, + blockedCount, + passRate: Math.round(passRate * 100) / 100, + status, + }, + stressFactors: scenario.factors, + }); + } + + const totalEvaluations = scenarioResults.reduce((s, r) => s + r.summary.totalGuardrails, 0); + const totalActivations = scenarioResults.reduce((s, r) => s + r.summary.activatedCount, 0); + + const triggerMap = new Map(); + for (const sr of scenarioResults) { + for (const ga of sr.guardrailActivations) { + const existing = triggerMap.get(ga.ruleId); + if (existing) { + existing.triggerCount++; + } else { + triggerMap.set(ga.ruleId, { ruleId: ga.ruleId, ruleName: ga.ruleName, triggerCount: 1 }); + } + } + } + + const mostTriggeredGuardrails = Array.from(triggerMap.values()) + .sort((a, b) => b.triggerCount - a.triggerCount); + + return { + generatedAt: new Date().toISOString(), + scenarioCount: this.scenarios.length, + scenarios: scenarioResults, + summary: { + totalScenarios: this.scenarios.length, + totalGuardrailEvaluations: totalEvaluations, + totalActivations, + mostTriggeredGuardrails, + }, + }; + } + + addScenario(scenario: StressScenario): void { + this.scenarios.push(scenario); + } + + removeScenario(scenarioId: string): boolean { + const idx = this.scenarios.findIndex(s => s.id === scenarioId); + if (idx === -1) return false; + this.scenarios.splice(idx, 1); + return true; + } + + getScenarios(): StressScenario[] { + return [...this.scenarios]; + } + + updateConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + getConfig(): StressMatrixConfig { + return { ...this.config }; + } +} + +export const stressMatrixService = new StressMatrixService();