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+₹2,738· 0.18%

7 holdings · updated just now

Beta

0.97

vs NIFTY 50

VaR 95% · 1d

24,715

1.6% of value

Allocation

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

Financials is 44% of the portfolio, above your 25% limit. Trimming it lowers single-sector risk.

Stress test

β-scaled impact
NIFTY +0.0%
Estimated portfolio impact₹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";

/* ------------------------------------------------------------------ */
/* 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);

// Categorical allocation palette, tuned to read in both themes.
const SECTOR_HUES = [
  "217 91% 60%", // indigo
  "258 90% 66%", // violet
  "173 80% 40%", // teal
  "38 92% 50%", // amber
  "330 81% 60%", // rose
  "199 89% 48%", // cyan
];

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

export function PortfolioRisk({
  holdings = DEMO_HOLDINGS,
  concentrationLimit = 0.25,
  className,
  ref,
  ...rest
}: PortfolioRiskProps) {
  const reduced = useReducedMotion();
  const rootRef = React.useRef<HTMLDivElement>(null);
  const uid = React.useId().replace(/[^a-zA-Z0-9]/g, "");
  React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);

  const [shock, setShock] = React.useState(0); // NIFTY move in %, negative = down
  const [active, setActive] = React.useState<string | null>(null);

  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);
  const dayPnlPct = (dayPnl / total) * 100;
  // 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)
      .map((s, i) => ({ ...s, hue: SECTOR_HUES[i % SECTOR_HUES.length] }));
  }, [holdings, total]);

  // Cumulative offsets so segments lay out along one bar.
  const laidOut = React.useMemo(() => {
    let acc = 0;
    return sectors.map((s) => {
      const left = acc;
      acc += s.share;
      return { ...s, left };
    });
  }, [sectors]);

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

  // Stress slider geometry: 0% sits where "no move" falls in the range.
  const SHOCK_MIN = -10;
  const SHOCK_MAX = 5;
  const posOf = (v: number) => ((v - SHOCK_MIN) / (SHOCK_MAX - SHOCK_MIN)) * 100;
  const zeroPos = posOf(0);
  const thumbPos = posOf(shock);
  const fillLeft = Math.min(zeroPos, thumbPos);
  const fillWidth = Math.abs(thumbPos - zeroPos);
  const stressDown = shock < 0;

  useGSAP(
    () => {
      const root = rootRef.current;
      if (!root) return;
      const segs = gsap.utils.toArray<HTMLElement>(root.querySelectorAll("[data-seg]"));
      const legend = gsap.utils.toArray<HTMLElement>(root.querySelectorAll("[data-legend]"));
      if (reduced) {
        gsap.set(segs, { scaleX: 1, opacity: 1 });
        gsap.set(legend, { y: 0, opacity: 1 });
        return;
      }
      gsap.set(segs, { transformOrigin: "left center" });
      gsap
        .timeline()
        .fromTo(
          segs,
          { scaleX: 0, opacity: 0.4 },
          { scaleX: 1, opacity: 1, duration: 0.75, ease: "expo.out", stagger: 0.07 }
        )
        .fromTo(
          legend,
          { y: 6, opacity: 0 },
          { y: 0, opacity: 1, duration: 0.4, ease: "power3.out", stagger: 0.04 },
          0.35
        );
    },
    { dependencies: [reduced, holdings], scope: rootRef }
  );

  return (
    <div
      ref={rootRef}
      data-slot="portfolio-risk"
      className={cn(
        "group/card relative w-full max-w-2xl overflow-hidden rounded-3xl border border-border/60 bg-card p-6 shadow-md",
        className
      )}
      {...rest}
    >
      {/* Ambient top sheen */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 -top-24 h-40 bg-[radial-gradient(60%_100%_at_50%_100%,hsl(var(--elevation-hue)/0.05),transparent)] dark:bg-[radial-gradient(60%_100%_at_50%_100%,hsl(0_0%_100%/0.04),transparent)]"
      />

      {/* ---- Hero: value + day P&L ---------------------------------- */}
      <div className="relative flex flex-wrap items-start justify-between gap-4">
        <div>
          <p className="tracking-[0.1em] type-overline text-muted-foreground">
            Portfolio value
          </p>
          <div className="mt-1.5 flex flex-wrap items-center gap-2.5">
            <span className="text-4xl font-semibold leading-none tracking-tight text-foreground numeric">
              <NumberFlow value={total} prefix="₹" locale="en-IN" />
            </span>
            <span
              className={cn(
                "inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium numeric ring-1 ring-inset",
                dayPnl >= 0
                  ? "bg-market-up/10 text-market-up ring-market-up/20"
                  : "bg-market-down/10 text-market-down ring-market-down/20"
              )}
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2.5}
                strokeLinecap="round"
                strokeLinejoin="round"
                className={cn("size-3", dayPnl < 0 && "rotate-180")}
                aria-hidden="true"
              >
                <path d="M12 19V5M5 12l7-7 7 7" />
              </svg>
              {dayPnl >= 0 ? "+₹" : "−₹"}
              {inr(Math.abs(Math.round(dayPnl)))}
              <span className="opacity-60">· {Math.abs(dayPnlPct).toFixed(2)}%</span>
            </span>
          </div>
          <p className="mt-1.5 text-xs text-muted-foreground">
            {holdings.length} holdings · updated just now
          </p>
        </div>

        {/* Risk chips */}
        <div className="flex items-stretch gap-5 rounded-2xl border border-border/60 bg-background/60 px-4 py-2.5 backdrop-blur-sm">
          <div>
            <p className="type-overline text-muted-foreground">
              Beta
            </p>
            <p className="mt-0.5 text-lg font-semibold numeric text-foreground">
              <NumberFlow value={beta} decimals={2} />
            </p>
            <p className="type-caption text-muted-foreground">vs NIFTY 50</p>
          </div>
          <div className="w-px bg-border/60" />
          <div>
            <p className="type-overline text-muted-foreground">
              VaR 95% · 1d
            </p>
            <p className="mt-0.5 text-lg font-semibold numeric text-risk-high">
              ₹<NumberFlow value={Math.round(var95)} locale="en-IN" />
            </p>
            <p className="type-caption text-muted-foreground">
              {((var95 / total) * 100).toFixed(1)}% of value
            </p>
          </div>
        </div>
      </div>

      {/* ---- Allocation: one segmented bar + interactive legend ------ */}
      <div className="relative mt-7">
        <div className="flex items-baseline justify-between">
          <h3 className="type-title text-foreground">Allocation</h3>
          {concentrated ? (
            <span className="inline-flex items-center gap-1.5 type-meta font-medium text-warning">
              <span className="relative flex size-1.5">
                <span className="absolute inline-flex size-full animate-ping rounded-full bg-warning/60" />
                <span className="relative inline-flex size-1.5 rounded-full bg-warning" />
              </span>
              Concentrated
            </span>
          ) : null}
        </div>

        {/* Segmented bar */}
        <div
          className="relative mt-3 h-3.5 w-full"
          role="img"
          aria-label={`Sector allocation: ${sectors
            .map((s) => `${s.sector} ${(s.share * 100).toFixed(0)}%`)
            .join(", ")}`}
        >
          {laidOut.map((s) => {
            const isActive = active === s.sector;
            const dim = active != null && !isActive;
            return (
              <div
                key={s.sector}
                data-seg
                onMouseEnter={() => setActive(s.sector)}
                onMouseLeave={() => setActive(null)}
                className={cn(
                  "absolute top-0 h-full cursor-default rounded-full transition-[opacity,transform,box-shadow] duration-300",
                  dim ? "opacity-40" : "opacity-100",
                  isActive && "shadow-[0_0_0_1px_var(--card),0_4px_12px_-2px_var(--seg-shadow)]"
                )}
                style={
                  {
                    left: `calc(${s.left * 100}% + ${s.left > 0 ? 3 : 0}px)`,
                    width: `calc(${s.share * 100}% - 3px)`,
                    background: `linear-gradient(180deg, hsl(${s.hue} / 0.92), hsl(${s.hue}))`,
                    transform: isActive ? "scaleY(1.25)" : "scaleY(1)",
                    "--seg-shadow": `hsl(${s.hue} / 0.5)`,
                  } as React.CSSProperties
                }
              />
            );
          })}
        </div>

        {/* Legend */}
        <ul className="mt-4 flex flex-wrap gap-x-5 gap-y-2.5">
          {laidOut.map((s) => {
            const isActive = active === s.sector;
            const dim = active != null && !isActive;
            const over = s.share > concentrationLimit;
            return (
              <li
                key={s.sector}
                data-legend
                onMouseEnter={() => setActive(s.sector)}
                onMouseLeave={() => setActive(null)}
                className={cn(
                  "flex cursor-default items-center gap-2 transition-opacity duration-200",
                  dim && "opacity-45"
                )}
              >
                <span
                  className="size-2.5 shrink-0 rounded-[4px] transition-transform duration-200"
                  style={{
                    background: `hsl(${s.hue})`,
                    transform: isActive ? "scale(1.25)" : "scale(1)",
                  }}
                />
                <span className="text-xs font-medium text-foreground">{s.sector}</span>
                <span
                  className={cn(
                    "text-xs numeric",
                    over ? "font-semibold text-warning" : "text-muted-foreground"
                  )}
                >
                  {(s.share * 100).toFixed(1)}%
                </span>
              </li>
            );
          })}
        </ul>

        {concentrated ? (
          <div className="mt-4 flex items-start gap-2.5 rounded-2xl border border-warning/25 bg-warning/[0.06] px-3.5 py-3">
            <svg
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth={2}
              strokeLinecap="round"
              strokeLinejoin="round"
              className="mt-px size-4 shrink-0 text-warning"
              aria-hidden="true"
            >
              <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
              <path d="M12 9v4M12 17h.01" />
            </svg>
            <p className="type-label leading-5 text-foreground">
              <span className="font-semibold">{topConcentration.sector}</span> is{" "}
              {(topConcentration.share * 100).toFixed(0)}% of the portfolio, above your{" "}
              {(concentrationLimit * 100).toFixed(0)}% limit. Trimming it lowers single-sector risk.
            </p>
          </div>
        ) : null}
      </div>

      {/* ---- Stress test: custom slider ----------------------------- */}
      <div className="relative mt-7 rounded-2xl border border-border/60 bg-background/50 p-4">
        <div className="flex items-center justify-between gap-2">
          <div className="flex items-center gap-2">
            <h3 className="type-title text-foreground">Stress test</h3>
            <span className="type-meta text-muted-foreground">β-scaled impact</span>
          </div>
          <span
            className={cn(
              "rounded-lg px-2 py-1 text-xs font-semibold numeric ring-1 ring-inset transition-colors duration-300",
              shock === 0
                ? "bg-muted/60 text-muted-foreground ring-border/60"
                : stressDown
                  ? "bg-market-down/10 text-market-down ring-market-down/20"
                  : "bg-market-up/10 text-market-up ring-market-up/20"
            )}
          >
            NIFTY {shock >= 0 ? "+" : ""}
            {shock.toFixed(1)}%
          </span>
        </div>

        {/* Slider */}
        <div className="relative mt-4 h-6">
          <style>{`
            #stress-${uid}::-webkit-slider-thumb { -webkit-appearance: none; width: 28px; height: 28px; cursor: grab; }
            #stress-${uid}:active::-webkit-slider-thumb { cursor: grabbing; }
            #stress-${uid}::-moz-range-thumb { width: 28px; height: 28px; border: 0; background: transparent; cursor: grab; }
          `}</style>
          {/* track */}
          <div className="pointer-events-none absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-muted ring-1 ring-inset ring-border/50" />
          {/* fill */}
          <div
            className="pointer-events-none absolute top-1/2 h-2 -translate-y-1/2 rounded-full transition-[background] duration-300"
            style={{
              left: `${fillLeft}%`,
              width: `${fillWidth}%`,
              background: stressDown
                ? "linear-gradient(270deg, color-mix(in oklab, var(--market-down) 55%, transparent), var(--market-down))"
                : "linear-gradient(90deg, color-mix(in oklab, var(--market-up) 55%, transparent), var(--market-up))",
            }}
          />
          {/* zero tick */}
          <div
            className="pointer-events-none absolute top-1/2 h-3.5 w-px -translate-x-1/2 -translate-y-1/2 rounded-full bg-border"
            style={{ left: `${zeroPos}%` }}
          />
          {/* native input (a11y + keyboard + pointer), transparent on top */}
          <input
            id={`stress-${uid}`}
            type="range"
            min={SHOCK_MIN}
            max={SHOCK_MAX}
            step={0.5}
            value={shock}
            aria-label="Index shock scenario in percent"
            onChange={(e) => setShock(parseFloat(e.target.value))}
            className="peer absolute inset-0 z-20 w-full cursor-pointer appearance-none bg-transparent opacity-0"
          />
          {/* thumb */}
          <div
            className={cn(
              "pointer-events-none absolute top-1/2 z-10 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 bg-background shadow-md transition-[border-color,box-shadow] duration-300",
              "peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background",
              shock === 0
                ? "border-muted-foreground/60"
                : stressDown
                  ? "border-market-down"
                  : "border-market-up"
            )}
            style={{ left: `${thumbPos}%` }}
          />
        </div>

        <div className="mt-4 flex items-end justify-between">
          <span className="type-meta text-muted-foreground">
            Estimated portfolio impact
          </span>
          <span
            className={cn(
              "type-metric numeric transition-colors duration-300",
              shock === 0
                ? "text-muted-foreground"
                : shockImpact >= 0
                  ? "text-market-up"
                  : "text-market-down"
            )}
          >
            <NumberFlow
              value={Math.abs(Math.round(shockImpact))}
              prefix={shock === 0 ? "₹" : shockImpact >= 0 ? "+₹" : "−₹"}
              locale="en-IN"
            />
          </span>
        </div>

        {shock <= -5 ? (
          <p className="mt-3 rounded-xl bg-muted/60 px-3 py-2 type-meta 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>
  );
}