Company Comparison
compare_companiesCompare up to 5 companies across valuation, profitability, financial health, growth, and analyst ratings. Returns derived rankings to identify top performers in each dimension for investment decisions.
Instructions
Side-by-side comparison of 2-5 companies across price, valuation (P/E, P/B, P/S, EV/EBITDA, DCF), profitability (margins, ROE, ROA, ROIC), financial health (D/E, current ratio, interest coverage), growth (revenue and earnings YoY), dividends, and analyst ratings. Returns derived rankings showing which company leads each dimension — lowest_pe, highest_margin, strongest_balance_sheet, best_growth, most_undervalued, highest_rated. Use this for investment comparisons, competitive analysis, or evaluating alternatives in the same sector.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| symbols | Yes | 2-5 stock ticker symbols to compare (e.g., ["AAPL", "MSFT", "GOOGL"]) |
Output Schema
| Name | Required | Description | Default |
|---|---|---|---|
| symbols_compared | Yes | ||
| comparison_date | Yes | ||
| companies | Yes | ||
| rankings | Yes | ||
| meta | Yes |
Implementation Reference
- src/tools/compare-companies.ts:320-384 (handler)Main export function `compareCompanies` — the core tool handler. Takes an array of 2-5 ticker symbols, fetches batch quotes with fallback, then calls `fetchCompanyData` in parallel for each symbol, derives rankings, and returns a structured CompareCompaniesResult.
export async function compareCompanies( symbols: string[], client?: FmpClient, ): Promise<CompareCompaniesResult> { const fmp = client ?? new FmpClient(); const normalized = symbols.map((s) => s.trim().toUpperCase()); // 1. Batch quote with per-symbol fallback on 402/empty. // FMP moved /stable/batch-quote behind a paywall on free tier; withBatchFallback // degrades gracefully to per-symbol getQuote calls so the comparison still // returns a complete response instead of failing the whole tool. const { results: quotes, diag: quoteDiag } = await withBatchFallback<string, FmpQuote>( normalized, async (syms) => { try { return await fmp.getBatchQuote(syms); } catch { return null; } }, async (sym) => { try { return await fmp.getQuote(sym); } catch { return null; } }, { concurrency: 5 }, ); // When fallback fires, the initial (failed) batch call still consumed a // network round-trip, so count it alongside the per-item attempts. let totalApiCalls = quoteDiag.usedFallback ? 1 + (quoteDiag.perItemCount ?? normalized.length) : 1; // Map quotes by symbol for fast lookup const quoteMap = new Map<string, FmpQuote>(); for (const q of quotes) { quoteMap.set(q.symbol, q); } // 2. Fetch per-symbol data in parallel const results = await Promise.all( normalized.map((sym) => fetchCompanyData(sym, quoteMap.get(sym), fmp)), ); const companies = results.map((r) => r.comparison); totalApiCalls += results.reduce((sum, r) => sum + r.apiCalls, 0); // 3. Derive rankings const rankings = deriveRankings(companies); return { symbols_compared: normalized, comparison_date: new Date().toISOString(), companies, rankings, meta: { source: 'Toolstem via Financial Modeling Prep', timestamp: new Date().toISOString(), data_delay: 'Real-time during market hours', api_calls_made: totalApiCalls, }, }; } - `fetchCompanyData` — helper that fetches all per-symbol data (profile, DCF, rating, key metrics, ratios, income statement) in parallel and assembles a CompanyComparison object with price, valuation, profitability, financial health, growth, dividend, and rating sections.
async function fetchCompanyData( symbol: string, quote: FmpQuote | undefined, fmp: FmpClient, ): Promise<{ comparison: CompanyComparison; apiCalls: number }> { let apiCalls = 0; // Fire all per-symbol requests in parallel const [profile, dcf, rating, keyMetrics, ratios, income] = await Promise.all([ fmp.getProfile(symbol).catch(() => null), fmp.getDCF(symbol).catch(() => null), fmp.getRating(symbol).catch(() => null), fmp.getKeyMetrics(symbol, 'annual').catch(() => null), fmp.getFinancialRatios(symbol, 'annual').catch(() => null), fmp.getIncomeStatement(symbol, 'annual').catch(() => null), ]); apiCalls += 6; // Price section (prefer batch quote, fall back to profile) const current = safeNumber(quote?.price) ?? safeNumber(profile?.price); const changePercent = safeNumber(quote?.changesPercentage); const yearHigh = safeNumber(quote?.yearHigh); const yearLow = safeNumber(quote?.yearLow); let distFromHigh: number | null = null; if (current !== null && yearHigh !== null && yearHigh > 0) { distFromHigh = round2(((current - yearHigh) / yearHigh) * 100); } // Valuation section const marketCap = safeNumber(quote?.marketCap) ?? safeNumber(profile?.marketCap) ?? safeNumber(profile?.mktCap); const peRatio = safeNumber(quote?.pe); const dcfValue = safeNumber(dcf?.dcf) ?? safeNumber(profile?.dcf); let dcfUpside: number | null = null; if (dcfValue !== null && current !== null && current > 0) { dcfUpside = round2(((dcfValue - current) / current) * 100); } // Key metrics (latest only) const latestKm = keyMetrics?.[0] ?? null; const latestRt = ratios?.[0] ?? null; const pbRatio = safeNumber(latestKm?.pbRatio) ?? safeNumber(latestRt?.priceToBookRatio); const psRatio = safeNumber(latestKm?.priceToSalesRatio) ?? safeNumber(latestRt?.priceToSalesRatio); const evToEbitda = safeNumber(latestKm?.enterpriseValueOverEBITDA) ?? safeNumber(latestRt?.enterpriseValueMultiple); // Profitability const grossMargin = toPct(latestRt?.grossProfitMargin); const operatingMargin = toPct(latestRt?.operatingProfitMargin); const netMargin = toPct(latestRt?.netProfitMargin); const roe = toPct(latestRt?.returnOnEquity ?? latestKm?.roe); const roa = toPct(latestRt?.returnOnAssets); const roic = toPct(latestKm?.roic); // Financial health const debtToEquity = safeNumber(latestRt?.debtEquityRatio) ?? safeNumber(latestKm?.debtToEquity); const currentRatio = safeNumber(latestRt?.currentRatio) ?? safeNumber(latestKm?.currentRatio); const interestCoverage = safeNumber(latestRt?.interestCoverage) ?? safeNumber(latestKm?.interestCoverage); // Growth — compute from income statements const incLatest = income?.[0] ?? null; const incPrior = income?.[1] ?? null; let revenueGrowth: number | null = null; let earningsGrowth: number | null = null; const rev0 = safeNumber(incLatest?.revenue); const rev1 = safeNumber(incPrior?.revenue); if (rev0 !== null && rev1 !== null && rev1 !== 0) { revenueGrowth = round1(((rev0 - rev1) / Math.abs(rev1)) * 100); } const ni0 = safeNumber(incLatest?.netIncome); const ni1 = safeNumber(incPrior?.netIncome); if (ni0 !== null && ni1 !== null && ni1 !== 0) { earningsGrowth = round1(((ni0 - ni1) / Math.abs(ni1)) * 100); } // Dividend const dividendYield = toPct(latestRt?.dividendYield ?? latestKm?.dividendYield); const payoutRatio = toPct(latestRt?.payoutRatio ?? latestRt?.dividendPayoutRatio); // Rating let ratingBlock: CompanyComparison['rating'] = null; if (rating) { ratingBlock = { score: safeNumber(rating.ratingScore), recommendation: rating.ratingRecommendation ?? rating.rating ?? null, }; } const comparison: CompanyComparison = { symbol, company_name: profile?.companyName ?? quote?.name ?? null, sector: profile?.sector ?? null, industry: profile?.industry ?? null, price: { current: current !== null ? round2(current) : null, change_percent: changePercent !== null ? round2(changePercent) : null, year_high: yearHigh !== null ? round2(yearHigh) : null, year_low: yearLow !== null ? round2(yearLow) : null, distance_from_52w_high_percent: distFromHigh, }, valuation: { market_cap: marketCap, market_cap_readable: marketCap !== null ? formatMarketCap(marketCap) : null, pe_ratio: peRatio !== null ? round2(peRatio) : null, pb_ratio: pbRatio !== null ? round2(pbRatio) : null, ps_ratio: psRatio !== null ? round2(psRatio) : null, ev_to_ebitda: evToEbitda !== null ? round2(evToEbitda) : null, dcf_value: dcfValue !== null ? round2(dcfValue) : null, dcf_upside_percent: dcfUpside, }, profitability: { gross_margin: grossMargin, operating_margin: operatingMargin, net_margin: netMargin, roe, roa, roic, }, financial_health: { debt_to_equity: debtToEquity !== null ? round2(debtToEquity) : null, current_ratio: currentRatio !== null ? round2(currentRatio) : null, interest_coverage: interestCoverage !== null ? round1(interestCoverage) : null, }, growth: { revenue_growth_yoy: revenueGrowth, earnings_growth_yoy: earningsGrowth, }, dividend: { dividend_yield: dividendYield, payout_ratio: payoutRatio, }, rating: ratingBlock, }; return { comparison, apiCalls }; } - `deriveRankings` — helper that compares all companies across dimensions (lowest P/E, highest net margin, strongest balance sheet, best growth, most undervalued by DCF, highest rated) and returns the leading symbol for each.
function deriveRankings(companies: CompanyComparison[]): CompareCompaniesResult['rankings'] { // Lowest positive P/E const withPe = companies.filter((c) => c.valuation.pe_ratio !== null && c.valuation.pe_ratio > 0); const lowestPe = withPe.length > 0 ? withPe.reduce((a, b) => (a.valuation.pe_ratio! < b.valuation.pe_ratio! ? a : b)).symbol : null; // Highest net margin const withMargin = companies.filter((c) => c.profitability.net_margin !== null); const highestMargin = withMargin.length > 0 ? withMargin.reduce((a, b) => (a.profitability.net_margin! > b.profitability.net_margin! ? a : b)).symbol : null; // Strongest balance sheet (lowest non-negative D/E) const withDe = companies.filter( (c) => c.financial_health.debt_to_equity !== null && c.financial_health.debt_to_equity >= 0, ); const strongestBs = withDe.length > 0 ? withDe.reduce((a, b) => (a.financial_health.debt_to_equity! < b.financial_health.debt_to_equity! ? a : b)).symbol : null; // Best revenue growth const withGrowth = companies.filter((c) => c.growth.revenue_growth_yoy !== null); const bestGrowth = withGrowth.length > 0 ? withGrowth.reduce((a, b) => (a.growth.revenue_growth_yoy! > b.growth.revenue_growth_yoy! ? a : b)).symbol : null; // Most undervalued (highest DCF upside) const withDcf = companies.filter((c) => c.valuation.dcf_upside_percent !== null); const mostUndervalued = withDcf.length > 0 ? withDcf.reduce((a, b) => (a.valuation.dcf_upside_percent! > b.valuation.dcf_upside_percent! ? a : b)).symbol : null; // Highest rated const withRating = companies.filter((c) => c.rating !== null && c.rating.score !== null); const highestRated = withRating.length > 0 ? withRating.reduce((a, b) => (a.rating!.score! > b.rating!.score! ? a : b)).symbol : null; return { lowest_pe: lowestPe, highest_margin: highestMargin, strongest_balance_sheet: strongestBs, best_growth: bestGrowth, most_undervalued: mostUndervalued, highest_rated: highestRated, }; } - src/index.ts:308-343 (registration)MCP tool registration via `server.registerTool('compare_companies', ...)` with input schema (array of 2-5 symbols), output schema (CompareCompaniesOutputShape), and the handler lambda that calls `compareCompanies(symbols)`.
server.registerTool( 'compare_companies', { title: 'Company Comparison', description: 'Side-by-side comparison of 2-5 companies across price, valuation (P/E, P/B, P/S, EV/EBITDA, DCF), profitability (margins, ROE, ROA, ROIC), financial health (D/E, current ratio, interest coverage), growth (revenue and earnings YoY), dividends, and analyst ratings. Returns derived rankings showing which company leads each dimension — lowest_pe, highest_margin, strongest_balance_sheet, best_growth, most_undervalued, highest_rated. Use this for investment comparisons, competitive analysis, or evaluating alternatives in the same sector.', inputSchema: { symbols: z .array( z .string() .min(1) .max(10) .regex(/^[A-Za-z0-9.^=-]+$/, 'Invalid ticker symbol format'), ) .min(2) .max(5) .describe('2-5 stock ticker symbols to compare (e.g., ["AAPL", "MSFT", "GOOGL"])'), }, outputSchema: CompareCompaniesOutputShape, annotations: { title: 'Company Comparison', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, async ({ symbols }) => { const result = await compareCompanies(symbols); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result as unknown as { [key: string]: unknown }, }; }, ); - src/index.ts:150-219 (schema)Zod schemas for compare_companies output: `CompanyComparisonShape` (per-company) and `CompareCompaniesOutputShape` (includes symbols_compared, comparison_date, companies array, rankings, and meta).
const CompanyComparisonShape = z.object({ symbol: z.string(), company_name: z.string().nullable(), sector: z.string().nullable(), industry: z.string().nullable(), price: z.object({ current: z.number().nullable(), change_percent: z.number().nullable(), year_high: z.number().nullable(), year_low: z.number().nullable(), distance_from_52w_high_percent: z.number().nullable(), }), valuation: z.object({ market_cap: z.number().nullable(), market_cap_readable: z.string().nullable(), pe_ratio: z.number().nullable(), pb_ratio: z.number().nullable(), ps_ratio: z.number().nullable(), ev_to_ebitda: z.number().nullable(), dcf_value: z.number().nullable(), dcf_upside_percent: z.number().nullable(), }), profitability: z.object({ gross_margin: z.number().nullable(), operating_margin: z.number().nullable(), net_margin: z.number().nullable(), roe: z.number().nullable(), roa: z.number().nullable(), roic: z.number().nullable(), }), financial_health: z.object({ debt_to_equity: z.number().nullable(), current_ratio: z.number().nullable(), interest_coverage: z.number().nullable(), }), growth: z.object({ revenue_growth_yoy: z.number().nullable(), earnings_growth_yoy: z.number().nullable(), }), dividend: z.object({ dividend_yield: z.number().nullable(), payout_ratio: z.number().nullable(), }), rating: z .object({ score: z.number().nullable(), recommendation: z.string().nullable(), }) .nullable(), }); const CompareCompaniesOutputShape = { symbols_compared: z.array(z.string()), comparison_date: z.string(), companies: z.array(CompanyComparisonShape), rankings: z.object({ lowest_pe: z.string().nullable(), highest_margin: z.string().nullable(), strongest_balance_sheet: z.string().nullable(), best_growth: z.string().nullable(), most_undervalued: z.string().nullable(), highest_rated: z.string().nullable(), }), meta: z.object({ source: z.string(), timestamp: z.string(), data_delay: z.string(), api_calls_made: z.number(), }), };