UNPKG

mcp-tenant-credit-scorer

Version:

MCP server for tenant credit scoring based on S&P corporate methodology

407 lines (341 loc) 13.7 kB
import { readFile } from 'fs/promises'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); export class ScoreCalculator { constructor() { this.scoringTables = null; this.initialized = false; } async initialize() { if (this.initialized) return; try { const dataPath = join(__dirname, '..', 'data', 'scoring-tables.json'); const data = await readFile(dataPath, 'utf-8'); this.scoringTables = JSON.parse(data); this.initialized = true; } catch (error) { console.error('Failed to load scoring tables:', error); // Use embedded scoring tables as fallback this.scoringTables = this.getEmbeddedScoringTables(); this.initialized = true; } } async calculateAllScores(data) { await this.initialize(); const { financials, industry, companyInfo } = data; // Calculate individual component scores const scores = { industryRisk: this.scoreIndustryRisk(industry), competitivePosition: await this.scoreCompetitivePosition(data), financialRisk: this.scoreFinancialRisk(financials, industry), liquidity: this.scoreLiquidity(financials), management: this.scoreManagement(data) }; // Calculate weighted score const weightedScore = this.calculateWeightedScore(scores); // Convert to Faropoint score const faropointScore = this.convertToFaropointScore(weightedScore); // Apply revenue adjustment const adjustedScore = this.applyRevenueAdjustment(faropointScore, financials.revenue); return { componentScores: scores, weightedScore, basefaropointScore: faropointScore, revenueAdjustment: adjustedScore.adjustment, finalFaropointScore: adjustedScore.score, bondEquivalent: this.getBondEquivalent(adjustedScore.score), defaultProbability: this.getDefaultProbability(adjustedScore.score) }; } scoreIndustryRisk(industry) { return { score: industry.industryRisk.score, riskLevel: industry.industryRisk.riskLevel, cyclicality: industry.industryRisk.cyclicality, rationale: `Industry classified as ${industry.primaryIndustry} with ${industry.industryRisk.cyclicality} cyclicality` }; } async scoreCompetitivePosition(data) { const { industry, companyInfo, financials } = data; // Get CPGP weights const cpgp = industry.defaultCPGP; const weights = this.getCPGPWeights(cpgp); // Score subfactors const subfactors = { competitiveAdvantage: this.scoreCompetitiveAdvantage(data), scaleScope: this.scoreScaleScope(data), operatingEfficiency: this.scoreOperatingEfficiency(financials), profitability: this.scoreProfitability(financials) }; // Calculate weighted score const weightedScore = (subfactors.competitiveAdvantage.score * weights.advantage) + (subfactors.scaleScope.score * weights.scale) + (subfactors.operatingEfficiency.score * weights.efficiency); return { score: Math.round(weightedScore), cpgp: cpgp, subfactors: subfactors, rationale: `Scored using ${cpgp} profile with weighted average of subfactors` }; } scoreFinancialRisk(financials, industry) { const volatility = this.getVolatilityTable(industry.industryRisk.score); // Primary metric: EBITDA to Interest Coverage const ebitdaToInterest = financials.ebitdaToInterest || 0; // Secondary check: Debt to EBITDA const debtToEbitda = financials.netDebtToEbitda || 999; // Get score from volatility table let score = 1; const thresholds = this.scoringTables.financialRisk[volatility]; for (const [scoreLevel, metrics] of Object.entries(thresholds)) { if (ebitdaToInterest >= metrics.ebitdaToInterest.min) { score = parseInt(scoreLevel); // Verify with debt/EBITDA if available if (debtToEbitda < metrics.debtToEbitda.max) { break; } else { // Conflict - use lower score score = Math.max(1, score - 1); break; } } } return { score, volatilityTable: volatility, ebitdaToInterest, debtToEbitda, rationale: `${volatility} volatility table applied based on industry risk` }; } scoreLiquidity(financials) { const currentRatio = financials.currentRatio || 0; let score = 1; if (currentRatio > 1.3) score = 5; else if (currentRatio > 1.0) score = 4; else if (currentRatio >= 0.9) score = 3; else if (currentRatio >= 0.8) score = 2; else score = 1; return { score, currentRatio, rationale: `Current ratio of ${currentRatio.toFixed(2)} indicates ${this.getLiquidityDesignation(score)} liquidity` }; } scoreManagement(data) { // Default to neutral without specific information const { companyInfo, financials } = data; let score = 3; // Neutral default const factors = []; // Check for positive indicators if (financials.auditedFinancials) { score = Math.min(5, score + 1); factors.push('Audited financials'); } if (companyInfo.yearsInBusiness > 10) { score = Math.min(5, score + 1); factors.push('Established business'); } // Check for negative indicators if (financials.recentManagementChange) { score = Math.max(1, score - 1); factors.push('Recent management change'); } return { score, factors, rationale: factors.length > 0 ? factors.join(', ') : 'Limited information available' }; } scoreCompetitiveAdvantage(data) { // Simplified scoring based on available data const { companyInfo, financials } = data; let score = 3; // Default to balanced // Check for positive indicators if (financials.ebitdaMargin > 20) score = Math.min(5, score + 1); if (companyInfo.marketLeader) score = Math.min(5, score + 1); if (companyInfo.proprietaryTechnology) score = Math.min(5, score + 1); // Check for negative indicators if (financials.ebitdaMargin < 5) score = Math.max(1, score - 1); if (companyInfo.highCompetition) score = Math.max(1, score - 1); return { score, rationale: 'Based on margin profile and market position' }; } scoreScaleScope(data) { const { financials, companyInfo } = data; let score = 3; // Default // Revenue-based scoring if (financials.revenue > 100000000) score = 4; else if (financials.revenue > 50000000) score = 3; else if (financials.revenue > 10000000) score = 2; else score = 1; // Adjust for concentration if (companyInfo.customerConcentration > 40) score = Math.max(1, score - 2); else if (companyInfo.customerConcentration > 25) score = Math.max(1, score - 1); return { score, rationale: 'Based on revenue scale and concentration' }; } scoreOperatingEfficiency(financials) { const margin = financials.ebitdaMargin || 0; let score = 1; if (margin > 20) score = 5; else if (margin > 15) score = 4; else if (margin > 10) score = 3; else if (margin > 5) score = 2; else score = 1; return { score, rationale: `EBITDA margin of ${margin.toFixed(1)}%` }; } scoreProfitability(financials) { // Similar to operating efficiency but considers trends const score = this.scoreOperatingEfficiency(financials).score; return { score, rationale: 'Based on profitability metrics' }; } getCPGPWeights(cpgp) { const weights = { 'Services & Product Focus': { advantage: 0.45, scale: 0.30, efficiency: 0.25 }, 'Product Focus/Scale Driven': { advantage: 0.35, scale: 0.50, efficiency: 0.15 }, 'Capital or Asset Focus': { advantage: 0.30, scale: 0.30, efficiency: 0.40 }, 'Commodity Focus/Cost Driven': { advantage: 0.15, scale: 0.35, efficiency: 0.50 }, 'Commodity Focus/Scale Driven': { advantage: 0.10, scale: 0.55, efficiency: 0.35 } }; return weights[cpgp] || weights['Services & Product Focus']; } getVolatilityTable(industryRiskScore) { if (industryRiskScore >= 4) return 'low'; if (industryRiskScore === 3) return 'standard'; return 'high'; } calculateWeightedScore(scores) { const weights = { industryRisk: 0.20, competitivePosition: 0.20, financialRisk: 0.40, liquidity: 0.10, management: 0.10 }; let totalScore = 0; for (const [component, weight] of Object.entries(weights)) { totalScore += scores[component].score * weight; } return totalScore; } convertToFaropointScore(weightedScore) { // Linear interpolation between ranges if (weightedScore >= 4.5) return 9.0 + (weightedScore - 4.5) * 2; if (weightedScore >= 4.0) return 8.0 + (weightedScore - 4.0) * 2; if (weightedScore >= 3.5) return 7.0 + (weightedScore - 3.5) * 2; if (weightedScore >= 3.0) return 6.0 + (weightedScore - 3.0) * 2; if (weightedScore >= 2.5) return 5.0 + (weightedScore - 2.5) * 2; if (weightedScore >= 2.0) return 4.0 + (weightedScore - 2.0) * 2; if (weightedScore >= 1.5) return 3.0 + (weightedScore - 1.5) * 2; return 2.0 + (weightedScore - 1.0) * 2; } applyRevenueAdjustment(score, revenue) { if (!revenue || revenue === 0) { return { score, adjustment: 0 }; } let adjustmentPercent = 0; if (revenue < 100000000 && score > 3.5) { // Small company penalty let basePenalty = 0; if (revenue < 5000000) basePenalty = 0.10; else if (revenue < 10000000) basePenalty = 0.08; else if (revenue < 25000000) basePenalty = 0.06; else if (revenue < 50000000) basePenalty = 0.04; else basePenalty = 0.02; adjustmentPercent = -basePenalty * Math.min((score - 3.5), 6.5) * 8; const maxAdjustment = -0.50 + 0.05 * (score - 1.0); adjustmentPercent = Math.max(adjustmentPercent, maxAdjustment); } else if (revenue > 1000000000) { // Large company bonus const maxBonus = revenue > 10000000000 ? 0.48 : 0.24; adjustmentPercent = maxBonus * Math.exp(-0.15 * Math.pow(score - 1.5, 2)); } const adjustedScore = score * (1 + adjustmentPercent); return { score: Math.max(2.0, Math.min(10.0, adjustedScore)), adjustment: adjustmentPercent }; } getBondEquivalent(faropointScore) { if (faropointScore >= 9.0) return 'AAA to AA'; if (faropointScore >= 8.0) return 'A+ to A-'; if (faropointScore >= 7.0) return 'BBB+ to BBB-'; if (faropointScore >= 6.0) return 'BB+ to BB-'; if (faropointScore >= 5.0) return 'B+ to B-'; if (faropointScore >= 4.0) return 'CCC+ to CCC'; if (faropointScore >= 3.0) return 'CC'; return 'C to D'; } getDefaultProbability(faropointScore) { const probabilities = { 10: { oneYear: '0.00-0.02%', fiveYear: '0.01-0.10%' }, 9: { oneYear: '0.02-0.05%', fiveYear: '0.10-0.25%' }, 8: { oneYear: '0.02-0.05%', fiveYear: '0.10-0.25%' }, 7: { oneYear: '0.05-0.15%', fiveYear: '0.25-0.75%' }, 6: { oneYear: '0.15-0.60%', fiveYear: '0.75-3.00%' }, 5: { oneYear: '0.60-3.18%', fiveYear: '3.00-15.00%' }, 4: { oneYear: '3.18-14.87%', fiveYear: '15.00-50.00%' }, 3: { oneYear: '14.87-26.55%', fiveYear: '50.00-70.00%' }, 2: { oneYear: '>26.55%', fiveYear: '>70.00%' } }; const roundedScore = Math.round(faropointScore); return probabilities[roundedScore] || probabilities[2]; } getLiquidityDesignation(score) { const designations = { 5: 'strong', 4: 'adequate', 3: 'moderate', 2: 'weak', 1: 'very weak' }; return designations[score] || 'unknown'; } async scoreComponent(component, data) { await this.initialize(); switch (component) { case 'industry': return this.scoreIndustryRisk(data.industry); case 'competitive': return await this.scoreCompetitivePosition(data); case 'financial': return this.scoreFinancialRisk(data.financials, data.industry); case 'liquidity': return this.scoreLiquidity(data.financials); case 'management': return this.scoreManagement(data); default: throw new Error(`Unknown component: ${component}`); } } getEmbeddedScoringTables() { // Simplified embedded scoring tables return { financialRisk: { low: { 5: { ebitdaToInterest: { min: 8.0 }, debtToEbitda: { max: 2.0 } }, 4: { ebitdaToInterest: { min: 6.0 }, debtToEbitda: { max: 3.0 } }, 3: { ebitdaToInterest: { min: 4.0 }, debtToEbitda: { max: 4.0 } }, 2: { ebitdaToInterest: { min: 2.5 }, debtToEbitda: { max: 5.0 } }, 1: { ebitdaToInterest: { min: 0 }, debtToEbitda: { max: 999 } } }, standard: { 5: { ebitdaToInterest: { min: 12.0 }, debtToEbitda: { max: 1.5 } }, 4: { ebitdaToInterest: { min: 8.0 }, debtToEbitda: { max: 2.0 } }, 3: { ebitdaToInterest: { min: 5.0 }, debtToEbitda: { max: 3.0 } }, 2: { ebitdaToInterest: { min: 3.0 }, debtToEbitda: { max: 4.0 } }, 1: { ebitdaToInterest: { min: 0 }, debtToEbitda: { max: 999 } } }, high: { 5: { ebitdaToInterest: { min: 15.0 }, debtToEbitda: { max: 1.75 } }, 4: { ebitdaToInterest: { min: 10.0 }, debtToEbitda: { max: 2.5 } }, 3: { ebitdaToInterest: { min: 6.0 }, debtToEbitda: { max: 3.5 } }, 2: { ebitdaToInterest: { min: 3.5 }, debtToEbitda: { max: 4.5 } }, 1: { ebitdaToInterest: { min: 0 }, debtToEbitda: { max: 999 } } } } }; } }