**Module access control (admin)** - Added modules column to users table (JSON array of allowed module keys) - auth.me returns modules; admin.setUserModules/disableUser/enableUser/deleteUser procedures - UserActions dropdown with Reset Password, Manage Modules, Disable/Enable, Delete - useFeatureAccess hook + FeatureGate component for page-level gating - SidebarNav, CommandPalette, MobileTabNav filter by modules **Classification watchlists (auto-generated sector/thematic/style/region)** - watchlists schema: added kind, class_key, class_label columns - materializeClassificationWatchlists(): lazy idempotent materialization of system lists from Yahoo sector data - 11 GICS sector lists, Semiconductors, Biotech thematic lists, Small Cap style, Intl region - Materialization triggered on listWatchlists + addSymbol/removeSymbol/add - Sector thematic filter uses industry keywords (semiconductors, biotech) to avoid misclassification - Symbol overrides fixed: IREN removed from SMH theme, bitcoin miners XLE->XLK, SLNH added as Technology **Watchlist persistence & move** - active-watchlist-store.ts: Zustand persist store mirroring active-symbol-store pattern - moveSymbol procedure: removes from source, adds to target (idempotent, preserves empty lists) - Action menu (⋯) per row: Move to + Remove, click-outside close - Active watchlist survives navigation and page reloads **List protections** - default list: non-deletable, non-renamable, keeps empty row when pruned - System lists (sector/thematic etc.): read-only, add form hidden, no remove/move/delete **Per-user module restrictions** - ProtectedProcedure blocks non-active users - deleteUser refuses self-delete and last-admin-delete, cleans halt_state manually - Module gating on execution/analytics pages, sidebar, command palette, mobile nav Also fixed: schema.sql index moved to migration, materialize DB column fixes, test updates.
552 lines
21 KiB
TypeScript
552 lines
21 KiB
TypeScript
// 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>): 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<string, SymbolContextOverride> = {
|
|
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 };
|