Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions client/src/components/dashboard/ApyDispersionPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<string, { color: string; bg: string; icon: typeof CheckCircle }> = {
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<ApyDispersionResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="glass-card p-5">
<div className="flex items-center justify-center py-8">
<RefreshCw size={24} className="animate-spin text-[#6C5DD3]" />
</div>
</div>
);
}

if (error || !dispersion) {
return (
<div className="glass-card p-5 border border-red-500/30">
<div className="flex items-center gap-2 text-red-400">
<AlertTriangle size={16} />
<p className="text-sm">{error || 'No dispersion data'}</p>
</div>
</div>
);
}

const levelConfig = DISPERSION_CONFIG[dispersion.dispersionLevel] ?? DISPERSION_CONFIG.low;
const LevelIcon = levelConfig.icon;

return (
<div className="glass-card p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<BarChart3 size={18} className="text-[#6C5DD3]" />
<h3 className="text-sm font-semibold uppercase tracking-wider text-gray-400">
APY Dispersion
</h3>
</div>
<div className="flex items-center gap-2">
<span
className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider flex items-center gap-1 ${levelConfig.bg} ${levelConfig.color}`}
>
<LevelIcon size={10} />
{dispersion.dispersionLevel}
</span>
<span className="text-[10px] text-gray-500 bg-white/5 px-2 py-0.5 rounded">
{dispersion.providerCount} sources
</span>
</div>
</div>

{dispersion.warning && (
<div className="flex items-start gap-2 mb-3 p-2.5 bg-amber-500/10 border border-amber-500/30 rounded-lg">
<Info size={14} className="text-amber-400 mt-0.5 shrink-0" />
<p className="text-xs text-amber-200">{dispersion.warning}</p>
</div>
)}

<div className="grid grid-cols-4 gap-3 mb-4">
<div className="text-center p-2 bg-white/5 rounded-lg">
<p className="text-[10px] text-gray-500 uppercase">Mean</p>
<p className="text-sm font-bold text-white">{dispersion.meanApy.toFixed(2)}%</p>
</div>
<div className="text-center p-2 bg-white/5 rounded-lg">
<p className="text-[10px] text-gray-500 uppercase">Median</p>
<p className="text-sm font-bold text-white">{dispersion.medianApy.toFixed(2)}%</p>
</div>
<div className="text-center p-2 bg-white/5 rounded-lg">
<p className="text-[10px] text-gray-500 uppercase">Range</p>
<p className="text-sm font-bold text-white">{dispersion.range.toFixed(2)}%</p>
</div>
<div className="text-center p-2 bg-white/5 rounded-lg">
<p className="text-[10px] text-gray-500 uppercase">Std Dev</p>
<p className={`text-sm font-bold ${levelConfig.color}`}>{dispersion.stdDev.toFixed(3)}</p>
</div>
</div>

<div className="space-y-1.5">
{dispersion.sources.map((source) => (
<div
key={source.provider}
className="flex items-center justify-between py-2 px-2.5 rounded-lg bg-white/5"
>
<span className="text-xs text-gray-300">{source.provider}</span>
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-white">{source.apy.toFixed(2)}%</span>
<span className="text-[10px] text-gray-500">{formatTvl(source.tvlUsd)}</span>
<span
className={`text-[10px] font-medium ${
source.deviationFromMean >= 0 ? 'text-green-400' : 'text-red-400'
}`}
>
{source.deviationFromMean >= 0 ? '+' : ''}{source.deviationFromMean.toFixed(2)}
</span>
</div>
</div>
))}
</div>

<div className="mt-3 pt-3 border-t border-white/10 flex items-center justify-between text-xs">
<span className="text-gray-400">Confidence Signal</span>
<span
className={`font-bold uppercase ${
dispersion.confidenceSignal === 'high'
? 'text-green-400'
: dispersion.confidenceSignal === 'reduced'
? 'text-yellow-400'
: 'text-red-400'
}`}
>
{dispersion.confidenceSignal}
</span>
</div>
</div>
);
}
167 changes: 167 additions & 0 deletions client/src/components/dashboard/RiskPreferenceDriftIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, typeof Activity> = {
concentration: TrendingUp,
volatility: Activity,
liquidity: Droplets,
};

export default function RiskPreferenceDriftIndicator({ walletAddress }: { walletAddress: string }) {
const [driftResult, setDriftResult] = useState<DriftResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="glass-card p-5">
<div className="flex items-center justify-center py-8">
<RefreshCw size={24} className="animate-spin text-[#6C5DD3]" />
</div>
</div>
);
}

if (error || !driftResult) {
return (
<div className="glass-card p-5 border border-red-500/30">
<div className="flex items-center gap-2 text-red-400">
<AlertTriangle size={16} />
<p className="text-sm">{error || 'No drift data available'}</p>
</div>
</div>
);
}

return (
<div className="glass-card p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
{driftResult.isDrifting ? (
<AlertTriangle size={18} className="text-amber-400" />
) : (
<ShieldCheck size={18} className="text-green-400" />
)}
<h3 className="text-sm font-semibold uppercase tracking-wider text-gray-400">
Risk Preference Drift
</h3>
</div>
<span
className={`px-2.5 py-1 rounded-lg text-xs font-bold uppercase tracking-wider bg-gradient-to-r ${PREFERENCE_COLORS[driftResult.statedPreference] ?? 'from-gray-500 to-gray-600'} text-white`}
>
{driftResult.statedPreference}
</span>
</div>

<div className={`text-sm font-medium mb-3 ${driftResult.isDrifting ? 'text-amber-300' : 'text-green-300'}`}>
{driftResult.message}
</div>

<div className="space-y-2">
{driftResult.dimensions.map((dim) => {
const Icon = DIMENSION_ICONS[dim.dimension] ?? Activity;
return (
<div
key={dim.dimension}
className={`flex items-center justify-between p-2.5 rounded-lg border ${
dim.isDrifting
? 'bg-amber-500/10 border-amber-500/30'
: 'bg-white/5 border-white/10'
}`}
>
<div className="flex items-center gap-2">
<Icon size={14} className={dim.isDrifting ? 'text-amber-400' : 'text-gray-400'} />
<span className="text-xs capitalize text-gray-300">{dim.dimension}</span>
</div>
<div className="flex items-center gap-3 text-xs">
<span className={dim.isDrifting ? 'text-amber-400' : 'text-gray-400'}>
{dim.actualValue}{dim.dimension === 'liquidity' ? '' : '%'}
</span>
<span className="text-gray-600">/</span>
<span className="text-gray-500">
{dim.thresholdValue}{dim.dimension === 'liquidity' ? '' : '%'}
</span>
{dim.isDrifting && (
<span className="text-amber-400 font-medium">
+{Math.round(Math.abs(dim.deviationPct))}%
</span>
)}
</div>
</div>
);
})}
</div>

{driftResult.overallDriftPct > 0 && (
<div className="mt-3 pt-3 border-t border-white/10">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-400">Overall Drift</span>
<span className={`font-bold ${driftResult.isDrifting ? 'text-amber-400' : 'text-green-400'}`}>
{driftResult.overallDriftPct}%
</span>
</div>
<div className="mt-1.5 h-1.5 bg-white/10 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
driftResult.isDrifting ? 'bg-gradient-to-r from-amber-500 to-red-500' : 'bg-green-500'
}`}
style={{ width: `${Math.min(driftResult.overallDriftPct, 100)}%` }}
/>
</div>
</div>
)}
</div>
);
}
Loading
Loading