Portfolio Risk

Signature workflows

Risk cockpit: value, day P&L, beta and 1-day VaR tiles, sector exposure bars with concentration alerts, and a scenario slider with a hedge idea.

Portfolio value

₹15,41,000

7 holdings

Day P&L

+₹2,738

0.18%

Beta

0.97

vs NIFTY 50

VaR 95% · 1d

₹24,715

1.6% of value

Sector exposure

  • Financials44.1%
  • IT25.6%
  • Energy19.5%
  • Pharma6.0%
  • Consumer4.8%

Concentration risk

Financials is 44% of the portfolio — above your 25% limit.

Scenario

NIFTY +0.0%
Estimated portfolio impact (β-scaled)+₹0

Install

npx shadcn@latest add https://labs.duku.design/r/portfolio-risk.json

Ask your agent to use this

Connect DUKU Labs to MCP, then hand your coding agent this prompt:

Use the DUKU Labs MCP server to install the Portfolio Risk component (get_component "portfolio-risk", then its install command). Adapt it to my existing design tokens, preserve all accessibility and reduced-motion behavior, and keep every interaction state working.

Interaction

TryWhat happens
MountSector bars fill staggered; over-limit sectors render in risk-high with a concentration alert
Drag the scenario sliderβ-scaled impact rolls via NumberFlow with trend colors
Shock below −5%A hedge suggestion appears (educational copy, explicitly not advice)

Props

PropTypeDefaultDescription
holdingsHolding[]DEMO_HOLDINGSSymbol, sector, value, beta, day change.
concentrationLimitnumber0.25Sector share that triggers the warning.
Source — registry/default/fintech/portfolio-risk.tsx
"use client";

import * as React from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/lib/use-reduced-motion";
import { NumberFlow } from "@/registry/default/fintech/number-flow";
import { Alert } from "@/registry/default/ui/alert";

/* ------------------------------------------------------------------ */
/* Model                                                                */
/* ------------------------------------------------------------------ */

export interface Holding {
  symbol: string;
  name: string;
  sector: string;
  value: number;
  /** Stock beta vs the index. */
  beta: number;
  dayChangePct: number;
}

export const DEMO_HOLDINGS: Holding[] = [
  { symbol: "HDFCBANK", name: "HDFC Bank", sector: "Financials", value: 412000, beta: 1.05, dayChangePct: 0.6 },
  { symbol: "ICICIBANK", name: "ICICI Bank", sector: "Financials", value: 268000, beta: 1.12, dayChangePct: -0.3 },
  { symbol: "INFY", name: "Infosys", sector: "IT", value: 236000, beta: 0.85, dayChangePct: 1.2 },
  { symbol: "TCS", name: "TCS", sector: "IT", value: 158000, beta: 0.8, dayChangePct: 0.4 },
  { symbol: "RELIANCE", name: "Reliance", sector: "Energy", value: 301000, beta: 1.1, dayChangePct: -0.8 },
  { symbol: "SUNPHARMA", name: "Sun Pharma", sector: "Pharma", value: 92000, beta: 0.6, dayChangePct: 0.9 },
  { symbol: "DMART", name: "Avenue Supermarts", sector: "Consumer", value: 74000, beta: 0.7, dayChangePct: -1.1 },
];

export interface PortfolioRiskProps
  extends React.HTMLAttributes<HTMLDivElement> {
  holdings?: Holding[];
  /** Concentration warning threshold as a fraction of portfolio. Default 0.25. */
  concentrationLimit?: number;
  ref?: React.Ref<HTMLDivElement>;
}

const inr = (n: number) =>
  new Intl.NumberFormat("en-IN", { maximumFractionDigits: 0 }).format(n);

/* ------------------------------------------------------------------ */
/* Cockpit                                                              */
/* ------------------------------------------------------------------ */

