Skip to content
Merged

solved #1096

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
53 changes: 53 additions & 0 deletions docs/VOLATILITY_EXPOSURE_BINDING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Volatility Exposure Binding

This document describes how live commitment metrics feed the `VolatilityExposureMeter` on the commitment detail page.

## Data flow

1. **Commitment detail page** (`src/app/commitments/[id]/page.tsx`) gathers value history and drawdown series plus the commitment's `maxLossPercent`.
2. **`computeCommitmentExposure`** (`src/utils/exposure.ts`) derives a single 0–100 exposure score and related thresholds.
3. **`CommitmentHealthMetrics`** passes the result to chart tabs that render `VolatilityExposureMeter`.

## Exposure calculation

| Input | Role |
| --- | --- |
| Latest drawdown (0–1 fraction) | Compared against `maxLossPercent` to produce a drawdown exposure score (0–100). |
| Value history (≥ 2 points) | Mean absolute period-over-period return, scaled by protocol `maxLossPercentCeiling`. |
| `maxLossPercent` | Commitment rule; also drives the drawdown chart reference line (`maxLoss / 100`). |

When both drawdown and volatility signals exist, the final score is a weighted blend (60% drawdown, 40% volatility). If only one signal is available, that signal is used alone.

## Thresholds

| Threshold | Value | Usage |
| --- | --- | --- |
| Low / medium boundary | 33% | Meter zone label and `aria-valuetext` band |
| Medium / high boundary | 66% | Meter zone label and `aria-valuetext` band |
| Drawdown reference line | `maxLossPercent / 100` | Drawdown chart dashed threshold |

Zone thresholds are exported as `EXPOSURE_ZONE_THRESHOLDS` from `src/utils/exposure.ts`.

## Insufficient data

Exposure is **not** computed when:

- `maxLossPercent` is missing or ≤ 0, or
- There is no valid drawdown point **and** fewer than two usable value-history points.

In that case `computeCommitmentExposure` returns `{ status: 'insufficient_data' }`. The meter renders an explicit “Insufficient data” message with accessible `aria-valuetext` instead of a numeric reading.

## Accessibility

When data is available, the meter keeps:

- `role="meter"` with `aria-valuenow`, `aria-valuemin`, `aria-valuemax`
- `aria-label` including percent and band (low / medium / high)
- `aria-valuetext` with percent and band

When data is insufficient, `aria-valuetext` is `"Insufficient data"` and no misleading fill is shown.

## Tests

- Unit: `src/utils/__tests__/exposure.test.ts` — zones, boundaries, insufficient data
- RTL binding: `src/components/dashboard/__tests__/CommitmentHealthMetrics.exposure.test.tsx` — meter wired from computed exposure
10 changes: 8 additions & 2 deletions src/app/commitments/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import CommitmentEarlyExitModal from '@/components/CommitmentEarlyExitModal/Comm
import DisputeModal from '@/components/modals/DisputeModal';
import DisputeStatusTracker, { type DisputeInfo } from '@/components/dispute/DisputeStatusTracker';
import { openExplorerUrl } from '@/utils/explorerLinks';
import { computeCommitmentExposure } from '@/utils/exposure';
import { CommitmentStatusProvider, useCommitmentStatus } from '@/context/CommitmentStatusContext';

