Skip to content
Draft
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
35 changes: 35 additions & 0 deletions src/utils/__tests__/classification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,5 +137,40 @@ describe('classifyAtRiskCommitments', () => {
const [result] = classifyAtRiskCommitments([commitment], null);
expect(result.riskCategories).toHaveLength(1);
expect(result.riskCategories[0]).toBe('action_required');
expect(result.riskCategories).toEqual(['action_required']);
expect(result.riskCategories.filter((cat) => cat === 'action_required')).toHaveLength(1);
});

it('deduplicates action_required when all risk criteria are met simultaneously', () => {
const commitment: Commitment = {
...base,
complianceScore: 50,
daysRemaining: 2,
status: 'Violated',
currentDrawdown: '90',
maxLoss: '100',
};
const [result] = classifyAtRiskCommitments([commitment], null);
expect(result.riskCategories).toHaveLength(3);
expect(result.riskCategories).toEqual(['low_compliance', 'maturing_soon', 'action_required']);
expect(result.riskCategories.filter((cat) => cat === 'action_required')).toHaveLength(1);
});

it('respects custom classification thresholds for compliance and days remaining', () => {
const commitment: Commitment = {
...base,
complianceScore: 75,
daysRemaining: 10,
};
const defaultResult = classifyAtRiskCommitments([commitment], null);
expect(defaultResult).toHaveLength(0);

const customResult = classifyAtRiskCommitments([commitment], null, {
complianceScoreThreshold: 80,
daysRemainingThreshold: 12,
});
expect(customResult).toHaveLength(1);
expect(customResult[0].riskCategories).toContain('low_compliance');
expect(customResult[0].riskCategories).toContain('maturing_soon');
});
});
18 changes: 16 additions & 2 deletions src/utils/classification.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Commitment } from '@/lib/types/domain';
import { ProtocolConstants } from '@/utils/protocol';
import type { ProtocolConstants } from '@/utils/protocol';

export type RiskCategory = 'low_compliance' | 'maturing_soon' | 'action_required';