export function PortfolioRisk({
  holdings = DEMO_HOLDINGS,
  concentrationLimit = 0.25,
  className,
  ref,
  ...rest
}: PortfolioRiskProps) {
  const reduced = useReducedMotion();
  const rootRef = React.useRef<HTMLDivElement>(null);
  React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);

  const [shock, setShock] = React.useState(0); // NIFTY move in %, negative = down

  const total = holdings.reduce((s, h) => s + h.value, 0);
  const beta = holdings.reduce((s, h) => s + h.beta * h.value, 0) / total;
  const dayPnl = holdings.reduce((s, h) => s + (h.dayChangePct / 100) * h.value, 0);
  // 1-day 95% parametric VaR with ~1% daily index vol scaled by beta
  const var95 = total * beta * 0.01 * 1.65;

  const sectors = React.useMemo(() => {
    const map = new Map<string, number>();
    for (const h of holdings) map.set(h.sector, (map.get(h.sector) ?? 0) + h.value);
    return [...map.entries()]
      .map(([sector, value]) => ({ sector, value, share: value / total }))
      .sort((a, b) => b.value - a.value);
  }, [holdings, total]);

  const topConcentration = sectors[0];
  const concentrated = topConcentration.share > concentrationLimit;
  const shockImpact = total * beta * (shock / 100);

  /* Sector bars fill on scroll-in / data change. */
  useGSAP(
    () => {
      const root = rootRef.current;
      if (!root) return;
      const bars = gsap.utils.toArray<HTMLElement>(root.querySelectorAll("[data-bar]"));
      for (const bar of bars) {
        const target = Number(bar.dataset.bar ?? 0) * 100;
        if (reduced) gsap.set(bar, { width: `${target}%` });
        else
          gsap.fromTo(
            bar,
            { width: 0 },
            { width: `${target}%`, duration: 0.7, ease: "power3.out", stagger: 0.06 }
          );
      }
    },
    { dependencies: [reduced, holdings], scope: rootRef }
  );

  const tiles: { label: string; value: React.ReactNode; sub?: string }[] = [
    {
      label: "Portfolio value",
      value: <NumberFlow value={total} prefix="₹" locale="en-IN" />,
      sub: `${holdings.length} holdings`,
    },
    {
      label: "Day P&L",
      value: (
        <span className={dayPnl >= 0 ? "text-market-up" : "text-market-down"}>
          <NumberFlow value={Math.round(dayPnl)} prefix={dayPnl >= 0 ? "+₹" : "−₹"} locale="en-IN" />
        </span>
      ),
      sub: `${((dayPnl / total) * 100).toFixed(2)}%`,
    },
    {
      label: "Beta",
      value: <NumberFlow value={beta} decimals={2} />,
      sub: "vs NIFTY 50",
    },
    {
      label: "VaR 95% · 1d",
      value: (
        <span className="text-risk-high">
          <NumberFlow value={Math.round(var95)} prefix="₹" locale="en-IN" />
        </span>
      ),
      sub: `${((var95 / total) * 100).toFixed(1)}% of value`,
    },
  ];

  return (
    <div
      ref={rootRef}
      data-slot="portfolio-risk"
      className={cn(
        "w-full max-w-2xl rounded-2xl border border-border bg-card p-4",
        className
      )}
      {...rest}
    >
      {/* Stat tiles */}
      <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
        {tiles.map((t) => (
          <div key={t.label} className="rounded-xl border border-border bg-background p-3">
            <p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
              {t.label}
            </p>
            <p className="mt-1 text-lg font-semibold tabular-nums text-foreground">
              {t.value}
            </p>
            {t.sub ? (
              <p className="text-[11px] tabular-nums text-muted-foreground">{t.sub}</p>
            ) : null}
          </div>
        ))}
      </div>

      {/* Sector exposure */}
      <div className="mt-4">
        <h3 className="text-xs font-semibold text-foreground">Sector exposure</h3>
        <ul className="mt-2 flex flex-col gap-2">
          {sectors.map((s) => {
            const over = s.share > concentrationLimit;
            return (
              <li key={s.sector} className="flex items-center gap-2 text-xs">
                <span className="w-20 shrink-0 truncate text-muted-foreground">
                  {s.sector}
                </span>
                <span className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
                  <span
                    data-bar={s.share}
                    className={cn(
                      "block h-full rounded-full",
                      over ? "bg-risk-high" : "bg-primary"
                    )}
                    style={{ width: 0 }}
                  />
                </span>
                <span
                  className={cn(
                    "w-12 shrink-0 text-right tabular-nums",
                    over ? "font-medium text-risk-high" : "text-foreground"
                  )}
                >
                  {(s.share * 100).toFixed(1)}%
                </span>
              </li>
            );
          })}
        </ul>
      </div>

      {concentrated ? (
        <div className="mt-3">
          <Alert variant="warning" title="Concentration risk">
            {topConcentration.sector} is {(topConcentration.share * 100).toFixed(0)}% of the
            portfolio — above your {(concentrationLimit * 100).toFixed(0)}% limit.
          </Alert>
        </div>
      ) : null}

      {/* Scenario testing */}
      <div className="mt-4 rounded-xl border border-border bg-background p-3">
        <div className="flex items-center justify-between gap-2">
          <h3 className="text-xs font-semibold text-foreground">Scenario</h3>
          <span className="text-xs tabular-nums text-muted-foreground">
            NIFTY {shock >= 0 ? "+" : ""}
            {shock.toFixed(1)}%
          </span>
        </div>
        <input
          type="range"
          min={-10}
          max={5}
          step={0.5}
          value={shock}
          aria-label="Index shock scenario in percent"
          onChange={(e) => setShock(parseFloat(e.target.value))}
          className="mt-2 w-full accent-primary"
        />
        <div className="mt-1 flex items-baseline justify-between">
          <span className="text-[11px] text-muted-foreground">
            Estimated portfolio impact (β-scaled)
          </span>
          <span
            className={cn(
              "text-sm font-semibold tabular-nums",
              shockImpact >= 0 ? "text-market-up" : "text-market-down"
            )}
          >
            <NumberFlow
              value={Math.round(shockImpact)}
              prefix={shockImpact >= 0 ? "+₹" : "−₹"}
              locale="en-IN"
              trend
            />
          </span>
        </div>
        {shock <= -5 ? (
          <p className="mt-2 rounded-lg bg-muted/60 px-2.5 py-1.5 text-[11px] leading-4 text-foreground">
            <span className="font-semibold">Hedge idea:</span> a NIFTY put ~5% below spot
            (β-weighted {inr(Math.round(total * beta))} exposure) caps this scenario near ₹
            {inr(Math.round(Math.abs(shockImpact) * 0.4))}. Educational, not advice.
          </p>
        ) : null}
      </div>
    </div>
  );
}