// Mock Commitments
Expand Down Expand Up @@ -152,6 +153,12 @@ export default function CommitmentDetailPage({
const earlyExitPenaltyLabel = `${commitment.earlyExitPenaltyPercent ?? 3}%`
const { canEarlyExit } = commitment

const exposure = computeCommitmentExposure({
valueHistory: MOCK_VALUE_HISTORY_DATA,
drawdownHistory: MOCK_DRAWDOWN_DATA,
maxLossPercent: commitment.maxLoss,
});

const [exportModalOpen, setExportModalOpen] = useState(false);
const [earlyExitModalOpen, setEarlyExitModalOpen] = useState(false);
const [disputeModalOpen, setDisputeModalOpen] = useState(false);
Expand Down Expand Up @@ -252,8 +259,7 @@ export default function CommitmentDetailPage({
drawdownData={MOCK_DRAWDOWN_DATA}
valueHistoryData={MOCK_VALUE_HISTORY_DATA}
feeGenerationData={MOCK_FEE_GENERATION_DATA}
thresholdPercent={0.5}
volatilityPercent={35}
exposure={exposure}
/>
</ErrorBoundary>

Expand Down
59 changes: 36 additions & 23 deletions src/components/VolatilityExposureMeter/VolatilityExposureMeter.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
'use client'

import {
EXPOSURE_ZONE_THRESHOLDS,
getExposureLevel,
type ExposureLevel,
} from '@/utils/exposure'
import styles from './VolatilityExposureMeter.module.css'
import { useReducedMotion } from '@/lib/a11y/useReducedMotion'

export interface VolatilityExposureMeterProps {
/** Current exposure as a percentage (0–100). Clamped when rendering. */
valuePercent: number
valuePercent?: number
/** When true, shows an explicit insufficient-data state instead of a numeric reading. */
insufficientData?: boolean
/** Optional short description of what the exposure means. */
description?: string
/** Zone boundaries used for level labels and accessible annotations. */
zoneThresholds?: typeof EXPOSURE_ZONE_THRESHOLDS
}

function clamp(value: number): number {
if (typeof value !== 'number' || Number.isNaN(value)) return 0
return Math.max(0, Math.min(100, value))
}

function exposureLevel(percent: number): 'low' | 'medium' | 'high' {
if (percent <= 33) return 'low'
if (percent <= 66) return 'medium'
return 'high'
}

export default function VolatilityExposureMeter({
valuePercent,
valuePercent = 0,
insufficientData = false,
description,
zoneThresholds = EXPOSURE_ZONE_THRESHOLDS,
}: VolatilityExposureMeterProps) {
const percent = clamp(valuePercent)
const level = exposureLevel(percent)
Expand All @@ -34,34 +39,36 @@ export default function VolatilityExposureMeter({
<section
className={styles.container}
aria-labelledby="volatility-exposure-title"
aria-describedby={description ? 'volatility-exposure-desc' : undefined}
aria-describedby={
description || insufficientData ? 'volatility-exposure-desc' : undefined
}
>
<div className={styles.header}>
<h2 id="volatility-exposure-title" className={styles.title}>
Volatility Exposure
</h2>
<span className={styles.percentLabel}>{Math.round(percent)}%</span>
<span className={styles.percentLabel}>
{insufficientData ? '—' : `${Math.round(percent)}%`}
</span>
</div>

<div
className={styles.barTrack}
role="meter"
aria-valuenow={percent}
aria-valuenow={insufficientData ? undefined : percent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={ariaLabel}
aria-valuetext={`${percent} percent, ${level}`}
aria-valuetext={valueText}
>
<div
className={styles.barMask}
style={{
width: `${percent}%`,
// Honor the motion policy: live updates still apply, just without animation.
transition: reducedMotion ? 'none' : undefined,
}}
>
<div className={styles.barGradient} />
</div>
{!insufficientData && (
<div
className={styles.barMask}
style={{ width: `${percent}%` }}
>
<div className={styles.barGradient} />
</div>
)}
</div>

<div className={styles.labelsRow}>
Expand All @@ -70,7 +77,13 @@ export default function VolatilityExposureMeter({
<span>High</span>
</div>

{description && (
{insufficientData && (
<p id="volatility-exposure-desc" className={styles.description}>
Insufficient data — not enough value history or drawdown metrics to compute exposure.
</p>
)}

{!insufficientData && description && (
<p id="volatility-exposure-desc" className={styles.description}>
{description}
</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* @vitest-environment happy-dom
*/

import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import VolatilityExposureMeter from '../VolatilityExposureMeter';

describe('VolatilityExposureMeter', () => {
it('clamps invalid numeric input to zero for accessible meter values', () => {
render(<VolatilityExposureMeter valuePercent={Number.NaN} />);

const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuenow', '0');
expect(meter).toHaveAttribute('aria-valuetext', '0 percent, low');
});

it('renders insufficient data without a numeric fill or valuenow', () => {
render(<VolatilityExposureMeter insufficientData valuePercent={55} />);

const meter = screen.getByRole('meter');
expect(meter).not.toHaveAttribute('aria-valuenow');
expect(meter).toHaveAttribute('aria-valuetext', 'Insufficient data');
expect(screen.getByText('—')).toBeTruthy();
});
});
27 changes: 18 additions & 9 deletions src/components/dashboard/CommitmentHealthMetrics.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/vitest';

import CommitmentHealthMetrics from './CommitmentHealthMetrics';
import { EXPOSURE_ZONE_THRESHOLDS } from '@/utils/exposure';

vi.mock('next/dynamic', () => ({
default: (loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>) => {
Expand Down Expand Up @@ -99,14 +100,22 @@ const complianceData = [
{ date: isoDay(15), complianceScore: 95 },
];

const sampleExposure = {
status: 'ok' as const,
exposurePercent: 32,
level: 'low' as const,
drawdownThresholdPercent: 0.1,
zoneThresholds: EXPOSURE_ZONE_THRESHOLDS,
};

const baseProps = {
commitmentId: 'commitment-1',
valueHistoryData,
drawdownData,
feeGenerationData,
complianceData,
thresholdPercent: 10,
volatilityPercent: 3.2,
exposure: sampleExposure,
};

function renderComponent(overrides: Partial<typeof baseProps> = {}) {
Expand Down Expand Up @@ -161,12 +170,12 @@ describe('CommitmentHealthMetrics - initial render', () => {
expect(drawdownButton.className).not.toContain('bg-[#222]');
});

it('passes valueHistoryData and volatilityPercent to the value chart on initial render', () => {
it('passes valueHistoryData and exposure to the value chart on initial render', () => {
renderComponent();

expect((HealthMetricsValueHistoryChart as Mock).mock.calls[0][0]).toMatchObject({
data: valueHistoryData,
volatilityPercent: baseProps.volatilityPercent,
exposure: baseProps.exposure,
});
});
});
Expand All @@ -190,7 +199,7 @@ describe('CommitmentHealthMetrics - tab switching via click', () => {
expect((HealthMetricsDrawdownChart as Mock).mock.calls[0][0]).toMatchObject({
data: drawdownData,
thresholdPercent: baseProps.thresholdPercent,
volatilityPercent: baseProps.volatilityPercent,
exposure: baseProps.exposure,
});
});

Expand All @@ -207,7 +216,7 @@ describe('CommitmentHealthMetrics - tab switching via click', () => {

expect((HealthMetricsFeeGenerationChart as Mock).mock.calls[0][0]).toMatchObject({
data: feeGenerationData,
volatilityPercent: baseProps.volatilityPercent,
exposure: baseProps.exposure,
});
});

Expand All @@ -222,7 +231,7 @@ describe('CommitmentHealthMetrics - tab switching via click', () => {
expect(screen.queryByTestId('drawdown-chart')).not.toBeInTheDocument();
expect(screen.queryByTestId('fee-chart')).not.toBeInTheDocument();

// Compliance chart only ever takes `data` - no thresholdPercent/volatilityPercent.
// Compliance chart only ever takes `data` - no thresholdPercent/exposure.
expect((HealthMetricsComplianceChart as Mock).mock.calls[0][0]).toEqual({ data: complianceData });
});

Expand Down Expand Up @@ -369,16 +378,16 @@ describe('CommitmentHealthMetrics - empty datasets', () => {
// ---------------------------------------------------------------------------

describe('CommitmentHealthMetrics - optional props', () => {
it('renders the drawdown chart without thresholdPercent/volatilityPercent when omitted', async () => {
it('renders the drawdown chart without thresholdPercent/exposure when omitted', async () => {
const user = userEvent.setup();
renderComponent({ thresholdPercent: undefined, volatilityPercent: undefined });
renderComponent({ thresholdPercent: undefined, exposure: undefined });

await user.click(screen.getByRole('button', { name: TAB_LABELS.drawdown }));

expect((HealthMetricsDrawdownChart as Mock).mock.calls[0][0]).toMatchObject({
data: drawdownData,
thresholdPercent: undefined,
volatilityPercent: undefined,
exposure: undefined,
});
});
});
Expand Down
11 changes: 7 additions & 4 deletions src/components/dashboard/HealthMetricsDrawdownChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import {
ReferenceLine,
} from 'recharts';
import VolatilityExposureMeter from '../VolatilityExposureMeter/VolatilityExposureMeter';
import type { CommitmentExposureResult } from '@/utils/exposure';

interface HealthMetricsDrawdownChartProps {
data: Array<{ date: string; drawdownPercent: number }>;
thresholdPercent?: number;
volatilityPercent?: number;
exposure?: CommitmentExposureResult;
}

interface TooltipPayload {
Expand Down Expand Up @@ -46,7 +47,7 @@ const CustomTooltip = ({ active, payload, label }: TooltipPayload) => {
export const HealthMetricsDrawdownChart: React.FC<HealthMetricsDrawdownChartProps> = ({
data,
thresholdPercent,
volatilityPercent,
exposure,
}) => {
return (
<>
Expand Down Expand Up @@ -123,10 +124,12 @@ export const HealthMetricsDrawdownChart: React.FC<HealthMetricsDrawdownChartProp
</div>
</div>

{volatilityPercent !== undefined && (
{exposure && (
<div className="mt-4">
<VolatilityExposureMeter
valuePercent={volatilityPercent}
insufficientData={exposure.status === 'insufficient_data'}
valuePercent={exposure.exposurePercent}
zoneThresholds={exposure.zoneThresholds}
description="Current exposure to volatile assets based on allocation and market conditions."
/>
</div>
Expand Down
11 changes: 7 additions & 4 deletions src/components/dashboard/HealthMetricsFeeGenerationChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ import {
} from 'recharts';

import VolatilityExposureMeter from '../VolatilityExposureMeter/VolatilityExposureMeter';
import type { CommitmentExposureResult } from '@/utils/exposure';

interface HealthMetricsFeeGenerationChartProps {
data: Array<{ date: string; feeAmount: number }>;
volatilityPercent?: number;
exposure?: CommitmentExposureResult;
}

interface TooltipPayload {
Expand Down Expand Up @@ -47,7 +48,7 @@ const CustomTooltip = ({ active, payload, label }: TooltipPayload) => {

export const HealthMetricsFeeGenerationChart: React.FC<HealthMetricsFeeGenerationChartProps> = ({
data,
volatilityPercent,
exposure,
}) => {
return (
<>
Expand Down Expand Up @@ -126,10 +127,12 @@ export const HealthMetricsFeeGenerationChart: React.FC<HealthMetricsFeeGeneratio
</div>
</div>

{volatilityPercent !== undefined && (
{exposure && (
<div className="mt-4">
<VolatilityExposureMeter
valuePercent={volatilityPercent}
insufficientData={exposure.status === 'insufficient_data'}
valuePercent={exposure.exposurePercent}
zoneThresholds={exposure.zoneThresholds}
description="Current exposure to volatile assets based on allocation and market conditions."
/>
</div>
Expand Down
Loading
Loading