diff --git a/src/utils/__tests__/classification.test.ts b/src/utils/__tests__/classification.test.ts index f5f7d6f2..969765e4 100644 --- a/src/utils/__tests__/classification.test.ts +++ b/src/utils/__tests__/classification.test.ts @@ -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'); }); }); diff --git a/src/utils/classification.ts b/src/utils/classification.ts index 3c7e4ec8..5ca62ffb 100644 --- a/src/utils/classification.ts +++ b/src/utils/classification.ts @@ -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'; @@ -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, @@ -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); diff --git a/src/utils/tests/classification.test.ts b/src/utils/tests/classification.test.ts new file mode 100644 index 00000000..969765e4 --- /dev/null +++ b/src/utils/tests/classification.test.ts @@ -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'); + }); +});