diff --git a/docs/VOLATILITY_EXPOSURE_BINDING.md b/docs/VOLATILITY_EXPOSURE_BINDING.md new file mode 100644 index 00000000..befa189b --- /dev/null +++ b/docs/VOLATILITY_EXPOSURE_BINDING.md @@ -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 diff --git a/src/app/commitments/[id]/page.tsx b/src/app/commitments/[id]/page.tsx index ce9a32fc..0872e2dc 100644 --- a/src/app/commitments/[id]/page.tsx +++ b/src/app/commitments/[id]/page.tsx @@ -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 @@ -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); @@ -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} /> diff --git a/src/components/VolatilityExposureMeter/VolatilityExposureMeter.tsx b/src/components/VolatilityExposureMeter/VolatilityExposureMeter.tsx index 930dd2f3..3c89c85e 100644 --- a/src/components/VolatilityExposureMeter/VolatilityExposureMeter.tsx +++ b/src/components/VolatilityExposureMeter/VolatilityExposureMeter.tsx @@ -1,13 +1,22 @@ '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 { @@ -15,15 +24,11 @@ function clamp(value: number): number { 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) @@ -34,34 +39,36 @@ export default function VolatilityExposureMeter({

Volatility Exposure

- {Math.round(percent)}% + + {insufficientData ? '—' : `${Math.round(percent)}%`} +
-
-
-
+ {!insufficientData && ( +
+
+
+ )}
@@ -70,7 +77,13 @@ export default function VolatilityExposureMeter({ High
- {description && ( + {insufficientData && ( +

+ Insufficient data — not enough value history or drawdown metrics to compute exposure. +

+ )} + + {!insufficientData && description && (

{description}

diff --git a/src/components/VolatilityExposureMeter/__tests__/VolatilityExposureMeter.test.tsx b/src/components/VolatilityExposureMeter/__tests__/VolatilityExposureMeter.test.tsx new file mode 100644 index 00000000..3c5e8470 --- /dev/null +++ b/src/components/VolatilityExposureMeter/__tests__/VolatilityExposureMeter.test.tsx @@ -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(); + + 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(); + + const meter = screen.getByRole('meter'); + expect(meter).not.toHaveAttribute('aria-valuenow'); + expect(meter).toHaveAttribute('aria-valuetext', 'Insufficient data'); + expect(screen.getByText('—')).toBeTruthy(); + }); +}); diff --git a/src/components/dashboard/CommitmentHealthMetrics.test.tsx b/src/components/dashboard/CommitmentHealthMetrics.test.tsx index 99a7367c..27652231 100644 --- a/src/components/dashboard/CommitmentHealthMetrics.test.tsx +++ b/src/components/dashboard/CommitmentHealthMetrics.test.tsx @@ -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> }>) => { @@ -99,6 +100,14 @@ 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, @@ -106,7 +115,7 @@ const baseProps = { feeGenerationData, complianceData, thresholdPercent: 10, - volatilityPercent: 3.2, + exposure: sampleExposure, }; function renderComponent(overrides: Partial = {}) { @@ -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, }); }); }); @@ -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, }); }); @@ -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, }); }); @@ -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 }); }); @@ -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, }); }); }); diff --git a/src/components/dashboard/HealthMetricsDrawdownChart.tsx b/src/components/dashboard/HealthMetricsDrawdownChart.tsx index b637b588..1d9fd8cb 100644 --- a/src/components/dashboard/HealthMetricsDrawdownChart.tsx +++ b/src/components/dashboard/HealthMetricsDrawdownChart.tsx @@ -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 { @@ -46,7 +47,7 @@ const CustomTooltip = ({ active, payload, label }: TooltipPayload) => { export const HealthMetricsDrawdownChart: React.FC = ({ data, thresholdPercent, - volatilityPercent, + exposure, }) => { return ( <> @@ -123,10 +124,12 @@ export const HealthMetricsDrawdownChart: React.FC
- {volatilityPercent !== undefined && ( + {exposure && (
diff --git a/src/components/dashboard/HealthMetricsFeeGenerationChart.tsx b/src/components/dashboard/HealthMetricsFeeGenerationChart.tsx index 78b77b24..a37d04a4 100644 --- a/src/components/dashboard/HealthMetricsFeeGenerationChart.tsx +++ b/src/components/dashboard/HealthMetricsFeeGenerationChart.tsx @@ -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 { @@ -47,7 +48,7 @@ const CustomTooltip = ({ active, payload, label }: TooltipPayload) => { export const HealthMetricsFeeGenerationChart: React.FC = ({ data, - volatilityPercent, + exposure, }) => { return ( <> @@ -126,10 +127,12 @@ export const HealthMetricsFeeGenerationChart: React.FC
- {volatilityPercent !== undefined && ( + {exposure && (
diff --git a/src/components/dashboard/HealthMetricsValueHistoryChart.tsx b/src/components/dashboard/HealthMetricsValueHistoryChart.tsx index 1b6867c8..aeef6304 100644 --- a/src/components/dashboard/HealthMetricsValueHistoryChart.tsx +++ b/src/components/dashboard/HealthMetricsValueHistoryChart.tsx @@ -17,7 +17,7 @@ import { useReducedMotion } from '@/lib/a11y/useReducedMotion'; interface HealthMetricsValueHistoryChartProps { data: Array<{ date: string; currentValue: number; initialAmount?: number }>; - volatilityPercent?: number; + exposure?: CommitmentExposureResult; } interface TooltipPayload { @@ -55,7 +55,7 @@ const CustomTooltip = ({ active, payload, label }: TooltipPayload) => { export const HealthMetricsValueHistoryChart: React.FC = ({ data, - volatilityPercent, + exposure, }) => { const reducedMotion = useReducedMotion(); return ( @@ -139,10 +139,12 @@ export const HealthMetricsValueHistoryChart: React.FC - {volatilityPercent !== undefined && ( + {exposure && (
diff --git a/src/components/dashboard/__tests__/CommitmentHealthMetrics.exposure.test.tsx b/src/components/dashboard/__tests__/CommitmentHealthMetrics.exposure.test.tsx new file mode 100644 index 00000000..a409cc88 --- /dev/null +++ b/src/components/dashboard/__tests__/CommitmentHealthMetrics.exposure.test.tsx @@ -0,0 +1,96 @@ +/** + * @vitest-environment happy-dom + */ + +import React from 'react'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { HealthMetricsValueHistoryChart } from '../HealthMetricsValueHistoryChart'; +import { + computeCommitmentExposure, + type CommitmentExposureResult, +} from '@/utils/exposure'; + +beforeAll(() => { + global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; +}); + +vi.mock('recharts', async () => { + const actual = await vi.importActual('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }; +}); + +const valueData = [ + { date: 'Jan 1', currentValue: 1000, initialAmount: 1000 }, + { date: 'Jan 2', currentValue: 1010, initialAmount: 1000 }, +]; + +function renderWithExposure(exposure: CommitmentExposureResult) { + return render( + , + ); +} + +describe('VolatilityExposureMeter live binding', () => { + afterEach(() => { + cleanup(); + }); + + it('shows insufficient data state when exposure cannot be computed', () => { + const exposure = computeCommitmentExposure({ + maxLossPercent: 8, + valueHistory: [{ date: 'Jan 1', currentValue: 1000 }], + }); + + renderWithExposure(exposure); + + expect(screen.getByText(/Insufficient data/i)).toBeTruthy(); + expect(screen.getByRole('meter')).toHaveAttribute('aria-valuetext', 'Insufficient data'); + }); + + it('binds computed low-zone exposure to the meter', () => { + const exposure = computeCommitmentExposure({ + maxLossPercent: 10, + drawdownHistory: [{ date: 'Jan 1', drawdownPercent: 0.02 }], + }); + + renderWithExposure(exposure); + + const meter = screen.getByRole('meter'); + expect(meter).toHaveAttribute('aria-valuenow', '20'); + expect(meter).toHaveAttribute('aria-valuetext', '20 percent, low'); + }); + + it('binds computed medium-zone exposure at zone boundaries', () => { + const exposure = computeCommitmentExposure({ + maxLossPercent: 10, + drawdownHistory: [{ date: 'Jan 1', drawdownPercent: 0.034 }], + }); + + renderWithExposure(exposure); + + const meter = screen.getByRole('meter'); + expect(meter).toHaveAttribute('aria-valuetext', '34 percent, medium'); + }); + + it('binds computed high-zone exposure to the meter', () => { + const exposure = computeCommitmentExposure({ + maxLossPercent: 8, + drawdownHistory: [{ date: 'Jan 1', drawdownPercent: 0.08 }], + }); + + renderWithExposure(exposure); + + const meter = screen.getByRole('meter'); + expect(meter).toHaveAttribute('aria-valuetext', '100 percent, high'); + }); +}); diff --git a/src/components/dashboard/__tests__/CommitmentHealthMetrics.lazy.test.tsx b/src/components/dashboard/__tests__/CommitmentHealthMetrics.lazy.test.tsx index d8a07677..32a7d6c4 100644 --- a/src/components/dashboard/__tests__/CommitmentHealthMetrics.lazy.test.tsx +++ b/src/components/dashboard/__tests__/CommitmentHealthMetrics.lazy.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import CommitmentHealthMetrics from '../CommitmentHealthMetrics'; +import { computeCommitmentExposure } from '@/utils/exposure'; const defaultProps = { commitmentId: 'commitment-1', @@ -14,7 +15,10 @@ const defaultProps = { valueHistoryData: [{ date: '2026-01', currentValue: 1000, initialAmount: 900 }], feeGenerationData: [{ date: '2026-01', feeAmount: 50 }], thresholdPercent: 0.25, - volatilityPercent: 30, + exposure: computeCommitmentExposure({ + maxLossPercent: 10, + drawdownHistory: [{ date: '2026-01', drawdownPercent: 0.03 }], + }), }; const renderMetrics = (overrides: Partial = {}) => { diff --git a/src/components/dashboard/__tests__/HealthMetricsCharts.test.tsx b/src/components/dashboard/__tests__/HealthMetricsCharts.test.tsx index 0d188003..d8c7a41a 100644 --- a/src/components/dashboard/__tests__/HealthMetricsCharts.test.tsx +++ b/src/components/dashboard/__tests__/HealthMetricsCharts.test.tsx @@ -35,6 +35,12 @@ import { HealthMetricsComplianceChart } from '../HealthMetricsComplianceChart'; import { HealthMetricsDrawdownChart } from '../HealthMetricsDrawdownChart'; import { HealthMetricsFeeGenerationChart } from '../HealthMetricsFeeGenerationChart'; import { HealthMetricsValueHistoryChart } from '../HealthMetricsValueHistoryChart'; +import { computeCommitmentExposure } from '@/utils/exposure'; + +const sampleExposure = computeCommitmentExposure({ + maxLossPercent: 10, + drawdownHistory: [{ date: 'Jan', drawdownPercent: 0.04 }], +}); // --------------------------------------------------------------------------- // Shared test data @@ -147,12 +153,12 @@ describe('HealthMetricsDrawdownChart', () => { expect(container.firstChild).toBeTruthy(); }); - it('renders VolatilityExposureMeter when volatilityPercent is provided', () => { - render(); + it('renders VolatilityExposureMeter when exposure is provided', () => { + render(); expect(screen.getByTestId('volatility-meter')).toBeTruthy(); }); - it('does not render VolatilityExposureMeter when volatilityPercent is omitted', () => { + it('does not render VolatilityExposureMeter when exposure is omitted', () => { render(); expect(screen.queryByTestId('volatility-meter')).toBeNull(); }); @@ -224,12 +230,12 @@ describe('HealthMetricsFeeGenerationChart', () => { expect(container.firstChild).toBeTruthy(); }); - it('renders VolatilityExposureMeter when volatilityPercent is provided', () => { - render(); + it('renders VolatilityExposureMeter when exposure is provided', () => { + render(); expect(screen.getByTestId('volatility-meter')).toBeTruthy(); }); - it('does not render VolatilityExposureMeter when volatilityPercent is omitted', () => { + it('does not render VolatilityExposureMeter when exposure is omitted', () => { render(); expect(screen.queryByTestId('volatility-meter')).toBeNull(); }); @@ -310,12 +316,12 @@ describe('HealthMetricsValueHistoryChart', () => { expect(container.firstChild).toBeTruthy(); }); - it('renders VolatilityExposureMeter when volatilityPercent is provided', () => { - render(); + it('renders VolatilityExposureMeter when exposure is provided', () => { + render(); expect(screen.getByTestId('volatility-meter')).toBeTruthy(); }); - it('does not render VolatilityExposureMeter when volatilityPercent is omitted', () => { + it('does not render VolatilityExposureMeter when exposure is omitted', () => { render(); expect(screen.queryByTestId('volatility-meter')).toBeNull(); }); diff --git a/src/utils/__tests__/exposure.test.ts b/src/utils/__tests__/exposure.test.ts new file mode 100644 index 00000000..a0337211 --- /dev/null +++ b/src/utils/__tests__/exposure.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; + +import { + EXPOSURE_ZONE_THRESHOLDS, + computeCommitmentExposure, + computeDrawdownThresholdPercent, + getExposureLevel, +} from '../exposure'; + +describe('getExposureLevel', () => { + it('classifies low, medium, and high zones', () => { + expect(getExposureLevel(10)).toBe('low'); + expect(getExposureLevel(50)).toBe('medium'); + expect(getExposureLevel(90)).toBe('high'); + }); + + it('handles boundary values between zones', () => { + expect(getExposureLevel(33)).toBe('low'); + expect(getExposureLevel(34)).toBe('medium'); + expect(getExposureLevel(66)).toBe('medium'); + expect(getExposureLevel(67)).toBe('high'); + }); +}); + +describe('computeDrawdownThresholdPercent', () => { + it('converts max loss percent to a 0–1 chart threshold', () => { + expect(computeDrawdownThresholdPercent(8)).toBe(0.08); + expect(computeDrawdownThresholdPercent(50)).toBe(0.5); + }); +}); + +describe('computeCommitmentExposure', () => { + it('returns insufficient_data when metrics are missing or invalid', () => { + expect( + computeCommitmentExposure({ + maxLossPercent: 8, + valueHistory: [{ date: 'Jan 1', currentValue: 1000 }], + }).status, + ).toBe('insufficient_data'); + + expect( + computeCommitmentExposure({ + maxLossPercent: 0, + drawdownHistory: [{ date: 'Jan 1', drawdownPercent: 0.1 }], + }).status, + ).toBe('insufficient_data'); + + expect( + computeCommitmentExposure({ + maxLossPercent: 8, + }).status, + ).toBe('insufficient_data'); + }); + + it('computes low exposure from drawdown-only data', () => { + const result = computeCommitmentExposure({ + maxLossPercent: 10, + drawdownHistory: [{ date: 'Jan 1', drawdownPercent: 0.02 }], + }); + + expect(result.status).toBe('ok'); + expect(result.exposurePercent).toBe(20); + expect(result.level).toBe('low'); + expect(result.drawdownThresholdPercent).toBe(0.1); + expect(result.zoneThresholds).toEqual(EXPOSURE_ZONE_THRESHOLDS); + }); + + it('computes medium exposure in the medium zone', () => { + const result = computeCommitmentExposure({ + maxLossPercent: 8, + drawdownHistory: [{ date: 'Jan 1', drawdownPercent: 0.05 }], + }); + + expect(result.status).toBe('ok'); + expect(result.exposurePercent).toBe(62.5); + expect(result.level).toBe('medium'); + }); + + it('computes high exposure when drawdown nears max loss', () => { + const result = computeCommitmentExposure({ + maxLossPercent: 8, + drawdownHistory: [{ date: 'Jan 1', drawdownPercent: 0.08 }], + }); + + expect(result.status).toBe('ok'); + expect(result.exposurePercent).toBe(100); + expect(result.level).toBe('high'); + }); + + it('handles boundary values between exposure zones (drawdown-only)', () => { + const atLowBoundary = computeCommitmentExposure({ + maxLossPercent: 10, + drawdownHistory: [{ date: 'Jan 1', drawdownPercent: 0.033 }], + }); + expect(atLowBoundary.exposurePercent).toBe(33); + expect(atLowBoundary.level).toBe('low'); + + const atMediumLower = computeCommitmentExposure({ + maxLossPercent: 10, + drawdownHistory: [{ date: 'Jan 1', drawdownPercent: 0.034 }], + }); + expect(atMediumLower.exposurePercent).toBe(34); + expect(atMediumLower.level).toBe('medium'); + + const atMediumUpper = computeCommitmentExposure({ + maxLossPercent: 10, + drawdownHistory: [{ date: 'Jan 1', drawdownPercent: 0.066 }], + }); + expect(atMediumUpper.exposurePercent).toBe(66); + expect(atMediumUpper.level).toBe('medium'); + + const atHighLower = computeCommitmentExposure({ + maxLossPercent: 10, + drawdownHistory: [{ date: 'Jan 1', drawdownPercent: 0.067 }], + }); + expect(atHighLower.exposurePercent).toBe(67); + expect(atHighLower.level).toBe('high'); + }); + + it('computes exposure from value history volatility when drawdown is absent', () => { + const lowVolatility = computeCommitmentExposure({ + maxLossPercent: 10, + valueHistory: [ + { date: 'Jan 1', currentValue: 1000 }, + { date: 'Jan 2', currentValue: 1010 }, + ], + }); + expect(lowVolatility.status).toBe('ok'); + expect(lowVolatility.level).toBe('low'); + + const highVolatility = computeCommitmentExposure({ + maxLossPercent: 10, + valueHistory: [ + { date: 'Jan 1', currentValue: 1000 }, + { date: 'Jan 2', currentValue: 1200 }, + ], + }); + expect(highVolatility.status).toBe('ok'); + expect(highVolatility.level).toBe('high'); + }); + + it('ignores invalid drawdown readings and falls back to value history', () => { + const result = computeCommitmentExposure({ + maxLossPercent: 10, + drawdownHistory: [{ date: 'Jan 1', drawdownPercent: Number.NaN }], + valueHistory: [ + { date: 'Jan 1', currentValue: 1000 }, + { date: 'Jan 2', currentValue: 1010 }, + ], + }); + + expect(result.status).toBe('ok'); + expect(result.level).toBe('low'); + }); + + it('skips invalid value history points when computing volatility', () => { + const result = computeCommitmentExposure({ + maxLossPercent: 10, + valueHistory: [ + { date: 'Jan 1', currentValue: 0 }, + { date: 'Jan 2', currentValue: 1000 }, + { date: 'Jan 3', currentValue: Number.NaN }, + { date: 'Jan 4', currentValue: 1010 }, + ], + }); + + expect(result.status).toBe('ok'); + expect(result.exposurePercent).toBe(20); + }); + + it('returns zero drawdown threshold when max loss is invalid', () => { + expect(computeDrawdownThresholdPercent(Number.NaN)).toBe(0); + }); + + it('combines drawdown and value history when both are available', () => { + const result = computeCommitmentExposure({ + maxLossPercent: 10, + drawdownHistory: [{ date: 'Jan 1', drawdownPercent: 0.02 }], + valueHistory: [ + { date: 'Jan 1', currentValue: 1000 }, + { date: 'Jan 2', currentValue: 1050 }, + ], + }); + + expect(result.status).toBe('ok'); + expect(result.exposurePercent).toBe(52); + expect(result.level).toBe('medium'); + }); +}); diff --git a/src/utils/exposure.ts b/src/utils/exposure.ts new file mode 100644 index 00000000..cca483e6 --- /dev/null +++ b/src/utils/exposure.ts @@ -0,0 +1,169 @@ +import type { CommitmentLimits } from '@/utils/protocol'; + +export const EXPOSURE_ZONE_THRESHOLDS = { + lowMax: 33, + mediumMax: 66, +} as const; + +export type ExposureLevel = 'low' | 'medium' | 'high'; + +export interface ValueHistoryPoint { + date: string; + currentValue: number; + initialAmount?: number; +} + +export interface DrawdownPoint { + date: string; + drawdownPercent: number; +} + +export interface CommitmentExposureInput { + valueHistory?: ValueHistoryPoint[]; + drawdownHistory?: DrawdownPoint[]; + maxLossPercent: number; + protocolMaxLossPercentCeiling?: number; +} + +export interface CommitmentExposureResult { + status: 'ok' | 'insufficient_data'; + exposurePercent?: number; + level?: ExposureLevel; + /** Drawdown chart reference line on 0–1 scale (max loss as fraction). */ + drawdownThresholdPercent?: number; + zoneThresholds: typeof EXPOSURE_ZONE_THRESHOLDS; +} + +const DRAWDOWN_WEIGHT = 0.6; +const VOLATILITY_WEIGHT = 0.4; +/** Scales mean absolute return (0–1) to a 0–100 exposure contribution. */ +const VOLATILITY_RETURN_SCALE = 20; +const DEFAULT_PROTOCOL_MAX_LOSS_CEILING = 100; + +export function getExposureLevel( + percent: number, + thresholds: typeof EXPOSURE_ZONE_THRESHOLDS = EXPOSURE_ZONE_THRESHOLDS, +): ExposureLevel { + if (percent <= thresholds.lowMax) return 'low'; + if (percent <= thresholds.mediumMax) return 'medium'; + return 'high'; +} + +export function computeDrawdownThresholdPercent(maxLossPercent: number): number { + if (!Number.isFinite(maxLossPercent) || maxLossPercent <= 0) return 0; + return maxLossPercent / 100; +} + +function latestDrawdownFraction(drawdownHistory: DrawdownPoint[]): number | null { + if (!drawdownHistory?.length) return null; + + const latest = drawdownHistory[drawdownHistory.length - 1]; + if (!Number.isFinite(latest.drawdownPercent) || latest.drawdownPercent < 0) { + return null; + } + + return latest.drawdownPercent; +} + +function computeDrawdownExposurePercent( + drawdownFraction: number, + maxLossPercent: number, +): number { + const drawdownPercent = drawdownFraction * 100; + return Math.min(100, Math.max(0, (drawdownPercent / maxLossPercent) * 100)); +} + +function computeVolatilityExposurePercent( + values: number[], + protocolMaxLossPercentCeiling: number, +): number | null { + if (values.length < 2) return null; + + const returns: number[] = []; + for (let i = 1; i < values.length; i += 1) { + const previous = values[i - 1]; + const current = values[i]; + if (!Number.isFinite(previous) || previous <= 0 || !Number.isFinite(current)) { + continue; + } + returns.push(Math.abs((current - previous) / previous)); + } + + if (returns.length === 0) return null; + + const meanAbsReturn = returns.reduce((sum, value) => sum + value, 0) / returns.length; + const scale = (VOLATILITY_RETURN_SCALE * 100) / protocolMaxLossPercentCeiling; + return Math.min(100, Math.max(0, meanAbsReturn * 100 * scale)); +} + +function combineExposure( + drawdownExposure: number | null, + volatilityExposure: number | null, +): number | null { + if (drawdownExposure !== null && volatilityExposure !== null) { + return ( + DRAWDOWN_WEIGHT * drawdownExposure + VOLATILITY_WEIGHT * volatilityExposure + ); + } + if (drawdownExposure !== null) return drawdownExposure; + if (volatilityExposure !== null) return volatilityExposure; + return null; +} + +export function computeCommitmentExposure( + input: CommitmentExposureInput, + commitmentLimits?: Pick, +): CommitmentExposureResult { + const zoneThresholds = EXPOSURE_ZONE_THRESHOLDS; + const ceiling = + input.protocolMaxLossPercentCeiling ?? + commitmentLimits?.maxLossPercentCeiling ?? + DEFAULT_PROTOCOL_MAX_LOSS_CEILING; + + const drawdownThresholdPercent = computeDrawdownThresholdPercent(input.maxLossPercent); + + if (!Number.isFinite(input.maxLossPercent) || input.maxLossPercent <= 0) { + return { + status: 'insufficient_data', + drawdownThresholdPercent, + zoneThresholds, + }; + } + + const drawdownFraction = input.drawdownHistory + ? latestDrawdownFraction(input.drawdownHistory) + : null; + const drawdownExposure = + drawdownFraction !== null + ? computeDrawdownExposurePercent(drawdownFraction, input.maxLossPercent) + : null; + + const values = + input.valueHistory + ?.map((point) => point.currentValue) + .filter((value) => Number.isFinite(value)) ?? []; + const volatilityExposure = + values.length >= 2 + ? computeVolatilityExposurePercent(values, ceiling) + : null; + + const exposurePercent = combineExposure(drawdownExposure, volatilityExposure); + + if (exposurePercent === null || !Number.isFinite(exposurePercent)) { + return { + status: 'insufficient_data', + drawdownThresholdPercent, + zoneThresholds, + }; + } + + const rounded = Math.round(exposurePercent * 10) / 10; + + return { + status: 'ok', + exposurePercent: rounded, + level: getExposureLevel(rounded, zoneThresholds), + drawdownThresholdPercent, + zoneThresholds, + }; +}