// Pure helpers for focused-ticker context vs market, sector, and peers. // ADR-0007: describes relative position — never buy/sell directives. import { totalReturnPct, HORIZON_MS, type Horizon, type CandlePoint, } from './marketRotationRs.ts'; export type Stance = 'outperforming' | 'underperforming' | 'inline' | 'unknown'; export interface HorizonReturns { oneWeek: number | null; oneMonth: number | null; threeMonth: number | null; sixMonth: number | null; oneYear: number | null; threeYear: number | null; fiveYear: number | null; } export function returnsBundle(candles: CandlePoint[]): HorizonReturns { return { oneWeek: totalReturnPct(candles, HORIZON_MS.oneWeek), oneMonth: totalReturnPct(candles, HORIZON_MS.oneMonth), threeMonth: totalReturnPct(candles, HORIZON_MS.threeMonth), sixMonth: totalReturnPct(candles, HORIZON_MS.sixMonth), oneYear: totalReturnPct(candles, HORIZON_MS.oneYear), threeYear: totalReturnPct(candles, HORIZON_MS.threeYear), fiveYear: totalReturnPct(candles, HORIZON_MS.fiveYear), }; } function relativeBundle(subject: HorizonReturns, bench: HorizonReturns): HorizonReturns { return { oneWeek: relativeTo(subject.oneWeek, bench.oneWeek), oneMonth: relativeTo(subject.oneMonth, bench.oneMonth), threeMonth: relativeTo(subject.threeMonth, bench.threeMonth), sixMonth: relativeTo(subject.sixMonth, bench.sixMonth), oneYear: relativeTo(subject.oneYear, bench.oneYear), threeYear: relativeTo(subject.threeYear, bench.threeYear), fiveYear: relativeTo(subject.fiveYear, bench.fiveYear), }; } export function relativeTo( subject: number | null, benchmark: number | null, ): number | null { if (subject === null || benchmark === null) return null; return subject - benchmark; } export function stanceFromRs(rs: number | null, threshold = 2): Stance { if (rs === null) return 'unknown'; if (rs > threshold) return 'outperforming'; if (rs < -threshold) return 'underperforming'; return 'inline'; } export function avgReturn(values: Array): number | null { const nums = values.filter((v): v is number => v !== null && Number.isFinite(v)); if (nums.length === 0) return null; return nums.reduce((a, b) => a + b, 0) / nums.length; } /** Map Yahoo sector labels → liquid sector ETF. */ export function sectorToEtf(sector: string | null | undefined): { etf: string; label: string } | null { if (!sector) return null; const s = sector.toLowerCase(); const table: Array<{ match: RegExp; etf: string; label: string }> = [ { match: /technolog|information technology|software/, etf: 'XLK', label: 'Technology' }, { match: /financial|bank/, etf: 'XLF', label: 'Financials' }, { match: /energy|oil|gas/, etf: 'XLE', label: 'Energy' }, { match: /industrial/, etf: 'XLI', label: 'Industrials' }, { match: /health|pharma|biotech|biotechnology/, etf: 'XLV', label: 'Healthcare' }, { match: /consumer cycl|consumer discretionary|retail/, etf: 'XLY', label: 'Consumer Discretionary' }, { match: /consumer defen|consumer staple|food|beverage/, etf: 'XLP', label: 'Consumer Staples' }, { match: /utilit/, etf: 'XLU', label: 'Utilities' }, { match: /real estate|reit/, etf: 'XLRE', label: 'Real Estate' }, { match: /communicat|media|telecom/, etf: 'XLC', label: 'Communication Services' }, { match: /material|basic material|mining|chemical/, etf: 'XLB', label: 'Materials' }, ]; for (const row of table) { if (row.match.test(s)) return { etf: row.etf, label: row.label }; } return null; } /** Industry / description keywords → thematic ETF (optional overlay). */ export function industryToTheme( industry: string | null | undefined, description: string | null | undefined, ): { etf: string; label: string } | null { const text = `${industry ?? ''} ${description ?? ''}`.toLowerCase(); if (!text.trim()) return null; // Order matters: compute / AI infra and crypto-mining before generic finance. const table: Array<{ match: RegExp; etf: string; label: string }> = [ { match: /ai data.?center|hyperscal|gpu cloud|hpc|high.?performance comput|ai infrastructure|ai infra|neocloud/, etf: 'SMH', label: 'AI / HPC infrastructure' }, { match: /bitcoin.?min|crypto.?min|digital.?asset.?min|cryptocurrency min|btc min/, etf: 'BLOK', label: 'Digital-asset mining' }, { match: /data.?center|colocation|power for compute/, etf: 'SRVR', label: 'Data centers' }, { match: /semiconductor|chip|gpu|foundry|fabless/, etf: 'SMH', label: 'Semiconductors' }, { match: /biotech|biotechnology|genomic/, etf: 'XBI', label: 'Biotech' }, { match: /software|saas|cloud|enterprise software/, etf: 'IGV', label: 'Software' }, { match: /bank|banking|regional bank/, etf: 'KBE', label: 'Banks' }, { match: /insurance/, etf: 'KIE', label: 'Insurance' }, { match: /aerospace|defense|weapon/, etf: 'ITA', label: 'Defense' }, { match: /airline|aviation/, etf: 'JETS', label: 'Airlines' }, ]; for (const row of table) { if (row.match.test(text)) return { etf: row.etf, label: row.label }; } return null; } /** Crypto miners / AI power-compute names often mis-bucketed by Yahoo under Financials. */ export const CRYPTO_MINING_PEERS = [ 'IREN', 'CIFR', 'RIOT', 'MARA', 'CLSK', 'WULF', 'CORZ', 'HUT', 'BITF', 'HIVE', 'BTDR', 'BTBT', ] as const; /** Hyperscaler / GPU-cloud / AI data-center operating peers (not crypto-miner beta). */ export const AI_INFRA_PEERS = [ 'CRWV', 'NBIS', 'IREN', 'APLD', 'VRT', 'SMCI', 'ANET', 'EQIX', 'DLR', 'GDS', 'VNET', ] as const; export interface SymbolContextOverride { /** Operating peer set (excludes self at use site). */ peers: string[]; /** Preferred GICS-style comparison ETF (may override Yahoo sector). */ sectorEtf: string; sectorLabel: string; themeEtf: string | null; themeLabel: string | null; /** Shown when Yahoo classification disagrees with operating profile. */ classificationNote: string; } /** * Explicit operating-profile overrides. Yahoo still lists many power/compute * and crypto-mining names under Financial Services / Capital Markets. */ export const SYMBOL_CONTEXT_OVERRIDES: Record = { IREN: { peers: ['CRWV', 'NBIS', 'APLD', 'VRT', 'SMCI', 'ANET', 'EQIX', 'DLR'], sectorEtf: 'XLK', sectorLabel: 'Technology (AI infrastructure)', themeEtf: null, themeLabel: 'AI / hyperscaler infrastructure', classificationNote: 'Vendor feeds often label IREN as Financial Services / Capital Markets (crypto-era bucket). Operating comparison uses AI infrastructure / GPU-cloud peers (e.g. CRWV, NBIS), not banks or pure crypto miners.', }, SLNH: { peers: ['IREN', 'CIFR', 'WULF', 'CORZ', 'APLD'], sectorEtf: 'XLK', sectorLabel: 'Technology (green data centers)', themeEtf: null, themeLabel: null, classificationNote: 'Vendor feeds label SLNH as Financial Services; operating profile is green-energy data centers and crypto-mining infrastructure.', }, CRWV: { peers: ['NBIS', 'IREN', 'APLD', 'VRT', 'SMCI', 'ANET', 'EQIX', 'DLR'], sectorEtf: 'XLK', sectorLabel: 'Technology (AI infrastructure)', themeEtf: 'SMH', themeLabel: 'AI / hyperscaler infrastructure', classificationNote: 'GPU-cloud / AI infrastructure peer set (with IREN, NBIS, etc.).', }, NBIS: { peers: ['CRWV', 'IREN', 'APLD', 'VRT', 'SMCI', 'ANET', 'EQIX', 'DLR'], sectorEtf: 'XLK', sectorLabel: 'Technology (AI infrastructure)', themeEtf: 'SMH', themeLabel: 'AI / hyperscaler infrastructure', classificationNote: 'AI infrastructure peer set (with CRWV, IREN, etc.).', }, CIFR: { peers: ['RIOT', 'MARA', 'CLSK', 'WULF', 'CORZ', 'HUT', 'BITF', 'IREN'], sectorEtf: 'XLK', sectorLabel: 'Technology (digital assets)', themeEtf: null, themeLabel: 'Digital-asset mining', classificationNote: 'Often classified under Financial Services; primary peer set is crypto miners (IREN retained as a related power/compute name).', }, RIOT: { peers: [...CRYPTO_MINING_PEERS], sectorEtf: 'XLK', sectorLabel: 'Technology (digital assets)', themeEtf: null, themeLabel: 'Digital-asset mining', classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.', }, MARA: { peers: [...CRYPTO_MINING_PEERS], sectorEtf: 'XLK', sectorLabel: 'Technology (digital assets)', themeEtf: null, themeLabel: 'Digital-asset mining', classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.', }, CLSK: { peers: [...CRYPTO_MINING_PEERS], sectorEtf: 'XLK', sectorLabel: 'Technology (digital assets)', themeEtf: null, themeLabel: 'Digital-asset mining', classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.', }, WULF: { peers: [...CRYPTO_MINING_PEERS], sectorEtf: 'XLK', sectorLabel: 'Technology (compute infrastructure)', themeEtf: null, themeLabel: 'AI / HPC infrastructure', classificationNote: 'Mining + HPC/AI data-center transition; not a traditional financial.', }, CORZ: { peers: [...CRYPTO_MINING_PEERS], sectorEtf: 'XLK', sectorLabel: 'Technology (compute infrastructure)', themeEtf: null, themeLabel: 'AI / HPC infrastructure', classificationNote: 'Mining + AI hosting; not a traditional financial.', }, APLD: { peers: ['IREN', 'CIFR', 'CRWV', 'NBIS', 'EQIX', 'DLR', 'VRT', 'SMCI'], sectorEtf: 'XLK', sectorLabel: 'Technology (data centers)', themeEtf: null, themeLabel: 'AI / HPC infrastructure', classificationNote: 'AI data-center developer/operator peer set.', }, }; export interface ResolvedBusinessContext { sectorEtf: string | null; sectorLabel: string | null; themeEtf: string | null; themeLabel: string | null; /** Curated peers when available; empty means fall back to vendor peers / ETF holdings. */ peers: string[]; classificationNote: string | null; /** When true, do not pull peers from sector ETF holdings (XLF etc.). */ blockSectorEtfPeers: boolean; /** Yahoo-reported sector (may be misleading). */ vendorSector: string | null; vendorIndustry: string | null; } /** * Resolve operating sector/theme/peers. Prefer symbol overrides and text signals * over vendor GICS when those conflict (e.g. IREN → not XLF). */ export function resolveBusinessContext(opts: { symbol: string; sector: string | null | undefined; industry: string | null | undefined; description: string | null | undefined; }): ResolvedBusinessContext { const symbol = opts.symbol.toUpperCase(); const vendorSector = opts.sector ?? null; const vendorIndustry = opts.industry ?? null; const text = `${opts.sector ?? ''} ${opts.industry ?? ''} ${opts.description ?? ''}`.toLowerCase(); const override = SYMBOL_CONTEXT_OVERRIDES[symbol]; if (override) { return { sectorEtf: override.sectorEtf, sectorLabel: override.sectorLabel, themeEtf: override.themeEtf, themeLabel: override.themeLabel, peers: override.peers.filter((p) => p !== symbol), classificationNote: override.classificationNote, blockSectorEtfPeers: true, vendorSector, vendorIndustry, }; } // Heuristic: crypto mining / AI power-compute mislabeled as Financial Services. const isCryptoOrComputeInfra = /bitcoin.?min|crypto.?min|digital.?asset.?min|cryptocurrency|data.?center|hyperscal|ai infrastructure|ai infra|hpc|gpu cloud|neocloud/.test(text); const vendorIsFinance = /financial|capital markets|asset management/.test(`${vendorSector ?? ''} ${vendorIndustry ?? ''}`.toLowerCase()); if (isCryptoOrComputeInfra && vendorIsFinance) { const aiTilt = /data.?center|hyperscal|ai infrastructure|ai infra|hpc|gpu|neocloud/.test(text); const peers = (aiTilt ? AI_INFRA_PEERS : CRYPTO_MINING_PEERS).filter((p) => p !== symbol); return { sectorEtf: 'XLK', sectorLabel: aiTilt ? 'Technology (compute infrastructure)' : 'Technology (digital assets)', themeEtf: aiTilt ? 'SMH' : null, themeLabel: aiTilt ? 'AI / HPC infrastructure' : 'Digital-asset mining', peers: [...peers], classificationNote: `Vendor sector is "${vendorSector ?? 'n/a'}" / "${vendorIndustry ?? 'n/a'}", which is a common bucket for crypto-era names. Operating comparison uses compute/digital-infrastructure peers instead of financials.`, blockSectorEtfPeers: true, vendorSector, vendorIndustry, }; } const sectorMap = sectorToEtf(vendorSector); const themeMap = industryToTheme(vendorIndustry, opts.description); return { sectorEtf: sectorMap?.etf ?? null, sectorLabel: sectorMap?.label ?? vendorSector, themeEtf: themeMap?.etf ?? null, themeLabel: themeMap?.label ?? null, peers: [], classificationNote: null, blockSectorEtfPeers: false, vendorSector, vendorIndustry, }; } export interface PeerRow { symbol: string; name: string | null; returns: HorizonReturns; rsVsMarket1M: number | null; } export interface TickerContextInput { symbol: string; name: string | null; sector: string | null; industry: string | null; tickerKind: string; symbolReturns: HorizonReturns; marketReturns: HorizonReturns; marketRegime: 'trending-up' | 'trending-down' | 'range-bound' | null; marketRegimeConfidence: number | null; sectorEtf: string | null; sectorLabel: string | null; sectorReturns: HorizonReturns | null; sectorLeadership: 'leading' | 'lagging' | 'inline' | 'unknown' | null; themeEtf: string | null; themeLabel: string | null; themeReturns: HorizonReturns | null; peers: PeerRow[]; /** When vendor GICS disagrees with operating profile. */ classificationNote?: string | null; vendorSector?: string | null; vendorIndustry?: string | null; } export interface TickerContextResult { symbol: string; name: string | null; sector: string | null; industry: string | null; tickerKind: string; performance: { symbol: HorizonReturns; vsMarket: HorizonReturns; vsSector: HorizonReturns | null; vsPeers1M: number | null; marketStance1M: Stance; sectorStance1M: Stance; peerStance1M: Stance; }; market: { regime: string | null; confidence: number | null; spyReturns: HorizonReturns; }; sectorContext: { label: string | null; etf: string | null; etfReturns: HorizonReturns | null; leadership: string | null; }; themeContext: { label: string | null; etf: string | null; etfReturns: HorizonReturns | null; }; classification: { note: string | null; vendorSector: string | null; vendorIndustry: string | null; }; peers: Array<{ symbol: string; name: string | null; oneMonth: number | null; rsVsMarket1M: number | null; }>; /** Short professional paragraphs for the UI. */ summary: { headline: string; market: string; sector: string; peers: string; synthesis: string; }; } function fmtPct(v: number | null): string { if (v === null || !Number.isFinite(v)) return 'n/a'; const sign = v > 0 ? '+' : ''; return `${sign}${v.toFixed(1)}%`; } function stancePhrase(s: Stance): string { if (s === 'outperforming') return 'outperforming'; if (s === 'underperforming') return 'underperforming'; if (s === 'inline') return 'broadly in line with'; return 'insufficient data versus'; } export function buildTickerContext(input: TickerContextInput): TickerContextResult { const vsMarket = relativeBundle(input.symbolReturns, input.marketReturns); const vsSector = input.sectorReturns ? relativeBundle(input.symbolReturns, input.sectorReturns) : null; const peerAvg1M = avgReturn(input.peers.map((p) => p.returns.oneMonth)); const vsPeers1M = relativeTo(input.symbolReturns.oneMonth, peerAvg1M); const marketStance1M = stanceFromRs(vsMarket.oneMonth); const sectorStance1M = stanceFromRs(vsSector?.oneMonth ?? null); const peerStance1M = stanceFromRs(vsPeers1M); const name = input.name ?? input.symbol; const regime = input.marketRegime === 'trending-up' ? 'trending up' : input.marketRegime === 'trending-down' ? 'trending down' : input.marketRegime === 'range-bound' ? 'range-bound' : null; const headline = `${input.symbol}${input.name ? ` (${input.name})` : ''} — relative context versus market, sector, and peers.`; const market = regime ? `Broad market regime is currently ${regime}` + (input.marketRegimeConfidence != null ? ` (~${input.marketRegimeConfidence}% classifier confidence). ` : '. ') + `SPY 1M ${fmtPct(input.marketReturns.oneMonth)}; ${input.symbol} 1M ${fmtPct(input.symbolReturns.oneMonth)} ` + `(${fmtPct(vsMarket.oneMonth)} relative). On a one-month basis the name is ${stancePhrase(marketStance1M)} the broad market.` : `SPY 1M ${fmtPct(input.marketReturns.oneMonth)}; ${input.symbol} 1M ${fmtPct(input.symbolReturns.oneMonth)} ` + `(${fmtPct(vsMarket.oneMonth)} relative). Market regime data is incomplete.`; let sector = 'Sector classification is unavailable for this symbol.'; if (input.sectorLabel || input.sector) { const label = input.sectorLabel ?? input.sector ?? 'sector'; const ind = input.industry ? ` Industry: ${input.industry}.` : ''; const vendorNote = input.classificationNote ? ` ${input.classificationNote}` : (input.vendorSector && input.sectorLabel && !input.sectorLabel.toLowerCase().includes((input.vendorSector ?? '').toLowerCase().split(' ')[0] ?? '___') ? ` Vendor feed lists sector as ${input.vendorSector}${input.vendorIndustry ? ` / ${input.vendorIndustry}` : ''}.` : ''); const etfBit = input.sectorEtf ? ` Comparison proxy ${input.sectorEtf} 1M ${fmtPct(input.sectorReturns?.oneMonth ?? null)}` + (vsSector ? `; name vs proxy ${fmtPct(vsSector.oneMonth)} (${stancePhrase(sectorStance1M)} that group).` : '.') : ''; const themeBit = input.themeLabel ? ` Theme: ${input.themeLabel}${input.themeEtf ? ` (${input.themeEtf})` : ''}` + (input.themeReturns ? ` 1M ${fmtPct(input.themeReturns.oneMonth)}.` : '.') : ''; const lead = input.sectorLeadership === 'leading' ? ' The comparison group is currently outperforming SPY.' : input.sectorLeadership === 'lagging' ? ' The comparison group is currently underperforming SPY.' : input.sectorLeadership === 'inline' ? ' The comparison group is roughly in line with SPY.' : ''; sector = `Operating comparison group: ${label}.${ind}${vendorNote}${etfBit}${themeBit}${lead}`; } let peers = 'Peer comparison is limited — peer list or peer price history is incomplete.'; if (input.peers.length > 0) { const ranked = [...input.peers] .filter((p) => p.returns.oneMonth !== null) .sort((a, b) => (b.returns.oneMonth ?? -Infinity) - (a.returns.oneMonth ?? -Infinity)); const top = ranked.slice(0, 3).map((p) => `${p.symbol} ${fmtPct(p.returns.oneMonth)}`).join(', '); peers = `Compared with ${input.peers.length} peer${input.peers.length === 1 ? '' : 's'} ` + `(1M peer average ${fmtPct(peerAvg1M)}), ${input.symbol} is ${stancePhrase(peerStance1M)} that set ` + `(${fmtPct(vsPeers1M)} relative).` + (top ? ` Stronger peer prints on 1M include: ${top}.` : ''); } const synthesisParts: string[] = []; if (marketStance1M === 'outperforming' && (sectorStance1M === 'outperforming' || sectorStance1M === 'unknown')) { synthesisParts.push('Relative price action is constructive versus the broad market'); if (sectorStance1M === 'outperforming') synthesisParts.push('and its sector group'); } else if (marketStance1M === 'underperforming') { synthesisParts.push('Relative price action is lagging the broad market'); if (sectorStance1M === 'underperforming') synthesisParts.push('and its sector group'); } else { synthesisParts.push('Relative price action is mixed or roughly in line with the market'); } if (peerStance1M === 'outperforming') synthesisParts.push('with a lead versus available peers on one month'); else if (peerStance1M === 'underperforming') synthesisParts.push('with a lag versus available peers on one month'); const synthesis = `${name}: ${synthesisParts.join(', ')}. ` + 'This is a relative-performance snapshot for research context — not a forecast and not an investment recommendation.'; return { symbol: input.symbol, name: input.name, sector: input.sector, industry: input.industry, tickerKind: input.tickerKind, performance: { symbol: input.symbolReturns, vsMarket, vsSector, vsPeers1M, marketStance1M, sectorStance1M, peerStance1M, }, market: { regime: input.marketRegime, confidence: input.marketRegimeConfidence, spyReturns: input.marketReturns, }, sectorContext: { label: input.sectorLabel ?? input.sector, etf: input.sectorEtf, etfReturns: input.sectorReturns, leadership: input.sectorLeadership, }, themeContext: { label: input.themeLabel, etf: input.themeEtf, etfReturns: input.themeReturns, }, classification: { note: input.classificationNote ?? null, vendorSector: input.vendorSector ?? input.sector, vendorIndustry: input.vendorIndustry ?? input.industry, }, peers: input.peers.map((p) => ({ symbol: p.symbol, name: p.name, oneMonth: p.returns.oneMonth, rsVsMarket1M: p.rsVsMarket1M, })), summary: { headline, market, sector, peers, synthesis }, }; } // re-export horizon type for callers export type { Horizon, CandlePoint };