import { Asset } from './Asset'; import { CAP_CATEGORY, GROWTH_CATEGORY } from '../config/constants'; import type { Sector, CapCategory, GrowthCategory } from '../config/constants'; import type { StockData, StockMetrics } from '../types/models.model'; export class Stock extends Asset { sector: Sector; metrics: StockMetrics; constructor(data: StockData) { super(data); this.sector = this.mapToStandardSector(data); this.metrics = { sector: this.sector, capCategory: this.classifyMarketCap(data.marketCap ?? null), growthCategory: this.classifyGrowth( data.revenueGrowth ?? null, data.earningsGrowth ?? null, data.dividendYield ?? null, ), peRatio: data.peRatio ?? null, pegRatio: data.pegRatio ?? null, priceToBook: data.priceToBook ?? null, grossMargin: data.grossMargin ?? null, netProfitMargin: data.netProfitMargin ?? null, operatingMargin: data.operatingMargin ?? null, returnOnEquity: data.returnOnEquity ?? null, revenueGrowth: data.revenueGrowth ?? null, earningsGrowth: data.earningsGrowth ?? null, debtToEquity: data.debtToEquity ?? null, quickRatio: data.quickRatio ?? null, fcfYield: data.fcfYield ?? null, pFFO: data.pFFO ?? null, dividendYield: data.dividendYield ?? null, beta: data.beta ?? null, dayChangePct: data.dayChangePct ?? null, week52High: data.week52High ?? null, week52Low: data.week52Low ?? null, week52Change: data.week52Change ?? null, week52FromHigh: data.week52FromHigh ?? null, week52FromLow: data.week52FromLow ?? null, marketCap: data.marketCap ?? null, analystRating: data.analystRating ?? null, analystTargetPrice: data.analystTargetPrice ?? null, analystUpside: data.analystUpside ?? null, numberOfAnalysts: data.numberOfAnalysts ?? null, dcfIntrinsicValue: data.dcfIntrinsicValue ?? null, dcfMarginOfSafety: data.dcfMarginOfSafety ?? null, currentPrice: (data.currentPrice as number) || 0, }; } // ── Market cap tier classification ────────────────────────────────────── // Thresholds follow MSCI/Russell institutional convention. classifyMarketCap(marketCap: number | null): CapCategory { if (marketCap == null) return CAP_CATEGORY.LARGE; // safe default if (marketCap >= 200e9) return CAP_CATEGORY.MEGA; if (marketCap >= 10e9) return CAP_CATEGORY.LARGE; if (marketCap >= 2e9) return CAP_CATEGORY.MID; if (marketCap >= 300e6) return CAP_CATEGORY.SMALL; return CAP_CATEGORY.MICRO; } // ── Growth / style classification ─────────────────────────────────────── // revenueGrowth and earningsGrowth are in percentage form (e.g. 15 = 15%). // dividendYield is also in percentage form (e.g. 3.5 = 3.5%). classifyGrowth( revenueGrowth: number | null, earningsGrowth: number | null, dividendYield: number | null, ): GrowthCategory { const rev = revenueGrowth ?? 0; const earn = earningsGrowth ?? 0; const div = dividendYield ?? 0; if (rev < -5) return GROWTH_CATEGORY.DECLINING; if (earn < 0 && rev >= 0) return GROWTH_CATEGORY.TURNAROUND; if (rev >= 15 || earn >= 20) return GROWTH_CATEGORY.HIGH_GROWTH; if (rev >= 5) return GROWTH_CATEGORY.MODERATE_GROWTH; if (div >= 3 && rev < 5) return GROWTH_CATEGORY.VALUE; return GROWTH_CATEGORY.STABLE; } mapToStandardSector(data: StockData): Sector { const profile = data.assetProfile ?? {}; const industry = (profile.industry || '').toLowerCase(); const sector = (profile.sector || '').toLowerCase(); const combined = `${industry} ${sector}`; if ( combined.includes('technology') || combined.includes('electronic') || combined.includes('semiconductor') || combined.includes('software') ) return 'TECHNOLOGY'; if (combined.includes('real estate') || combined.includes('reit')) return 'REIT'; if ( combined.includes('financial') || combined.includes('bank') || combined.includes('insurance') || combined.includes('asset management') ) return 'FINANCIAL'; if ( combined.includes('energy') || combined.includes('oil') || combined.includes('gas') || combined.includes('petroleum') ) return 'ENERGY'; if ( combined.includes('health') || combined.includes('biotech') || combined.includes('pharmaceutical') || combined.includes('medical') ) return 'HEALTHCARE'; if ( combined.includes('communication') || combined.includes('media') || combined.includes('entertainment') || combined.includes('telecom') ) return 'COMMUNICATION'; if ( combined.includes('consumer defensive') || combined.includes('consumer staples') || combined.includes('household') || combined.includes('beverage') || combined.includes('food') ) return 'CONSUMER_STAPLES'; if ( combined.includes('consumer cyclical') || combined.includes('consumer discretionary') || combined.includes('retail') || combined.includes('apparel') || combined.includes('auto') ) return 'CONSUMER_DISCRETIONARY'; return 'GENERAL'; } getDisplayMetrics(): Record { const fmt = (v: number | null, dec = 1, suffix = '') => v != null ? `${v.toFixed(dec)}${suffix}` : null; const fmtSign = (v: number | null, suffix = '%') => v != null ? `${v >= 0 ? '+' : ''}${v.toFixed(1)}${suffix}` : null; const m = this.metrics; const w52pos = m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0 ? (((m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)) * 100).toFixed(0) + '%' : null; // Analyst label: convert Yahoo's 1–5 scale to a readable string const analystLabel = (rating: number | null): string | null => { if (rating == null) return null; if (rating <= 1.5) return 'Strong Buy'; if (rating <= 2.5) return 'Buy'; if (rating <= 3.5) return 'Hold'; if (rating <= 4.5) return 'Sell'; return 'Strong Sell'; }; const display: Record = { Ticker: this.ticker, Price: this.formatCurrency(this.currentPrice), Sector: this.sector, 'Cap Tier': m.capCategory, Style: m.growthCategory, }; // Valuation if (m.peRatio != null) display['P/E'] = fmt(m.peRatio, 1); if (m.pegRatio != null) display['PEG'] = fmt(m.pegRatio, 2); if (m.priceToBook != null) display['P/B'] = fmt(m.priceToBook, 2); // Quality if (m.grossMargin != null) display['GrossM%'] = fmt(m.grossMargin, 1, '%'); if (m.returnOnEquity != null) display['ROE%'] = fmt(m.returnOnEquity, 1, '%'); if (m.operatingMargin != null) display['OpMgn%'] = fmt(m.operatingMargin, 1, '%'); if (m.netProfitMargin != null) display['NetMgn%'] = fmt(m.netProfitMargin, 1, '%'); if (m.revenueGrowth != null) display['Rev%'] = fmt(m.revenueGrowth, 1, '%'); if (m.fcfYield != null) display['FCF Yld%'] = fmt(m.fcfYield, 1, '%'); if (m.dividendYield != null) display['Div%'] = fmt(m.dividendYield, 2, '%'); // Risk if (m.debtToEquity != null) display['D/E'] = fmt(m.debtToEquity, 2); if (m.quickRatio != null) display['Quick'] = fmt(m.quickRatio, 2); if (m.beta != null) display['Beta'] = fmt(m.beta, 2); // Movement if (m.dayChangePct != null) display['Day %'] = fmtSign(m.dayChangePct, '%'); if (w52pos != null) display['52W Pos'] = w52pos; if (m.week52Change != null) display['52W Chg'] = fmtSign(m.week52Change, '%'); if (m.week52FromHigh != null) display['From High'] = fmtSign(m.week52FromHigh, '%'); if (m.week52FromLow != null) display['From Low'] = fmtSign(m.week52FromLow, '%'); // REIT-specific if (m.pFFO != null) display['P/FFO'] = fmt(m.pFFO, 1); // Analyst consensus if (m.analystRating != null) { display['Analyst'] = analystLabel(m.analystRating); display['# Analysts'] = m.numberOfAnalysts != null ? String(m.numberOfAnalysts) : null; display['Target'] = m.analystTargetPrice != null ? this.formatCurrency(m.analystTargetPrice) : null; display['Upside'] = fmtSign(m.analystUpside, '%'); } // DCF if (m.dcfIntrinsicValue != null) { display['DCF Value'] = this.formatCurrency(m.dcfIntrinsicValue); display['DCF Safety'] = m.dcfMarginOfSafety != null ? fmtSign(m.dcfMarginOfSafety, '%') : null; } return display; } }