UI enhancemnts

This commit is contained in:
Kazuma
2026-06-09 19:34:31 -04:00
parent fbadd7fb6e
commit 5655cde6bf
55 changed files with 6226 additions and 465 deletions
@@ -0,0 +1,503 @@
<script lang="ts">
import { tick } from 'svelte';
let {
open = false,
activeMetrics = [] as string[],
focusKey = null as string | null,
onClose,
}: {
open?: boolean;
activeMetrics?: string[];
focusKey?: string | null;
onClose: () => void;
} = $props();
let searchQuery = $state('');
let expandedItem = $state<string | null>(null);
let bodyEl = $state<HTMLElement | null>(null);
// When focusKey changes, expand and scroll to that item
$effect(() => {
if (focusKey && open) {
expandedItem = focusKey;
searchQuery = '';
tick().then(() => {
if (!bodyEl) return;
const el = bodyEl.querySelector(`[data-gkey="${focusKey}"]`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
});
// ── Glossary data ─────────────────────────────────────────────────────
type RangeBand = { val: string; label: string };
type GlossaryItem = {
key: string;
label: string;
category: 'Market Context' | 'Valuation' | 'Quality' | 'Risk' | 'Signals' | 'ETF' | 'Bond';
definition: string;
gate?: string;
goodRange?: RangeBand;
neutralRange?: RangeBand;
badRange?: RangeBand;
assetTypes?: ('STOCK' | 'ETF' | 'BOND')[];
};
const GLOSSARY: GlossaryItem[] = [
// ── Market Context ─────────────────────────────────────────────────
{
key: '10Y',
label: '10Y Treasury Yield',
category: 'Market Context',
definition: 'The yield on 10-year US government bonds — the global risk-free rate benchmark. Drives discount rates for all assets: higher yield = lower present value of future earnings.',
gate: 'Rate regime: < 2% LOW | 25% NORMAL | > 5% HIGH. HIGH rates compress growth stock P/E multipliers.',
goodRange: { val: '24%', label: 'Normal, accommodative' },
neutralRange: { val: '45%', label: 'Elevated, watch growth' },
badRange: { val: '> 5%', label: 'HIGH regime, P/E compression' },
},
{
key: 'VIX',
label: 'VIX — Volatility Index',
category: 'Market Context',
definition: 'The CBOE Volatility Index — measures expected 30-day S&P 500 volatility derived from options prices. Known as the "fear gauge."',
gate: 'Volatility regime: < 15 CALM | 1525 NORMAL | > 25 ELEVATED | > 35 EXTREME',
goodRange: { val: '< 15', label: 'Calm market, low fear' },
neutralRange: { val: '1525', label: 'Normal uncertainty' },
badRange: { val: '> 25', label: 'Elevated fear' },
},
{
key: 'Rate Regime',
label: 'Rate Regime',
category: 'Market Context',
definition: 'Derived from the 10Y Treasury yield. Controls how aggressively the INFLATED scoring mode adjusts P/E gates — HIGH rates tighten the multiplier from 1.5× to 1.2× of S&P P/E.',
gate: 'LOW < 2% | NORMAL 25% | HIGH > 5%',
goodRange: { val: 'LOW', label: 'Growth-friendly' },
neutralRange: { val: 'NORMAL', label: 'Balanced' },
badRange: { val: 'HIGH', label: 'Value favoured, growth penalised' },
},
// ── Valuation ──────────────────────────────────────────────────────
{
key: 'P/E',
label: 'P/E Ratio',
category: 'Valuation',
definition: 'Price-to-Earnings: how many dollars investors pay per $1 of annual profit. Lower = cheaper relative to earnings.',
gate: 'Graham gate: ≤ 15× | Inflated gate: ≤ S&P P/E × 1.5 (live)',
goodRange: { val: '< 15×', label: 'Value / below sector avg' },
neutralRange: { val: '1535×', label: 'Elevated but common' },
badRange: { val: '> 35×', label: 'Expensive without high growth' },
assetTypes: ['STOCK'],
},
{
key: 'PEG',
label: 'PEG Ratio',
category: 'Valuation',
definition: 'P/E divided by earnings growth rate. Adjusts for growth — a 30× P/E stock growing 30% has PEG 1.0, same as a 15× stock growing 15%.',
gate: 'Gate: < 1.0 (Lynch standard) · Weight: 2',
goodRange: { val: '< 1.0', label: 'Bargain' },
neutralRange: { val: '1.02.0', label: 'Fair' },
badRange: { val: '> 2.0', label: 'Costly' },
assetTypes: ['STOCK'],
},
{
key: 'DCF Safety',
label: 'DCF Margin of Safety',
category: 'Valuation',
definition: 'How much below the discounted cash flow intrinsic value the stock trades. Positive = undervalued vs. DCF model; negative = overvalued. Requires positive FCF to compute.',
gate: '≥ +20% → full score | 020% → +1 | -200% → -1 | < -20% → negative score',
goodRange: { val: '> +20%', label: 'Significant discount' },
neutralRange: { val: '020%', label: 'Modest discount' },
badRange: { val: '< -20%', label: 'Premium to fair value' },
assetTypes: ['STOCK'],
},
{
key: 'Upside',
label: 'Analyst Price Target Upside',
category: 'Valuation',
definition: 'Percentage gap between current price and Wall Street consensus target price. Positive = analysts expect the stock to rise.',
gate: 'Risk flag if ≥ +25% upside or ≤ -15% downside',
goodRange: { val: '+520%', label: 'Moderate consensus upside' },
neutralRange: { val: '05%', label: 'Fairly priced' },
badRange: { val: '< -10%', label: 'Analysts bearish' },
assetTypes: ['STOCK'],
},
// ── Quality ────────────────────────────────────────────────────────
{
key: 'ROE%',
label: 'Return on Equity',
category: 'Quality',
definition: 'Net income as a % of shareholders\' equity. Measures how efficiently management generates profit from invested capital.',
gate: 'Gate: ROE ≥ 15%',
goodRange: { val: '> 20%', label: 'Excellent capital efficiency' },
neutralRange: { val: '1020%', label: 'Adequate' },
badRange: { val: '< 10%', label: 'Poor capital use' },
assetTypes: ['STOCK'],
},
{
key: 'OpMgn%',
label: 'Operating Margin',
category: 'Quality',
definition: 'Operating profit as a % of revenue — what\'s left after COGS and operating expenses, before interest and taxes.',
gate: 'Gate: Op Margin ≥ 10%',
goodRange: { val: '> 20%', label: 'High quality business' },
neutralRange: { val: '520%', label: 'Modest margins' },
badRange: { val: '< 5%', label: 'Thin, fragile' },
assetTypes: ['STOCK'],
},
{
key: 'GrossM%',
label: 'Gross Margin',
category: 'Quality',
definition: 'Revenue minus cost of goods sold, as a %. Shows pricing power and production efficiency before overhead.',
gate: 'Informational — not a hard gate, used contextually',
goodRange: { val: '> 50%', label: 'Software / services quality' },
neutralRange: { val: '1550%', label: 'Moderate' },
badRange: { val: '< 15%', label: 'Commodity-like, price-taker' },
assetTypes: ['STOCK'],
},
{
key: 'FCF Yld%',
label: 'Free Cash Flow Yield',
category: 'Quality',
definition: 'Free cash flow per share divided by price — cash the business actually generates, expressed as a yield. Unlike earnings, FCF is hard to fake.',
gate: 'Gate: FCF > 0 (negative FCF = gate fail) | Weight: 3× in scoring',
goodRange: { val: '> 5%', label: 'Strong cash generation' },
neutralRange: { val: '05%', label: 'Weak positive' },
badRange: { val: '< 0%', label: 'Cash-burning' },
assetTypes: ['STOCK'],
},
{
key: 'Analyst',
label: 'Analyst Consensus Rating',
category: 'Quality',
definition: 'Wall Street average recommendation on a 15 scale (Yahoo). 1 = Strong Buy, 5 = Strong Sell. Requires ≥ 3 analysts for signal to fire.',
gate: '≤ 2.0 → full score | ≤ 3.0 → +1 | ≤ 4.0 → -1 | > 4.0 → negative score',
goodRange: { val: '1.02.5', label: 'Buy consensus' },
neutralRange: { val: '2.54.0', label: 'Neutral / Hold' },
badRange: { val: '> 4.0', label: 'Sell consensus' },
assetTypes: ['STOCK'],
},
{
key: 'Revenue',
label: 'Revenue Growth',
category: 'Quality',
definition: 'Year-over-year percentage change in total revenue. Measures whether the business is expanding its top line. A secondary scoring factor — positive growth adds to score, declining revenue subtracts.',
gate: 'Gate: Revenue growth > 0% for positive contribution | Weight: 2× in scoring',
goodRange: { val: '> 10%', label: 'Strong expansion' },
neutralRange: { val: '010%', label: 'Slow growth' },
badRange: { val: '< 0%', label: 'Shrinking top line' },
assetTypes: ['STOCK'],
},
// ── Risk ───────────────────────────────────────────────────────────
{
key: 'D/E',
label: 'Debt-to-Equity Ratio',
category: 'Risk',
definition: 'Total debt divided by shareholders\' equity. Measures financial leverage — how much borrowed money vs. owned capital the company uses.',
gate: 'Gate: D/E ≤ 1.5× | Tech: ≤ 2.0× | Financials: gate disabled (scored on P/B instead)',
goodRange: { val: '< 0.5×', label: 'Conservative' },
neutralRange: { val: '0.51.5×', label: 'Moderate' },
badRange: { val: '> 2.0×', label: 'High leverage risk' },
assetTypes: ['STOCK'],
},
{
key: '52W Chg',
label: '52-Week Price Change',
category: 'Risk',
definition: 'Total % price return over the past year. Captures trend strength and momentum.',
gate: 'Risk flag: ≥ +50% (at peak, reversal risk) | ≤ -30% (significant drawdown)',
goodRange: { val: '+530%', label: 'Steady uptrend' },
neutralRange: { val: '-5+5%', label: 'Flat / sideways' },
badRange: { val: '< -30%', label: 'Significant drawdown' },
assetTypes: ['STOCK'],
},
{
key: 'From High',
label: 'Distance from 52-Week High',
category: 'Risk',
definition: 'How far (%) the current price sits below the 52-week peak. Negative = below peak. A -15% reading means the stock is 15% off its high.',
gate: 'Risk flag if > -20% from high (at or near peak)',
goodRange: { val: '-525%', label: 'Healthy pullback' },
neutralRange: { val: '-2540%', label: 'Larger drawdown' },
badRange: { val: '03%', label: 'At peak, limited buffer' },
assetTypes: ['STOCK'],
},
// ── Signals ────────────────────────────────────────────────────────
{
key: 'Graham',
label: 'Graham (Fundamental) Score',
category: 'Signals',
definition: 'Strict value-investing score using fixed Graham gates: P/E ≤ 15×, PEG ≤ 1.0, D/E ≤ 1.5×, ROE ≥ 15%, FCF > 0. Does not adjust for market conditions — these thresholds never move.',
gate: 'All gates fixed regardless of S&P P/E or rate regime',
goodRange: { val: 'PASS', label: 'Passes all Graham gates' },
neutralRange: { val: 'PARTIAL', label: 'Passes some, fails others' },
badRange: { val: 'FAIL', label: 'Fails one or more hard gates' },
},
{
key: 'Mkt-Adj',
label: 'Market-Adjusted Score',
category: 'Signals',
definition: 'Relaxed scoring mode that calibrates gates to live market benchmarks. P/E gate = S&P P/E × 1.5 (or × 1.2 in HIGH rate regime). Reflects what is "acceptable" in today\'s market, not absolute value.',
gate: 'P/E gate: S&P P/E × 1.5 (NORMAL) or × 1.2 (HIGH) | Tech P/E: XLK P/E × 1.3',
goodRange: { val: 'PASS', label: 'Passes mkt-adjusted gates' },
neutralRange: { val: 'PARTIAL', label: 'Borderline vs live benchmarks' },
badRange: { val: 'FAIL', label: 'Fails even relaxed gates' },
},
{
key: 'signal',
label: 'Signal',
category: 'Signals',
definition: 'Overall recommendation derived by comparing Market-Adjusted and Graham (fundamental) scores.',
gate: 'Strong Buy = passes both | Momentum = passes Mkt-Adj only | Speculation = passes Mkt-Adj, fails Graham | Neutral = borderline | Avoid = fails both',
goodRange: { val: '✅ ⚡', label: 'Strong Buy / Momentum' },
neutralRange: { val: '🔄', label: 'Neutral — hold' },
badRange: { val: '⚠️ ❌', label: 'Speculation / Avoid' },
},
{
key: 'score',
label: 'Score (dot scale)',
category: 'Signals',
definition: 'Weighted sum of factor scores (ROE, FCF, margin, PEG, revenue growth, analyst, DCF). Displayed as ●●●●○ dots out of 5 + raw number.',
gate: 'Positive factors add to score; negative riskFlags subtract. Gate failures bypass scoring entirely (shown as ✗).',
goodRange: { val: '> 12', label: 'High conviction' },
neutralRange: { val: '612', label: 'Borderline' },
badRange: { val: '< 6', label: 'Weak factors' },
},
{
key: 'Cap Tier',
label: 'Market Cap Tier',
category: 'Signals',
definition: 'Size classification based on market capitalisation. Mega Cap (> $200B), Large ($10200B), Mid ($210B), Small ($300M$2B), Micro (< $300M).',
gate: 'Informational — not a gate. Useful for position sizing and risk calibration.',
goodRange: { val: 'Mega / Large', label: 'Most liquid' },
neutralRange: { val: 'Mid', label: 'Balanced' },
badRange: { val: 'Micro', label: 'High vol, thin liquidity' },
assetTypes: ['STOCK'],
},
{
key: 'Style',
label: 'Growth / Style Category',
category: 'Signals',
definition: 'Derived from revenue growth and earnings growth. High Growth (rev ≥ 15% or earnings ≥ 20%), Growth (515%), Value (< 5% + yield ≥ 3%), Stable, Turnaround, Declining.',
gate: 'Informational — not a gate. Helps match the stock to your strategy.',
goodRange: { val: 'High Growth / Growth', label: 'Matches momentum strategy' },
neutralRange: { val: 'Stable / Value', label: 'Income / defensive' },
badRange: { val: 'Declining', label: 'Revenue shrinking > -5%' },
assetTypes: ['STOCK'],
},
// ── ETF ────────────────────────────────────────────────────────────
{
key: 'Exp Ratio%',
label: 'Expense Ratio',
category: 'ETF',
definition: 'Annual management fee as a % of AUM. Deducted from returns automatically. Lower is always better — costs compound against you.',
gate: 'Hard gate: Expense Ratio ≤ 0.20%',
goodRange: { val: '< 0.10%', label: 'Index-like, minimal drag' },
neutralRange: { val: '0.100.50%', label: 'Acceptable' },
badRange: { val: '> 0.50%', label: 'High cost drag' },
assetTypes: ['ETF'],
},
{
key: '5Y Return%',
label: '5-Year Annualised Return',
category: 'ETF',
definition: 'Compound annual growth rate over 5 years. The S&P 500 long-run average is ~10%; use that as a baseline.',
gate: 'Gate: 5Y Return ≥ 8% (S&P long-run floor)',
goodRange: { val: '> 12%', label: 'Outperforming market' },
neutralRange: { val: '812%', label: 'Market-rate returns' },
badRange: { val: '< 6%', label: 'Underperforming bonds + inflation' },
assetTypes: ['ETF'],
},
{
key: 'Yield%',
label: 'Distribution Yield',
category: 'ETF',
definition: 'Annual income distributions (dividends, interest) as a % of NAV. Important for income-focused or REIT ETFs.',
gate: 'REIT ETF: Yield floor based on XLRE yield × regime factor',
goodRange: { val: '> 3%', label: 'Strong income' },
neutralRange: { val: '13%', label: 'Low but positive' },
badRange: { val: '< 1%', label: 'Insufficient for income' },
assetTypes: ['ETF'],
},
// ── Bond ───────────────────────────────────────────────────────────
{
key: 'YTM%',
label: 'Yield to Maturity',
category: 'Bond',
definition: 'Total return if you hold the bond to maturity — includes coupon payments plus any price gain/loss vs. par. The true all-in yield.',
gate: 'Spread gate: YTM must exceed risk-free rate by ≥ 1.5% (NORMAL) or ≥ 1.8% (HIGH rates)',
goodRange: { val: 'Sprd > 2%', label: 'Good compensation for risk' },
neutralRange: { val: '12%', label: 'Adequate spread' },
badRange: { val: '< 1%', label: 'Not compensating for credit risk' },
assetTypes: ['BOND'],
},
{
key: 'Duration',
label: 'Duration (years)',
category: 'Bond',
definition: 'Sensitivity to interest rate changes. A duration of 5 means a 1% rate rise → ~5% price drop. Shorter = less rate risk.',
gate: 'Gate: Duration ≤ 7 years',
goodRange: { val: '< 4 yrs', label: 'Low rate sensitivity' },
neutralRange: { val: '47 yrs', label: 'Moderate' },
badRange: { val: '> 10 yrs', label: 'High rate risk' },
assetTypes: ['BOND'],
},
{
key: 'Rating',
label: 'Credit Rating',
category: 'Bond',
definition: 'Agency rating of default probability: AAA (safest) → AA → A → BBB (investment grade floor) → BB → B → CCC (junk).',
gate: 'Hard gate: Rating ≥ BBB (investment-grade, numeric ≥ 7)',
goodRange: { val: 'AAAA', label: 'Very low default risk' },
neutralRange: { val: 'BBB', label: 'Investment-grade floor' },
badRange: { val: '≤ BB', label: 'High yield / junk' },
assetTypes: ['BOND'],
},
];
const CATEGORIES = ['Market Context', 'Valuation', 'Quality', 'Risk', 'Signals', 'ETF', 'Bond'] as const;
function filteredItems(): GlossaryItem[] {
const q = searchQuery.trim().toLowerCase();
if (!q) return GLOSSARY;
return GLOSSARY.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
item.definition.toLowerCase().includes(q) ||
item.category.toLowerCase().includes(q),
);
}
function itemsForCategory(cat: string): GlossaryItem[] {
return filteredItems().filter((i) => i.category === cat);
}
function isActive(item: GlossaryItem): boolean {
return activeMetrics.some(
(k) => k === item.key || k === item.label,
);
}
function toggleItem(key: string) {
expandedItem = expandedItem === key ? null : key;
}
// Close on Escape
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open}
<!-- Click-outside backdrop — thin, no visual overlay, just captures clicks -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="glossary-backdrop" onclick={onClose}></div>
<aside class="glossary-panel" aria-label="Metrics Glossary">
<!-- Header -->
<div class="glossary-header">
<span class="glossary-title"><span class="glossary-title-q">?</span> Metric Glossary</span>
<button class="glossary-close" onclick={onClose} aria-label="Close glossary">×</button>
</div>
<!-- Search -->
<div class="glossary-search-wrap">
<input
class="glossary-search"
type="text"
placeholder="Search metrics…"
bind:value={searchQuery}
aria-label="Search glossary"
/>
{#if searchQuery}
<button class="glossary-search-clear" onclick={() => (searchQuery = '')} aria-label="Clear search"></button>
{/if}
</div>
<!-- Context banner — fixed between search and body, only when row is selected -->
{#if activeMetrics.length > 0}
<div class="glossary-ctx-banner">
✦ Highlighted metrics are relevant to the selected row
</div>
{/if}
<!-- Body -->
<div class="glossary-body" bind:this={bodyEl}>
{#each CATEGORIES as cat}
{@const items = itemsForCategory(cat)}
{#if items.length > 0}
<div class="glossary-category">
<div class="glossary-cat-header">{cat}</div>
{#each items as item}
{@const active = isActive(item)}
{@const isExpanded = expandedItem === item.key}
<div
class="glossary-item"
class:glossary-item-active={active}
class:glossary-item-open={isExpanded}
data-gkey={item.key}
>
<button
class="glossary-item-trigger"
onclick={() => toggleItem(item.key)}
aria-expanded={isExpanded}
>
<span class="glossary-item-label">
{#if active}<span class="glossary-active-dot"></span>{/if}
{item.label}
</span>
<span class="glossary-cat-tag gcat-{cat.toLowerCase().replace(/\s/g,'-')}">{cat}</span>
</button>
{#if isExpanded}
<div class="glossary-item-body">
<p class="glossary-definition">{item.definition}</p>
{#if item.gate}
<div class="glossary-gate-box">
<code>{item.gate}</code>
</div>
{/if}
{#if item.goodRange || item.neutralRange || item.badRange}
<div class="glossary-range-pills">
{#if item.goodRange}
<span class="glossary-range-pill grange-good">{item.goodRange.val}</span>
{/if}
{#if item.neutralRange}
<span class="glossary-range-pill grange-neutral">{item.neutralRange.val}</span>
{/if}
{#if item.badRange}
<span class="glossary-range-pill grange-bad">{item.badRange.val}</span>
{/if}
</div>
<div class="glossary-range-labels">
{#if item.goodRange}
<span class="grlabel-good">{item.goodRange.label}</span>
{/if}
{#if item.neutralRange}
<span class="grlabel-neutral">{item.neutralRange.label}</span>
{/if}
{#if item.badRange}
<span class="grlabel-bad">{item.badRange.label}</span>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/each}
{#if filteredItems().length === 0}
<div class="glossary-empty">No metrics match "{searchQuery}"</div>
{/if}
</div>
</aside>
{/if}