diff --git a/src/components/dashboard/PortfolioAnalytics.tsx b/src/components/dashboard/PortfolioAnalytics.tsx index 742f819b..d435d3ab 100644 --- a/src/components/dashboard/PortfolioAnalytics.tsx +++ b/src/components/dashboard/PortfolioAnalytics.tsx @@ -689,9 +689,9 @@ export default function PortfolioAnalytics() {
-
@@ -736,6 +736,35 @@ export default function PortfolioAnalytics() { {analytics.concentrationRisks.length}
+ +
+
+ COUNTERPARTY RISK +
+
+ {analytics.counterpartyRisk.score}/100 +
+
+ {analytics.counterpartyRisk.issuerCount} issuer{analytics.counterpartyRisk.issuerCount === 1 ? '' : 's'} +
+
+
+ +
+ {Object.entries(analytics.riskAssessment.components || {}).map(([label, score]: [string, any]) => ( +
+
{label}
+
+
= 70 ? 'var(--red)' : score >= 35 ? 'var(--amber)' : 'var(--green)' }} /> +
+
{score}/100
+
+ ))}
{/* Risk Factors */} @@ -802,6 +831,66 @@ export default function PortfolioAnalytics() {
)} + + {analytics.counterpartyRisk.issuerExposures.length > 0 && ( +
+
+ Counterparty Exposure +
+
+ {analytics.counterpartyRisk.issuerExposures.slice(0, 5).map((issuer: any) => ( +
+
+
{issuer.assets.join(', ')}
+
+ {issuer.issuer.slice(0, 10)}...{issuer.issuer.slice(-8)} +
+
+ {issuer.percentage.toFixed(1)}% +
+ ))} +
+
+ )} + + {analytics.riskAssessment.recommendations?.length > 0 && ( +
+
+ Recommendations +
+
+ {analytics.riskAssessment.recommendations.map((rec: string, index: number) => ( +
+ {rec} +
+ ))} +
+
+ )}
diff --git a/src/components/dashboard/PortfolioValue.tsx b/src/components/dashboard/PortfolioValue.tsx index 66f20503..52985691 100644 --- a/src/components/dashboard/PortfolioValue.tsx +++ b/src/components/dashboard/PortfolioValue.tsx @@ -1,15 +1,9 @@ import React, { useEffect, useMemo, useState } from 'react' import { useStore } from '../../lib/store' -import { fetchPrices, calculatePortfolioValue } from '../../lib/priceFeed' +import { fetchPrices, refreshPrices, calculatePortfolioValue } from '../../lib/priceFeed' import { getServer } from '../../lib/stellar' import { - calculateAssetAllocation, - calculateDiversificationScore, - identifyConcentrationRisks, - calculate24hPortfolioChange, fetchHistoricalPerformance, - calculateVolatility, - assessPortfolioRisk, generatePortfolioSummary, } from '../../lib/portfolioAnalytics'; import { @@ -178,26 +172,12 @@ export default function PortfolioValue() { return { ...point, value: totalValue }; }); - const allocation = calculateAssetAllocation(portfolio.items); - const diversificationScore = calculateDiversificationScore(allocation); - const concentrationRisks = identifyConcentrationRisks(allocation); - const change24h = calculate24hPortfolioChange(portfolio.items); - const volatility = calculateVolatility(historicalPerformance); - const riskAssessment = assessPortfolioRisk({ - volatility, - diversificationScore, - concentrationRisks, - }); const summary = generatePortfolioSummary(portfolio.items, historicalPerformance); return { - allocation, - diversificationScore, - concentrationRisks, - change24h, + ...summary, + change24h: summary.performance24h?.changePercent ?? 0, historicalPerformance, - volatility, - riskAssessment, summary, }; }, [portfolio, historicalData, prices]); @@ -361,11 +341,11 @@ function OverviewView({ portfolio, analytics }) { /> = 7 + diversificationScore >= 70 ? 'Well diversified' - : diversificationScore >= 4 + : diversificationScore >= 40 ? 'Moderate' : 'Concentrated' } @@ -374,11 +354,11 @@ function OverviewView({ portfolio, analytics }) { ({ - name: a.asset, - value: a.percentage, + name: a.code, + value: a.allocation, valueUsd: a.valueUsd, })); @@ -534,7 +514,7 @@ function AllocationView({ analytics }) {
{allocation.map((item, i) => (
- {item.asset} + {item.code}
${item.valueUsd.toFixed(2)}
- {item.percentage.toFixed(1)}% + {item.allocation.toFixed(1)}%
))} @@ -590,12 +570,12 @@ function AllocationView({ analytics }) {
- {risk.asset} + {risk.code}
{risk.message}
- {risk.percentage.toFixed(1)}% + {risk.allocation.toFixed(1)}%
))} @@ -715,7 +695,13 @@ function PerformanceView({ analytics, portfolio }) { // ─── Risk View ──────────────────────────────────────────────────────────────── function RiskView({ analytics }) { - const { riskAssessment, diversificationScore, volatility } = analytics; + const { + riskAssessment, + diversificationScore, + volatility, + concentrationRiskScore, + counterpartyRisk, + } = analytics; return (
@@ -730,22 +716,22 @@ function RiskView({ analytics }) { = 7 + diversificationScore >= 70 ? 'Well diversified' - : diversificationScore >= 4 + : diversificationScore >= 40 ? 'Moderate' : 'Concentrated' } @@ -757,6 +743,18 @@ function RiskView({ analytics }) { sub={volatility < 5 ? 'Low' : volatility < 15 ? 'Moderate' : 'High'} accent="var(--orange)" /> + = 70 ? 'High' : concentrationRiskScore >= 35 ? 'Moderate' : 'Low'} + accent={concentrationRiskScore >= 70 ? 'var(--red)' : concentrationRiskScore >= 35 ? 'var(--yellow)' : 'var(--green)'} + /> +
{/* Risk Assessment Details */} @@ -780,16 +778,50 @@ function RiskView({ analytics }) { marginBottom: '4px', }} > - {factor.name} + {factor.name || factor.factor}
- {factor.description} + {factor.description || factor.factor}
))} + {counterpartyRisk.issuerExposures.length > 0 && ( + +
+ {counterpartyRisk.issuerExposures.slice(0, 5).map((issuer) => ( +
+
+
+ {issuer.assets.join(', ')} +
+
+ {issuer.issuer.slice(0, 10)}...{issuer.issuer.slice(-8)} +
+
+
+ {issuer.percentage.toFixed(1)}% +
+
+ ))} +
+
+ )} + {/* Recommendations */} {riskAssessment.recommendations && riskAssessment.recommendations.length > 0 && ( diff --git a/src/components/dashboard/types.ts b/src/components/dashboard/types.ts index 61bf302a..1f656b5a 100644 --- a/src/components/dashboard/types.ts +++ b/src/components/dashboard/types.ts @@ -76,6 +76,7 @@ export interface SimulationResult { export interface PortfolioItem { code: string amount: number + issuer?: string | null priceUsd: number | null valueUsd: number | null change24h: number | null @@ -89,16 +90,22 @@ export interface PortfolioValue { /** Allocation breakdown for a single asset */ export interface AllocationItem { - asset: string - percentage: number + code: string + asset?: string + allocation: number + percentage?: number valueUsd: number + issuer?: string | null } /** A concentration risk found in the portfolio */ export interface ConcentrationRisk { asset: string + code?: string message: string percentage: number + allocation?: number + riskLevel?: string } /** Volatility and diversification assessment for the portfolio */ @@ -106,6 +113,8 @@ export interface PortfolioAnalytics { allocation: AllocationItem[] diversificationScore: number concentrationRisks: ConcentrationRisk[] + concentrationRiskScore?: number + counterpartyRisk?: CounterpartyRisk change24h: number | null historicalPerformance: HistoricalPoint[] volatility: number @@ -124,8 +133,27 @@ export interface HistoricalPoint { export interface RiskAssessment { level: string score: number - factors: { name: string; description: string }[] + factors: { name?: string; factor?: string; description?: string; impact?: string }[] recommendations?: string[] + components?: Record +} + +export interface CounterpartyRisk { + score: number + level: string + issuerCount: number + largestIssuerExposure: number + issuedExposure?: number + unpricedExposure: number + unpricedAssets?: number + issuerExposures: Array<{ + issuer: string + valueUsd: number + percentage: number + assets: string[] + }> + factors: RiskAssessment['factors'] + recommendations: string[] } /** Recharts pie chart data entry */ diff --git a/src/lib/portfolioAnalytics.js b/src/lib/portfolioAnalytics.js index bdc16fa7..2e1286ab 100644 --- a/src/lib/portfolioAnalytics.js +++ b/src/lib/portfolioAnalytics.js @@ -58,6 +58,10 @@ export function calculateDiversificationScore(portfolioItems) { return Math.max(0, Math.min(100, normalizedScore)) } +function clampScore(value) { + return Math.max(0, Math.min(100, Math.round(value))) +} + /** * Identify concentration risk (assets > 25% allocation) */ @@ -67,11 +71,144 @@ export function identifyConcentrationRisks(portfolioItems) { return portfolioItems .filter(item => (item.allocation || 0) > CONCENTRATION_THRESHOLD) .map(item => ({ + asset: item.code, code: item.code, allocation: item.allocation, + percentage: item.allocation, valueUsd: item.valueUsd, - riskLevel: item.allocation > 50 ? 'high' : 'medium' + riskLevel: item.allocation > 50 ? 'high' : 'medium', + message: `${item.code} represents ${item.allocation.toFixed(1)}% of the portfolio` + })) +} + +/** + * Score concentration risk using the largest position and HHI. + * 0 = balanced allocation, 100 = one asset dominates the portfolio. + */ +export function calculateConcentrationRiskScore(portfolioItems) { + if (!portfolioItems || portfolioItems.length === 0) return 0 + + const allocations = portfolioItems.map(item => item.allocation || 0) + const largestAllocation = Math.max(...allocations) + const hhi = allocations.reduce((sum, allocation) => sum + Math.pow(allocation, 2), 0) + const hhiScore = Math.max(0, (hhi - (10000 / Math.max(portfolioItems.length, 1))) / 100) + const largestScore = largestAllocation <= 25 ? largestAllocation * 0.6 : 15 + ((largestAllocation - 25) / 75) * 85 + + return clampScore((largestScore * 0.65) + (hhiScore * 0.35)) +} + +/** + * Assess issuer/counterparty exposure from Stellar trustline issuers. + * Native XLM is protocol exposure, while issued assets carry counterparty risk. + */ +export function assessCounterpartyRisk(portfolioItems) { + if (!portfolioItems || portfolioItems.length === 0) { + return { + score: 0, + level: 'low', + issuerCount: 0, + largestIssuerExposure: 0, + unpricedExposure: 0, + issuerExposures: [], + factors: [], + recommendations: [] + } + } + + const totalValue = portfolioItems.reduce((sum, item) => sum + (item.valueUsd || 0), 0) + const issuerMap = new Map() + let issuedValue = 0 + let unpricedAssets = 0 + let unpricedAmount = 0 + + portfolioItems.forEach((item) => { + if (!item.issuer) { + return + } + + if (!item.valueUsd || item.valueUsd <= 0) { + unpricedAssets += 1 + unpricedAmount += item.amount || 0 + return + } + + issuedValue += item.valueUsd + const current = issuerMap.get(item.issuer) || { issuer: item.issuer, valueUsd: 0, assets: [] } + current.valueUsd += item.valueUsd + current.assets.push(item.code) + issuerMap.set(item.issuer, current) + }) + + const issuerExposures = Array.from(issuerMap.values()) + .map((entry) => ({ + ...entry, + percentage: totalValue > 0 ? (entry.valueUsd / totalValue) * 100 : 0, + assets: Array.from(new Set(entry.assets)) })) + .sort((a, b) => b.percentage - a.percentage) + + const issuerCount = issuerExposures.length + const largestIssuerExposure = issuerExposures[0]?.percentage || 0 + const issuedExposure = totalValue > 0 ? (issuedValue / totalValue) * 100 : 0 + const unpricedExposure = portfolioItems.length > 0 ? (unpricedAssets / portfolioItems.length) * 100 : 0 + + let score = issuedExposure * 0.25 + largestIssuerExposure * 0.55 + unpricedExposure * 0.2 + if (issuerCount === 1 && issuedExposure > 20) score += 10 + if (issuerCount > 0 && issuerCount < 3 && issuedExposure > 50) score += 8 + score = clampScore(score) + + const factors = [] + const recommendations = [] + + if (largestIssuerExposure >= 35) { + factors.push({ + name: 'High issuer exposure', + factor: 'High issuer exposure', + impact: 'high', + description: `One issuer backs ${largestIssuerExposure.toFixed(1)}% of priced portfolio value.` + }) + recommendations.push('Reduce exposure to the largest issuer or add assets backed by independent issuers.') + } else if (largestIssuerExposure >= 15) { + factors.push({ + name: 'Moderate issuer exposure', + factor: 'Moderate issuer exposure', + impact: 'medium', + description: `Largest issuer exposure is ${largestIssuerExposure.toFixed(1)}% of portfolio value.` + }) + } + + if (unpricedAssets > 0) { + factors.push({ + name: 'Unpriced trustlines', + factor: 'Unpriced trustlines', + impact: unpricedAssets > 2 ? 'medium' : 'low', + description: `${unpricedAssets} issued asset${unpricedAssets === 1 ? '' : 's'} lack pricing data and may hide counterparty risk.` + }) + recommendations.push('Review unpriced trustlines and remove stale or unknown assets.') + } + + if (issuerCount === 0) { + factors.push({ + name: 'Protocol-only exposure', + factor: 'Protocol-only exposure', + impact: 'low', + description: 'No priced issued assets were found in the portfolio.' + }) + } + + return { + score, + level: score >= 70 ? 'high' : score >= 35 ? 'medium' : 'low', + issuerCount, + largestIssuerExposure, + issuedExposure, + unpricedExposure, + unpricedAssets, + unpricedAmount, + issuerExposures, + factors, + recommendations + } } // ── Performance Tracking ────────────────────────────────────────────────────── @@ -109,7 +246,7 @@ export function calculate24hPortfolioChange(portfolioItems) { let totalPreviousValue = 0 for (const item of portfolioItems) { - if (!item.valueUsd || !item.change24h) continue + if (item.valueUsd === null || item.valueUsd === undefined || item.change24h === null || item.change24h === undefined) continue const currentValue = item.valueUsd const previousValue = currentValue / (1 + item.change24h / 100) @@ -190,8 +327,9 @@ export async function fetchHistoricalPerformance(server, accountId, currentBalan * @param {Array} historicalData - Array of { value, timestamp } * @returns {number} Volatility percentage */ -export function calculateVolatility(historicalData) { +export function calculateVolatility(historicalData, options = {}) { if (!historicalData || historicalData.length < 2) return 0 + const annualized = options.annualized === true // Calculate daily returns const returns = [] @@ -211,8 +349,8 @@ export function calculateVolatility(historicalData) { // Calculate variance const variance = returns.reduce((sum, r) => sum + Math.pow(r - meanReturn, 2), 0) / returns.length - // Standard deviation (volatility) - const volatility = Math.sqrt(variance) * 100 + // Standard deviation of observed returns. Annualize only when explicitly requested. + const volatility = Math.sqrt(variance) * (annualized ? Math.sqrt(365) : 1) * 100 return volatility } @@ -235,54 +373,93 @@ export function calculateSharpeRatio(portfolioReturn, volatility, riskFreeRate = * @returns {Object} { level: 'low'|'medium'|'high', score: 0-100, factors: [] } */ export function assessPortfolioRisk(metrics) { - const { volatility = 0, diversificationScore = 0, concentrationRisks = [] } = metrics + const { + volatility = 0, + diversificationScore = 0, + concentrationRisks = [], + concentrationRiskScore = 0, + counterpartyRisk = { score: 0, factors: [], recommendations: [] }, + unpricedAssetCount = 0, + assetCount = 0 + } = metrics - let riskScore = 0 const factors = [] - - // Volatility contribution (0-40 points) - if (volatility > 10) { - riskScore += 40 - factors.push({ factor: 'High volatility', impact: 'high' }) - } else if (volatility > 5) { - riskScore += 20 - factors.push({ factor: 'Moderate volatility', impact: 'medium' }) + const recommendations = [] + + const volatilityScore = clampScore((volatility / 15) * 100) + const diversificationRiskScore = clampScore(100 - diversificationScore) + const counterpartyScore = counterpartyRisk.score || 0 + const valuationRiskScore = assetCount > 0 ? clampScore((unpricedAssetCount / assetCount) * 100) : 0 + + let riskScore = clampScore( + volatilityScore * 0.3 + + concentrationRiskScore * 0.3 + + counterpartyScore * 0.25 + + valuationRiskScore * 0.15 + ) + + if (volatility >= 10) { + factors.push({ name: 'High volatility', factor: 'High volatility', impact: 'high', description: `Portfolio value volatility is ${volatility.toFixed(2)}%.` }) + recommendations.push('Consider reducing exposure to assets with large recent price swings.') + } else if (volatility >= 5) { + factors.push({ name: 'Moderate volatility', factor: 'Moderate volatility', impact: 'medium', description: `Portfolio value volatility is ${volatility.toFixed(2)}%.` }) } else { - factors.push({ factor: 'Low volatility', impact: 'low' }) + factors.push({ name: 'Low volatility', factor: 'Low volatility', impact: 'low', description: `Portfolio value volatility is ${volatility.toFixed(2)}%.` }) } - // Diversification contribution (0-30 points) if (diversificationScore < 30) { - riskScore += 30 - factors.push({ factor: 'Poor diversification', impact: 'high' }) + factors.push({ name: 'Poor diversification', factor: 'Poor diversification', impact: 'high', description: 'Portfolio allocation is heavily concentrated.' }) + recommendations.push('Add exposure to additional assets or reduce the largest position.') } else if (diversificationScore < 60) { - riskScore += 15 - factors.push({ factor: 'Moderate diversification', impact: 'medium' }) + factors.push({ name: 'Moderate diversification', factor: 'Moderate diversification', impact: 'medium', description: 'Portfolio has some diversification, but allocation can be improved.' }) } else { - factors.push({ factor: 'Good diversification', impact: 'low' }) + factors.push({ name: 'Good diversification', factor: 'Good diversification', impact: 'low', description: 'Portfolio allocation is reasonably balanced.' }) } - // Concentration contribution (0-30 points) if (concentrationRisks.length > 0) { const highRiskCount = concentrationRisks.filter(r => r.riskLevel === 'high').length if (highRiskCount > 0) { - riskScore += 30 - factors.push({ factor: `${highRiskCount} highly concentrated asset(s)`, impact: 'high' }) + factors.push({ name: 'High concentration', factor: `${highRiskCount} highly concentrated asset(s)`, impact: 'high', description: 'At least one asset exceeds 50% of portfolio value.' }) + recommendations.push('Rebalance positions above 50% of portfolio value.') } else { - riskScore += 15 - factors.push({ factor: `${concentrationRisks.length} concentrated asset(s)`, impact: 'medium' }) + factors.push({ name: 'Concentration', factor: `${concentrationRisks.length} concentrated asset(s)`, impact: 'medium', description: 'One or more assets exceed 25% of portfolio value.' }) } } + ;(counterpartyRisk.factors || []).forEach((factor) => factors.push(factor)) + ;(counterpartyRisk.recommendations || []).forEach((recommendation) => recommendations.push(recommendation)) + + if (factors.some((factor) => factor.impact === 'high')) { + riskScore = Math.max(riskScore, 45) + } else if (factors.some((factor) => factor.impact === 'medium')) { + riskScore = Math.max(riskScore, 25) + } + + if (unpricedAssetCount > 0) { + recommendations.push('Confirm pricing and liquidity for assets without market data before increasing exposure.') + } + + if (recommendations.length === 0) { + recommendations.push('Maintain periodic reviews as prices, issuers, and allocations change.') + } + // Determine risk level let level = 'low' - if (riskScore > 60) level = 'high' - else if (riskScore > 30) level = 'medium' + if (riskScore >= 70) level = 'high' + else if (riskScore >= 35) level = 'medium' return { level, score: riskScore, - factors + factors, + recommendations: Array.from(new Set(recommendations)), + components: { + volatility: volatilityScore, + concentration: concentrationRiskScore, + diversification: diversificationRiskScore, + counterparty: counterpartyScore, + valuation: valuationRiskScore + } } } @@ -416,12 +593,19 @@ export function generatePortfolioSummary(portfolioItems, historicalData = []) { const allocation = calculateAssetAllocation(portfolioItems) const diversificationScore = calculateDiversificationScore(allocation) const concentrationRisks = identifyConcentrationRisks(allocation) + const concentrationRiskScore = calculateConcentrationRiskScore(allocation) + const counterpartyRisk = assessCounterpartyRisk(allocation) const performance24h = calculate24hPortfolioChange(portfolioItems) const volatility = calculateVolatility(historicalData) + const unpricedAssetCount = portfolioItems.filter(item => item.priceUsd === null || item.valueUsd === null).length const riskAssessment = assessPortfolioRisk({ volatility, diversificationScore, - concentrationRisks + concentrationRisks, + concentrationRiskScore, + counterpartyRisk, + unpricedAssetCount, + assetCount: portfolioItems.length }) const totalValue = portfolioItems.reduce((sum, item) => sum + (item.valueUsd || 0), 0) @@ -433,7 +617,10 @@ export function generatePortfolioSummary(portfolioItems, historicalData = []) { allocation, diversificationScore, concentrationRisks, + concentrationRiskScore, + counterpartyRisk, performance24h, + change24h: performance24h.changePercent, volatility, riskAssessment, topAssets: allocation.slice(0, 5), @@ -451,6 +638,8 @@ export default { calculate24hPortfolioChange, fetchHistoricalPerformance, calculateVolatility, + calculateConcentrationRiskScore, + assessCounterpartyRisk, calculateSharpeRatio, assessPortfolioRisk, calculateAssetPnL, diff --git a/src/lib/priceFeed.js b/src/lib/priceFeed.js index 021554f0..287ea26f 100644 --- a/src/lib/priceFeed.js +++ b/src/lib/priceFeed.js @@ -96,6 +96,7 @@ export function calculatePortfolioValue(balances, prices) { for (const balance of balances) { const code = balance.asset_type === 'native' ? 'XLM' : balance.asset_code; + const issuer = balance.asset_type === 'native' ? null : balance.asset_issuer; const amount = parseFloat(balance.balance) || 0; const price = prices[code]; @@ -105,6 +106,7 @@ export function calculatePortfolioValue(balances, prices) { items.push({ code, amount, + issuer, priceUsd: price.usd, valueUsd: usdValue, change24h: price.usd_24h_change, @@ -113,6 +115,7 @@ export function calculatePortfolioValue(balances, prices) { items.push({ code, amount, + issuer, priceUsd: null, valueUsd: null, change24h: null,