From 51fa43d7fae79656b11bb94613e1cfb0c5651ce2 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Sun, 2 Aug 2026 08:58:09 +0200 Subject: [PATCH 1/2] feat: add PortfolioRiskScore component #302 --- src/components/PortfolioRiskScore.tsx | 74 +++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/components/PortfolioRiskScore.tsx diff --git a/src/components/PortfolioRiskScore.tsx b/src/components/PortfolioRiskScore.tsx new file mode 100644 index 0000000..5e14245 --- /dev/null +++ b/src/components/PortfolioRiskScore.tsx @@ -0,0 +1,74 @@ +import React, { useMemo } from 'react'; + +type BondRating = 'AAA' | 'AA' | 'A' | 'BBB' | 'BB' | 'B' | 'CCC' | 'CC' | 'C' | 'D'; + +interface BondHolding { + symbol: string; + rating: BondRating; + weight: number; + yield: number; + maturity: string; +} + +const RATING_SCORES: Record = { + AAA: 1, AA: 2, A: 3, BBB: 5, BB: 8, B: 12, CCC: 18, CC: 25, C: 35, D: 50, +}; + +function calcPortfolioRisk(holdings: BondHolding[]) { + if (!holdings.length) return { score: 0, tier: 1, contributions: [] as any[] }; + const contributions = holdings.map(h => ({ + symbol: h.symbol, + rating: h.rating, + weight: h.weight, + score: RATING_SCORES[h.rating], + contribution: (RATING_SCORES[h.rating] * h.weight) / 100, + })); + const weightedSum = contributions.reduce((s, c) => s + c.contribution, 0); + const totalWeight = holdings.reduce((s, h) => s + h.weight, 0); + const rawScore = totalWeight > 0 ? (weightedSum / totalWeight) * 100 : 0; + const score = Math.round(rawScore); + let tier = 1; + if (score > 50) tier = 5; + else if (score > 25) tier = 4; + else if (score > 12) tier = 3; + else if (score > 5) tier = 2; + return { score, tier, contributions }; +} + +const TIERS: Record = { + 1: { label: 'Very Low Risk', color: '#22c55e' }, + 2: { label: 'Low Risk', color: '#84cc16' }, + 3: { label: 'Moderate Risk', color: '#eab308' }, + 4: { label: 'Elevated Risk', color: '#f97316' }, + 5: { label: 'High Risk', color: '#ef4444' }, +}; + +export default function PortfolioRiskScore({ holdings }: { holdings: BondHolding[] }) { + const { score, tier, contributions } = useMemo(() => calcPortfolioRisk(holdings), [holdings]); + const t = TIERS[tier]; + if (!holdings.length) return
No bond holdings to analyze.
; + return ( +
+
+

Portfolio Risk Score

+ + {score} + +
+

{t.label} — Weighted score from bond ratings mix

+
+ {contributions.map(c => ( +
+ + {c.symbol} + ({c.rating}) + + + {c.weight}% weight — {c.contribution.toFixed(1)} risk pts + +
+ ))} +
+
+ ); +} From e694b4b49eb75fbae3760b48ac671d867713039d Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Sun, 2 Aug 2026 08:58:10 +0200 Subject: [PATCH 2/2] test: add PortfolioRiskScore tests #302 --- src/components/PortfolioRiskScore.test.tsx | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/components/PortfolioRiskScore.test.tsx diff --git a/src/components/PortfolioRiskScore.test.tsx b/src/components/PortfolioRiskScore.test.tsx new file mode 100644 index 0000000..d76d8c0 --- /dev/null +++ b/src/components/PortfolioRiskScore.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import PortfolioRiskScore from './PortfolioRiskScore'; + +const mixed = [ + { symbol: 'AAA25', rating: 'AAA' as const, weight: 30, yield: 4.5, maturity: '2028-06-15' }, + { symbol: 'BBB25', rating: 'BBB' as const, weight: 25, yield: 6.2, maturity: '2030-03-20' }, + { symbol: 'BB20', rating: 'BB' as const, weight: 20, yield: 8.1, maturity: '2032-11-10' }, + { symbol: 'B15', rating: 'B' as const, weight: 15, yield: 10.5, maturity: '2034-01-05' }, + { symbol: 'AA10', rating: 'AA' as const, weight: 10, yield: 5.1, maturity: '2029-09-30' }, +]; + +describe('PortfolioRiskScore', () => { + it('renders risk score for mixed portfolio', () => { + render(); + expect(screen.getByText('Portfolio Risk Score')).toBeInTheDocument(); + }); + + it('shows empty state', () => { + render(); + expect(screen.getByText(/No bond holdings/)).toBeInTheDocument(); + }); + + it('renders all holdings', () => { + render(); + expect(screen.getByText('AAA25')).toBeInTheDocument(); + expect(screen.getByText('B15')).toBeInTheDocument(); + }); + + it('all-AAA shows Very Low Risk', () => { + render(); + expect(screen.getByText(/Very Low Risk/)).toBeInTheDocument(); + }); +});