223 lines
8.5 KiB
TypeScript
223 lines
8.5 KiB
TypeScript
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,
|
||
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<string, string | null> {
|
||
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<string, string | null> = {
|
||
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);
|
||
|
||
// 52-week movement
|
||
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;
|
||
}
|
||
}
|