Files
investor-flow/app/server/src/analysis/tickerContext.ts
T
Investor Flow Build 7492317ebb feat: per-user module access, classification watchlists, watchlist persistence & move
**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.
2026-07-25 11:38:23 -04:00

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 };