mcp-tenant-credit-scorer
Version:
MCP server for tenant credit scoring based on S&P corporate methodology
407 lines (341 loc) • 13.7 kB
JavaScript
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 } }
}
}
};
}
}