Expand All @@ -15,6 +15,21 @@ export interface ClassificationThresholds {
const DEFAULT_COMPLIANCE_THRESHOLD = 70;
const DEFAULT_DAYS_THRESHOLD = 7;

/**
* Evaluates commitments against risk criteria and assigns deduplicated risk categories.
*
* Risk categories include:
* - 'low_compliance': Compliance score falls below the configured threshold (default: 70).
* - 'maturing_soon': Days remaining until expiration is less than or equal to the threshold (default: 7).
* - 'action_required': Commitment is Violated or current drawdown meets/exceeds 80% of maxLoss.
*
* Ensures riskCategories contains no duplicates even when multiple criteria trigger the same category.
*
* @param commitments - The array of commitments to evaluate.
* @param constants - Protocol constants configuration, or null if unconfigured.
* @param thresholds - Optional custom thresholds for compliance score and days remaining.
* @returns Array of at-risk commitments with unique risk categories, filtering out commitments with no risk.
*/
export function classifyAtRiskCommitments(
commitments: Commitment[],
constants: ProtocolConstants | null,
Expand All @@ -39,7 +54,6 @@ export function classifyAtRiskCommitments(
riskCategories.push('action_required');
}

// If maxLoss is exceeded or getting close
if (c.currentDrawdown && c.maxLoss) {
const drawdown = parseFloat(c.currentDrawdown);
const maxLoss = parseFloat(c.maxLoss);
Expand Down
176 changes: 176 additions & 0 deletions src/utils/tests/classification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { describe, it, expect } from 'vitest';
import { classifyAtRiskCommitments } from '@/utils/classification';
import type { Commitment } from '@/lib/types/domain';

const base: Commitment = {
id: 'c1',
type: 'Balanced',
status: 'Active',
asset: 'XLM',
amount: '1000',
};

describe('classifyAtRiskCommitments', () => {
it('returns empty array when no commitments are at risk', () => {
const safe: Commitment = {
...base,
complianceScore: 90,
daysRemaining: 30,
status: 'Active',
};
const result = classifyAtRiskCommitments([safe], null);
expect(result).toHaveLength(0);
});

it('classifies low compliance score (< 70) as low_compliance', () => {
const commitment: Commitment = { ...base, complianceScore: 69 };
const result = classifyAtRiskCommitments([commitment], null);
expect(result).toHaveLength(1);
expect(result[0].riskCategories).toContain('low_compliance');
});

it('does not classify compliance score of exactly 70 as low_compliance', () => {
const commitment: Commitment = { ...base, complianceScore: 70 };
const result = classifyAtRiskCommitments([commitment], null);
expect(result).toHaveLength(0);
});

it('classifies daysRemaining <= 7 as maturing_soon', () => {
const atBoundary: Commitment = { ...base, daysRemaining: 7 };
const justOver: Commitment = { ...base, id: 'c2', daysRemaining: 8 };
const resultBoundary = classifyAtRiskCommitments([atBoundary], null);
const resultOver = classifyAtRiskCommitments([justOver], null);

expect(resultBoundary).toHaveLength(1);
expect(resultBoundary[0].riskCategories).toContain('maturing_soon');
expect(resultOver).toHaveLength(0);
});

it('classifies Violated status as action_required', () => {
const commitment: Commitment = { ...base, status: 'Violated' };
const result = classifyAtRiskCommitments([commitment], null);
expect(result).toHaveLength(1);
expect(result[0].riskCategories).toContain('action_required');
});

it('classifies drawdown >= 80% of maxLoss as action_required', () => {
const atThreshold: Commitment = {
...base,
currentDrawdown: '80',
maxLoss: '100',
};
const belowThreshold: Commitment = {
...base,
id: 'c2',
currentDrawdown: '79',
maxLoss: '100',
};
const atResult = classifyAtRiskCommitments([atThreshold], null);
const belowResult = classifyAtRiskCommitments([belowThreshold], null);

expect(atResult).toHaveLength(1);
expect(atResult[0].riskCategories).toContain('action_required');
expect(belowResult).toHaveLength(0);
});

it('handles non-numeric drawdown/maxLoss gracefully', () => {
const commitment: Commitment = {
...base,
currentDrawdown: 'N/A',
maxLoss: '100',
};
const result = classifyAtRiskCommitments([commitment], null);
expect(result).toHaveLength(0);
});

it('assigns multiple risk categories when multiple conditions are met', () => {
const commitment: Commitment = {
...base,
complianceScore: 50,
daysRemaining: 3,
status: 'Violated',
};
const result = classifyAtRiskCommitments([commitment], null);
expect(result).toHaveLength(1);
expect(result[0].riskCategories).toContain('low_compliance');
expect(result[0].riskCategories).toContain('maturing_soon');
expect(result[0].riskCategories).toContain('action_required');
});

it('filters out commitments with no risk categories', () => {
const commitments: Commitment[] = [
{ ...base, id: 'c1', complianceScore: 80, daysRemaining: 30, status: 'Active' },
{ ...base, id: 'c2', complianceScore: 50 },
{ ...base, id: 'c3', daysRemaining: 5 },
];
const result = classifyAtRiskCommitments(commitments, null);
expect(result).toHaveLength(2);
expect(result.map((r) => r.id)).toEqual(['c2', 'c3']);
});

it('returns empty array for empty input', () => {
const result = classifyAtRiskCommitments([], null);
expect(result).toHaveLength(0);
});

it('returns exact risk category tokens (pin styling contract)', () => {
const low: Commitment = { ...base, complianceScore: 0 };
const soon: Commitment = { ...base, id: 'c2', daysRemaining: 0 };
const action: Commitment = { ...base, id: 'c3', status: 'Violated' };

const [lowResult] = classifyAtRiskCommitments([low], null);
const [soonResult] = classifyAtRiskCommitments([soon], null);
const [actionResult] = classifyAtRiskCommitments([action], null);

expect(lowResult.riskCategories[0]).toBe('low_compliance');
expect(soonResult.riskCategories[0]).toBe('maturing_soon');
expect(actionResult.riskCategories[0]).toBe('action_required');
});

it('does not duplicate action_required when commitment is both Violated and past 80% drawdown threshold', () => {
const commitment: Commitment = {
...base,
status: 'Violated',
currentDrawdown: '85',
maxLoss: '100',
};
const [result] = classifyAtRiskCommitments([commitment], null);
expect(result.riskCategories).toHaveLength(1);
expect(result.riskCategories[0]).toBe('action_required');
expect(result.riskCategories).toEqual(['action_required']);
expect(result.riskCategories.filter((cat) => cat === 'action_required')).toHaveLength(1);
});

it('deduplicates action_required when all risk criteria are met simultaneously', () => {
const commitment: Commitment = {
...base,
complianceScore: 50,
daysRemaining: 2,
status: 'Violated',
currentDrawdown: '90',
maxLoss: '100',
};
const [result] = classifyAtRiskCommitments([commitment], null);
expect(result.riskCategories).toHaveLength(3);
expect(result.riskCategories).toEqual(['low_compliance', 'maturing_soon', 'action_required']);
expect(result.riskCategories.filter((cat) => cat === 'action_required')).toHaveLength(1);
});

it('respects custom classification thresholds for compliance and days remaining', () => {
const commitment: Commitment = {
...base,
complianceScore: 75,
daysRemaining: 10,
};
const defaultResult = classifyAtRiskCommitments([commitment], null);
expect(defaultResult).toHaveLength(0);

const customResult = classifyAtRiskCommitments([commitment], null, {
complianceScoreThreshold: 80,
daysRemainingThreshold: 12,
});
expect(customResult).toHaveLength(1);
expect(customResult[0].riskCategories).toContain('low_compliance');
expect(customResult[0].riskCategories).toContain('maturing_soon');
});
});