merge: resolve conflicts keeping feature/bechmarks changes
This commit is contained in:
@@ -4,6 +4,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AuthResponse } from '$lib/types.js';
|
||||
import { authStore } from '$lib/stores/auth.store.svelte.js';
|
||||
|
||||
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* fetch() wrapper that automatically attaches the JWT Bearer token.
|
||||
* Use this for all API calls that require authentication.
|
||||
*/
|
||||
export function authFetch(url: string, init: RequestInit = {}): Promise<Response> {
|
||||
const token = authStore.token;
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
return fetch(url, { ...init, headers });
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<AuthResponse> {
|
||||
const res = await fetch(`${BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const { error } = await res.json().catch(() => ({ error: 'Login failed' }));
|
||||
throw new Error(error ?? 'Login failed');
|
||||
}
|
||||
return res.json() as Promise<AuthResponse>;
|
||||
}
|
||||
|
||||
export async function register(
|
||||
email: string,
|
||||
password: string,
|
||||
role: 'trader' | 'viewer' = 'viewer',
|
||||
inviteCode = '',
|
||||
): Promise<AuthResponse> {
|
||||
const res = await fetch(`${BASE}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, role, inviteCode }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const { error } = await res.json().catch(() => ({ error: 'Registration failed' }));
|
||||
throw new Error(error ?? 'Registration failed');
|
||||
}
|
||||
return res.json() as Promise<AuthResponse>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MarketCall, CalendarEvent, ScreenerResult } from '$lib/types.js';
|
||||
import { authFetch } from './auth.js';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
@@ -21,9 +22,8 @@ export async function createCall(payload: {
|
||||
tickers: string[];
|
||||
date?: string;
|
||||
}): Promise<MarketCall> {
|
||||
const res = await fetch(`${BASE}/calls`, {
|
||||
const res = await authFetch(`${BASE}/calls`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -31,7 +31,7 @@ export async function createCall(payload: {
|
||||
}
|
||||
|
||||
export async function deleteCall(id: string): Promise<{ ok: boolean }> {
|
||||
const res = await fetch(`${BASE}/calls/${id}`, { method: 'DELETE' });
|
||||
const res = await authFetch(`${BASE}/calls/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MarketContext, PortfolioHolding, PortfolioAdvice } from '$lib/types.js';
|
||||
import { authFetch } from './auth.js';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
@@ -9,7 +10,7 @@ export async function fetchPortfolio(): Promise<{
|
||||
netWorth: number | null;
|
||||
error?: string;
|
||||
}> {
|
||||
const res = await fetch(`${BASE}/finance/portfolio`);
|
||||
const res = await authFetch(`${BASE}/finance/portfolio`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
@@ -17,9 +18,8 @@ export async function fetchPortfolio(): Promise<{
|
||||
export async function addHolding(
|
||||
holding: PortfolioHolding,
|
||||
): Promise<{ holdings: PortfolioHolding[] }> {
|
||||
const res = await fetch(`${BASE}/finance/holdings`, {
|
||||
const res = await authFetch(`${BASE}/finance/holdings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(holding),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -27,15 +27,13 @@ export async function addHolding(
|
||||
}
|
||||
|
||||
export async function removeHolding(ticker: string): Promise<{ holdings: PortfolioHolding[] }> {
|
||||
const res = await fetch(`${BASE}/finance/holdings/${ticker}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const res = await authFetch(`${BASE}/finance/holdings/${ticker}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchMarketContext(): Promise<MarketContext> {
|
||||
const res = await fetch(`${BASE}/finance/market-context`);
|
||||
const res = await authFetch(`${BASE}/finance/market-context`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,5 +3,22 @@
|
||||
// Existing imports from '$lib/api.js' continue to work via api.ts re-export.
|
||||
|
||||
export { screenTickers, fetchCatalysts, analyzeTickers } from './screener.js';
|
||||
export {
|
||||
fetchProfile,
|
||||
fetchChart,
|
||||
fetchTickerNews,
|
||||
fetchSectorPulse,
|
||||
fetchSectorDetail,
|
||||
} from './screener.js';
|
||||
export type {
|
||||
CompanyProfile,
|
||||
PricePoint,
|
||||
TickerNewsStory,
|
||||
SectorPulse,
|
||||
SectorPulseEntry,
|
||||
SectorDetail,
|
||||
} from './screener.js';
|
||||
export { fetchPortfolio, addHolding, removeHolding, fetchMarketContext } from './finance.js';
|
||||
export { fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar } from './calls.js';
|
||||
export { login, register, authFetch } from './auth.js';
|
||||
export { fetchWatchlist, pinTicker, unpinTicker } from './watchlist.js';
|
||||
|
||||
@@ -19,6 +19,99 @@ export async function fetchCatalysts(): Promise<{ tickers: string[]; stories: Ca
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Ticker modal data (profile + chart + news) ─────────────────────────────
|
||||
|
||||
export interface AnalystTargets {
|
||||
mean: number | null;
|
||||
high: number | null;
|
||||
low: number | null;
|
||||
analysts: number | null;
|
||||
recommendationMean: number | null; // 1=Strong Buy … 5=Strong Sell
|
||||
upsidePct: number | null;
|
||||
}
|
||||
|
||||
export interface CompanyProfile {
|
||||
name: string;
|
||||
summary: string | null;
|
||||
sector: string | null;
|
||||
industry: string | null;
|
||||
website: string | null;
|
||||
employees: number | null;
|
||||
marketCap: number | null;
|
||||
currentPrice: number | null;
|
||||
targets?: AnalystTargets;
|
||||
}
|
||||
|
||||
export type ChartRange = '1d' | '5d' | '1mo' | '3mo' | '6mo' | 'ytd' | '1y' | '5y';
|
||||
|
||||
export interface PricePoint {
|
||||
date: string;
|
||||
close: number;
|
||||
}
|
||||
|
||||
export interface TickerNewsStory {
|
||||
headline: string;
|
||||
tickers: string[];
|
||||
source: string;
|
||||
catalyst: string | null;
|
||||
url: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
export async function fetchProfile(ticker: string): Promise<CompanyProfile | null> {
|
||||
const res = await fetch(`${BASE}/screen/profile/${encodeURIComponent(ticker)}`);
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { profile: CompanyProfile | null };
|
||||
return body.profile;
|
||||
}
|
||||
|
||||
export async function fetchChart(ticker: string, range: ChartRange = '6mo'): Promise<PricePoint[]> {
|
||||
const res = await fetch(`${BASE}/screen/chart/${encodeURIComponent(ticker)}?range=${range}`);
|
||||
if (!res.ok) return [];
|
||||
const body = (await res.json()) as { points: PricePoint[] };
|
||||
return body.points ?? [];
|
||||
}
|
||||
|
||||
export interface SectorPulseEntry {
|
||||
etf: string;
|
||||
sector: string; // internal constant: TECHNOLOGY, FINANCIAL, …
|
||||
name: string; // display name
|
||||
changePct: number | null;
|
||||
}
|
||||
|
||||
export interface SectorPulse {
|
||||
asOf: string | null;
|
||||
leader: SectorPulseEntry | null;
|
||||
sectors: SectorPulseEntry[];
|
||||
}
|
||||
|
||||
export async function fetchSectorPulse(): Promise<SectorPulse | null> {
|
||||
const res = await fetch(`${BASE}/screen/sectors`);
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface SectorDetail {
|
||||
sector: string;
|
||||
etf: string | null;
|
||||
name?: string;
|
||||
stocks: import('$lib/types.js').AssetResult[];
|
||||
news: TickerNewsStory[];
|
||||
}
|
||||
|
||||
export async function fetchSectorDetail(sector: string): Promise<SectorDetail | null> {
|
||||
const res = await fetch(`${BASE}/screen/sector/${encodeURIComponent(sector)}`);
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchTickerNews(ticker: string, days = 14): Promise<TickerNewsStory[]> {
|
||||
const res = await fetch(`${BASE}/news/${encodeURIComponent(ticker)}?days=${days}`);
|
||||
if (!res.ok) return [];
|
||||
const body = (await res.json()) as { stories: TickerNewsStory[] };
|
||||
return body.stories ?? [];
|
||||
}
|
||||
|
||||
export async function analyzeTickers(
|
||||
tickers: string[],
|
||||
): Promise<{ analysis: LLMAnalysis | null; reason?: string | null }> {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { authFetch } from './auth.js';
|
||||
|
||||
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
export async function fetchWatchlist(): Promise<{ tickers: string[] }> {
|
||||
const res = await authFetch(`${BASE}/api/watchlist`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function pinTicker(ticker: string): Promise<void> {
|
||||
const res = await authFetch(`${BASE}/api/watchlist/${encodeURIComponent(ticker)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export async function unpinTicker(ticker: string): Promise<void> {
|
||||
const res = await authFetch(`${BASE}/api/watchlist/${encodeURIComponent(ticker)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { CalendarEvent } from '$lib/types.js';
|
||||
|
||||
let { events }: { events: CalendarEvent[] } = $props();
|
||||
|
||||
type EventType = 'earnings' | 'exdividend' | 'dividend';
|
||||
const eventIcon = (t: EventType): string => ({ earnings: '📊', exdividend: '💰', dividend: '💵' })[t] ?? '📅';
|
||||
const eventColor = (t: EventType): string =>
|
||||
({ earnings: '#60a5fa', exdividend: '#facc15', dividend: '#4ade80' })[t] ?? '#94a3b8';
|
||||
|
||||
const fmtMoney = (n: number | null | undefined): string | null => n == null ? null :
|
||||
n >= 1e9 ? '$' + (n / 1e9).toFixed(1) + 'B' :
|
||||
n >= 1e6 ? '$' + (n / 1e6).toFixed(1) + 'M' : '$' + n.toFixed(2);
|
||||
|
||||
const upcoming = $derived(events.filter(e => !e.isPast).slice(0, 20));
|
||||
const past = $derived(events.filter(e => e.isPast).slice(0, 10));
|
||||
</script>
|
||||
|
||||
{#if events.length > 0}
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>📅 Upcoming Events</h2>
|
||||
<span class="count">{upcoming.length} upcoming</span>
|
||||
{#if past.length > 0}
|
||||
<span class="count" style="margin-left:4px">{past.length} recent</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="cal-grid">
|
||||
{#each upcoming as ev}
|
||||
<div class="cal-event">
|
||||
<div class="cal-date">{ev.date}</div>
|
||||
<div class="cal-content">
|
||||
<span class="cal-ticker">{ev.ticker}</span>
|
||||
<span class="cal-type" style="color:{eventColor(ev.type)}">
|
||||
{eventIcon(ev.type)} {ev.label}
|
||||
{#if ev.detail}<span class="cal-detail">· {ev.detail}</span>{/if}
|
||||
</span>
|
||||
{#if ev.epsEstimate != null}
|
||||
<span class="cal-est">EPS est. ${ev.epsEstimate.toFixed(2)} · Rev est. {fmtMoney(ev.revEstimate)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if past.length > 0}
|
||||
<div class="cal-divider">— Past —</div>
|
||||
{#each past as ev}
|
||||
<div class="cal-event past">
|
||||
<div class="cal-date">{ev.date}</div>
|
||||
<div class="cal-content">
|
||||
<span class="cal-ticker">{ev.ticker}</span>
|
||||
<span class="cal-type past-type">{eventIcon(ev.type)} {ev.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
interface TickerSnapshot {
|
||||
price: number | null;
|
||||
signal: string | null;
|
||||
}
|
||||
|
||||
interface MarketCall {
|
||||
id: string;
|
||||
title: string;
|
||||
quarter: string;
|
||||
date: string;
|
||||
thesis: string;
|
||||
tickers: string[];
|
||||
snapshot: Record<string, TickerSnapshot>;
|
||||
}
|
||||
|
||||
let {
|
||||
call,
|
||||
onDelete,
|
||||
}: {
|
||||
call: MarketCall;
|
||||
onDelete: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
const signalColor = (s: string | null | undefined): string => {
|
||||
if (s?.includes('Strong')) return '#4ade80';
|
||||
if (s?.includes('Momentum')) return '#60a5fa';
|
||||
if (s?.includes('Neutral')) return '#94a3b8';
|
||||
if (s?.includes('Speculation')) return '#fb923c';
|
||||
return '#f87171';
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="section call-card">
|
||||
<div class="section-header">
|
||||
<div class="call-card-meta">
|
||||
<a href="/calls/{call.id}" class="call-card-title">{call.title}</a>
|
||||
<div class="call-card-badges">
|
||||
<span class="tag">{call.quarter}</span>
|
||||
<span class="call-date-badge">{call.date}</span>
|
||||
<span class="count">{call.tickers.length} tickers</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-call-delete" onclick={() => onDelete(call.id)}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="call-card-body">
|
||||
<p class="call-thesis">{call.thesis}</p>
|
||||
|
||||
{#if Object.keys(call.snapshot ?? {}).length}
|
||||
<div class="snapshot-grid">
|
||||
{#each call.tickers as ticker}
|
||||
{@const snap = call.snapshot[ticker]}
|
||||
{#if snap}
|
||||
<a href="/calls/{call.id}" class="snap-card">
|
||||
<div class="snap-ticker">{ticker}</div>
|
||||
<div class="snap-price">${snap.price?.toFixed(2) ?? '—'}</div>
|
||||
<div class="snap-signal" style="color:{signalColor(snap.signal)}">
|
||||
{snap.signal?.replace(/[✅⚡⚠️🔄❌]/u, '').trim() ?? '—'}
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<a href="/calls/{call.id}" class="call-view-link">View performance →</a>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
|
||||
interface FormData {
|
||||
title: string;
|
||||
quarter: string;
|
||||
date: string;
|
||||
thesis: string;
|
||||
tickers: string;
|
||||
}
|
||||
|
||||
let {
|
||||
saving = false,
|
||||
error = null,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
saving?: boolean;
|
||||
error?: string | null;
|
||||
onSubmit: (data: FormData) => void;
|
||||
onCancel: () => void;
|
||||
} = $props();
|
||||
|
||||
function currentQuarter(): string {
|
||||
const d = new Date();
|
||||
return `Q${Math.ceil((d.getMonth() + 1) / 3)} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
let form = $state<FormData>({
|
||||
title: '',
|
||||
quarter: currentQuarter(),
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
thesis: '',
|
||||
tickers: '',
|
||||
});
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
onSubmit({ ...form });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="section form-section">
|
||||
<div class="section-header"><h2>New Market Call</h2></div>
|
||||
<form class="call-form" onsubmit={handleSubmit}>
|
||||
<div class="call-form-row">
|
||||
<label>
|
||||
<span>Title</span>
|
||||
<input bind:value={form.title} placeholder="Q3 2025 — Rate pivot & tech rotation" required />
|
||||
</label>
|
||||
<label class="narrow">
|
||||
<span>Quarter</span>
|
||||
<input bind:value={form.quarter} placeholder="Q3 2025" required />
|
||||
</label>
|
||||
<label class="narrow">
|
||||
<span>Date</span>
|
||||
<input type="date" bind:value={form.date} required />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>Thesis</span>
|
||||
<textarea
|
||||
bind:value={form.thesis}
|
||||
rows="4"
|
||||
placeholder="Describe the macro thesis behind this call — why these tickers, why now..."
|
||||
required
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Tickers to track</span>
|
||||
<input bind:value={form.tickers} placeholder="AAPL, MSFT, TLT, GLD …" required />
|
||||
<span class="call-hint">Comma or space separated. Current prices will be snapshot automatically.</span>
|
||||
</label>
|
||||
{#if error}
|
||||
<div class="form-error-block">⚠ {error}</div>
|
||||
{/if}
|
||||
<div class="call-form-actions">
|
||||
<button type="submit" class="btn-primary" disabled={saving}>
|
||||
{#if saving}<Spinner size="sm" /><span>Snapshotting prices…</span>
|
||||
{:else}Save Call{/if}
|
||||
</button>
|
||||
<button type="button" class="btn-ghost" onclick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as CallForm } from './CallForm.svelte';
|
||||
export { default as CallCard } from './CallCard.svelte';
|
||||
export { default as CalendarSection } from './CalendarSection.svelte';
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './shared/index.js';
|
||||
export * from './screener/index.js';
|
||||
export * from './portfolio/index.js';
|
||||
export * from './calls/index.js';
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { fmt, fmtShort } from '$lib/utils.js';
|
||||
import type { PersonalFinance } from '$lib/types.js';
|
||||
|
||||
let { pf }: { pf: PersonalFinance } = $props();
|
||||
</script>
|
||||
|
||||
<div class="pnl-grid">
|
||||
<div class="pnl-card"><div class="pnl-label">Net Worth</div>
|
||||
<div class="pnl-value {pf.netWorth >= 0 ? 'green' : 'red'}">{fmtShort(pf.netWorth)}</div></div>
|
||||
<div class="pnl-card"><div class="pnl-label">Total Assets</div>
|
||||
<div class="pnl-value">{fmtShort(pf.totalAssets)}</div></div>
|
||||
<div class="pnl-card"><div class="pnl-label">Liabilities</div>
|
||||
<div class="pnl-value red">{fmtShort(pf.totalLiabilities)}</div></div>
|
||||
<div class="pnl-card"><div class="pnl-label">Cash ({pf.cashPct}%)</div>
|
||||
<div class="pnl-value">{fmtShort(pf.totalCash)}</div></div>
|
||||
<div class="pnl-card"><div class="pnl-label">Investments ({pf.investPct}%)</div>
|
||||
<div class="pnl-value">{fmtShort(pf.totalInvestments)}</div></div>
|
||||
{#if pf.savingsRate != null}
|
||||
<div class="pnl-card"><div class="pnl-label">Savings Rate</div>
|
||||
<div class="pnl-value {parseFloat(pf.savingsRate) >= 20 ? 'green' : 'yellow'}">{pf.savingsRate}%</div></div>
|
||||
{/if}
|
||||
<div class="pnl-card"><div class="pnl-label">Monthly Income</div>
|
||||
<div class="pnl-value">{fmtShort(pf.totalIncome)}</div></div>
|
||||
<div class="pnl-card"><div class="pnl-label">Monthly Spend</div>
|
||||
<div class="pnl-value">{fmtShort(pf.totalSpend)}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="accounts-two-col">
|
||||
<section class="accounts-section">
|
||||
<h2>Accounts</h2>
|
||||
<table class="accounts-table">
|
||||
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th class="right">Balance</th></tr></thead>
|
||||
<tbody>
|
||||
{#each pf.accounts as a}
|
||||
<tr>
|
||||
<td class="ticker">{a.name}</td>
|
||||
<td><span class="tag">{a.type}</span></td>
|
||||
<td class="gray">{a.org}</td>
|
||||
<td class="num right {a.balance >= 0 ? 'green' : 'red'}">{fmt(a.balance)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="accounts-section">
|
||||
<h2>Spending — Last 30 Days</h2>
|
||||
<table class="accounts-table">
|
||||
<thead><tr><th>Category</th><th class="right">Amount</th><th class="right">%</th><th>Share</th></tr></thead>
|
||||
<tbody>
|
||||
{#each pf.categoryBreakdown.slice(0, 10) as c}
|
||||
<tr>
|
||||
<td>{c.category}</td>
|
||||
<td class="num right">{fmt(c.amount)}</td>
|
||||
<td class="num right gray">{c.pct}%</td>
|
||||
<td style="width:100px">
|
||||
<div class="spend-bar-bg"><div class="spend-bar-fill" style="width:{Math.min(c.pct,100)}%"></div></div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import type { HoldingFormData } from '$lib/types.js';
|
||||
|
||||
let {
|
||||
saving = false,
|
||||
error = null as string | null,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
saving?: boolean;
|
||||
error?: string | null;
|
||||
onSubmit: (data: HoldingFormData) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let form = $state({ ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' });
|
||||
|
||||
function handleSubmit() {
|
||||
const ticker = form.ticker.trim().toUpperCase();
|
||||
const shares = parseFloat(form.shares);
|
||||
const costBasis = parseFloat(form.costBasis) || 0;
|
||||
if (!ticker || !shares || shares <= 0) return;
|
||||
onSubmit({
|
||||
ticker,
|
||||
shares,
|
||||
costBasis,
|
||||
type: form.type as HoldingFormData['type'],
|
||||
source: form.source,
|
||||
});
|
||||
form = { ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="add-form">
|
||||
<div class="add-form-title">Add Holding</div>
|
||||
<div class="add-form-row">
|
||||
<div class="field">
|
||||
<label for="form-ticker">Ticker</label>
|
||||
<input id="form-ticker" bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-shares">Shares</label>
|
||||
<input id="form-shares" bind:value={form.shares} placeholder="10" type="number" min="0" step="any" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-cost">Cost Basis / share</label>
|
||||
<input id="form-cost" bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-type">Type</label>
|
||||
<select id="form-type" bind:value={form.type}>
|
||||
<option value="stock">Stock</option>
|
||||
<option value="etf">ETF</option>
|
||||
<option value="bond">Bond</option>
|
||||
<option value="crypto">Crypto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-source">Source</label>
|
||||
<input id="form-source" bind:value={form.source} placeholder="Robinhood" />
|
||||
</div>
|
||||
<button class="btn-form-save" onclick={handleSubmit} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button class="btn-form-cancel" onclick={onClose}>✕</button>
|
||||
</div>
|
||||
{#if error}
|
||||
<div class="form-error">⚠ {error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import SignalBadge from '$lib/components/shared/SignalBadge.svelte';
|
||||
import { sigOrd, fmt, fmtShort, glClass, advClass } from '$lib/utils.js';
|
||||
import type { AdviceRow } from '$lib/types.js';
|
||||
|
||||
export interface UpdateData {
|
||||
shares: number;
|
||||
costBasis: number;
|
||||
type: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
let {
|
||||
rows,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
rows: AdviceRow[];
|
||||
onUpdate: (ticker: string, data: UpdateData) => void;
|
||||
onDelete: (ticker: string) => void;
|
||||
} = $props();
|
||||
|
||||
// ── Sort ──────────────────────────────────────────────────────────
|
||||
let sortCol = $state('ticker');
|
||||
let sortDir = $state(1);
|
||||
|
||||
function toggleSort(col: string) {
|
||||
if (sortCol === col) sortDir = sortDir === 1 ? -1 : 1;
|
||||
else { sortCol = col; sortDir = 1; }
|
||||
}
|
||||
|
||||
const sortIcon = (col: string) => sortCol !== col ? '⇅' : sortDir === 1 ? '↑' : '↓';
|
||||
|
||||
const sorted = $derived.by(() => [...rows].sort((a, b) => {
|
||||
let av: string | number, bv: string | number;
|
||||
switch (sortCol) {
|
||||
case 'ticker': av = a.ticker; bv = b.ticker; break;
|
||||
case 'type': av = a.type ?? ''; bv = b.type ?? ''; break;
|
||||
case 'shares': av = a.shares ?? 0; bv = b.shares ?? 0; break;
|
||||
case 'cost': av = a.costBasis ?? 0; bv = b.costBasis ?? 0; break;
|
||||
case 'current': av = parseFloat(a.currentPrice ?? '0') || 0; bv = parseFloat(b.currentPrice ?? '0') || 0; break;
|
||||
case 'value': av = parseFloat(a.marketValue ?? '0') || 0; bv = parseFloat(b.marketValue ?? '0') || 0; break;
|
||||
case 'gl': av = parseFloat(a.gainLossPct ?? '0') || 0; bv = parseFloat(b.gainLossPct ?? '0') || 0; break;
|
||||
case 'signal': av = sigOrd(a.signal); bv = sigOrd(b.signal); break;
|
||||
default: return 0;
|
||||
}
|
||||
return av < bv ? -sortDir : av > bv ? sortDir : 0;
|
||||
}));
|
||||
|
||||
// ── Totals ────────────────────────────────────────────────────────
|
||||
const totalValue = $derived(rows.reduce((s, a) => s + (parseFloat(a.marketValue ?? '0') || 0), 0));
|
||||
const totalCost = $derived(rows.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0));
|
||||
const totalGL = $derived(totalValue - totalCost);
|
||||
|
||||
// ── Inline edit ───────────────────────────────────────────────────
|
||||
interface InlineEdit { ticker: string; shares: string; costBasis: string; type: string; source: string }
|
||||
let editing: InlineEdit | null = $state(null);
|
||||
let saving = $state(false);
|
||||
|
||||
function startEdit(a: AdviceRow) {
|
||||
editing = {
|
||||
ticker: a.ticker,
|
||||
shares: String(a.shares),
|
||||
costBasis: String(a.costBasis ?? 0),
|
||||
type: a.type ?? 'stock',
|
||||
source: a.source ?? 'Robinhood',
|
||||
};
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editing) return;
|
||||
saving = true;
|
||||
onUpdate(editing.ticker, {
|
||||
shares: parseFloat(editing.shares),
|
||||
costBasis: parseFloat(editing.costBasis) || 0,
|
||||
type: editing.type,
|
||||
source: editing.source,
|
||||
});
|
||||
editing = null;
|
||||
saving = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- P&L Summary -->
|
||||
<div class="pnl-grid">
|
||||
<div class="pnl-card">
|
||||
<div class="pnl-label-row">
|
||||
<span class="pnl-label">Total Value</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Current market value of all holdings. Shares × live price from Yahoo Finance.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pnl-value">{fmtShort(totalValue)}</div>
|
||||
</div>
|
||||
<div class="pnl-card">
|
||||
<div class="pnl-label-row">
|
||||
<span class="pnl-label">Total Cost</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Total amount invested — sum of cost basis × shares across all positions.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pnl-value">{fmtShort(totalCost)}</div>
|
||||
</div>
|
||||
<div class="pnl-card">
|
||||
<div class="pnl-label-row">
|
||||
<span class="pnl-label">Total G/L</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Total unrealised gain or loss — Total Value minus Total Cost.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pnl-value {totalGL >= 0 ? 'green' : 'red'}">{fmtShort(totalGL)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Holdings table -->
|
||||
<section class="advice-section">
|
||||
<h2>Holdings — Hold / Sell / Add Advice</h2>
|
||||
<table class="advice-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick={() => toggleSort('ticker')}>Ticker {sortIcon('ticker')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('type')}>Type {sortIcon('type')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('shares')}>Shares {sortIcon('shares')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('cost')}>Cost {sortIcon('cost')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('current')}>Current {sortIcon('current')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('value')}>Value {sortIcon('value')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('gl')}>G/L {sortIcon('gl')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('signal')}>Signal {sortIcon('signal')}</th>
|
||||
<th>Advice</th><th>Reason</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted as a}
|
||||
{@const isEditing = editing?.ticker === a.ticker}
|
||||
<tr class:editing={isEditing}>
|
||||
<td class="ticker">{a.ticker}</td>
|
||||
<td>
|
||||
{#if isEditing && editing}
|
||||
<select class="inline-select" bind:value={editing.type}>
|
||||
<option value="stock">stock</option>
|
||||
<option value="etf">etf</option>
|
||||
<option value="bond">bond</option>
|
||||
<option value="crypto">crypto</option>
|
||||
</select>
|
||||
{:else}
|
||||
<span class="tag">{a.type}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">
|
||||
{#if isEditing && editing}
|
||||
<input class="inline-input" bind:value={editing.shares} type="number" min="0" step="any" />
|
||||
{:else}
|
||||
{a.shares}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">
|
||||
{#if isEditing && editing}
|
||||
<input class="inline-input" bind:value={editing.costBasis} type="number" min="0" step="any" />
|
||||
{:else}
|
||||
{fmt(a.costBasis)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">{fmt(a.currentPrice != null ? parseFloat(a.currentPrice) : null)}</td>
|
||||
<td class="num">{fmt(a.marketValue != null ? parseFloat(a.marketValue) : null)}</td>
|
||||
<td class="num {glClass(a.gainLossPct)}">{a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
|
||||
<td>{#if a.signal}<SignalBadge signal={a.signal} />{:else}<span class="gray">—</span>{/if}</td>
|
||||
<td class={advClass(a.advice)}>{a.advice}</td>
|
||||
<td class="reason col-reason">{a.reason}</td>
|
||||
<td class="advice-row-actions">
|
||||
{#if isEditing}
|
||||
<button class="btn-save-inline" onclick={saveEdit} disabled={saving}>{saving ? '…' : '✓'}</button>
|
||||
<button class="btn-cancel-inline" onclick={() => editing = null}>✕</button>
|
||||
{:else}
|
||||
<button class="btn-row-edit" onclick={() => startEdit(a)} title="Edit">✎</button>
|
||||
<button class="btn-row-delete" onclick={() => onDelete(a.ticker)} title="Remove">✕</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as AddHoldingForm } from './AddHoldingForm.svelte';
|
||||
export { default as AdviceTable } from './AdviceTable.svelte';
|
||||
export { default as AccountsTable } from './AccountsTable.svelte';
|
||||
@@ -0,0 +1,596 @@
|
||||
<script lang="ts">
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
import type { SidebarState } from '$lib/types.js';
|
||||
|
||||
let { sidebar, onClose, onScreenTickers }: {
|
||||
sidebar: SidebarState;
|
||||
onClose: () => void;
|
||||
onScreenTickers?: (tickers: string[]) => void;
|
||||
} = $props();
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function sentimentClass(s: string) {
|
||||
if (s === 'BULLISH') return 'sent-bullish';
|
||||
if (s === 'BEARISH') return 'sent-bearish';
|
||||
return 'sent-neutral';
|
||||
}
|
||||
|
||||
function sentimentEmoji(s: string) {
|
||||
if (s === 'BULLISH') return '▲';
|
||||
if (s === 'BEARISH') return '▼';
|
||||
return '⊙';
|
||||
}
|
||||
|
||||
function sentimentLabel(s: string) {
|
||||
if (s === 'BULLISH') return 'Bullish';
|
||||
if (s === 'BEARISH') return 'Bearish';
|
||||
return 'Neutral';
|
||||
}
|
||||
|
||||
// Derive industry impact from reason text heuristically
|
||||
function industryImpact(reason: string): 'bear' | 'bull' | 'neut' {
|
||||
const r = reason.toLowerCase();
|
||||
const bearWords = ['weigh', 'pressure', 'risk', 'decline', 'weaken', 'concern', 'miss', 'delay', 'slowdown', 'threat', 'compress', 'reduce', 'cut', 'loss'];
|
||||
const bullWords = ['benefit', 'strength', 'tailwind', 'inflow', 'growth', 'gain', 'boost', 'rise', 'improve', 'outperform'];
|
||||
const bearScore = bearWords.filter(w => r.includes(w)).length;
|
||||
const bullScore = bullWords.filter(w => r.includes(w)).length;
|
||||
if (bearScore > bullScore) return 'bear';
|
||||
if (bullScore > bearScore) return 'bull';
|
||||
return 'neut';
|
||||
}
|
||||
|
||||
function biasClass(bias: string) {
|
||||
if (bias === 'BULL') return 'sig-bull';
|
||||
if (bias === 'BEAR') return 'sig-bear';
|
||||
return 'sig-neut';
|
||||
}
|
||||
|
||||
function biasLabel(bias: string) {
|
||||
if (bias === 'BULL') return '▲ BULLISH';
|
||||
if (bias === 'BEAR') return '▼ BEARISH';
|
||||
return '⊙ WATCH';
|
||||
}
|
||||
|
||||
// sensitivity 1–5 → confidence label + class
|
||||
function confLabel(s: number): string {
|
||||
if (s >= 4) return 'HIGH confidence';
|
||||
if (s >= 2) return 'MED confidence';
|
||||
return 'LOW confidence';
|
||||
}
|
||||
|
||||
function confClass(s: number): string {
|
||||
if (s >= 4) return 'conf-high';
|
||||
if (s >= 2) return 'conf-med';
|
||||
return 'conf-low';
|
||||
}
|
||||
|
||||
// sensitivity → confidence bar %
|
||||
function confPct(s: number): number {
|
||||
return Math.round((s / 5) * 100);
|
||||
}
|
||||
|
||||
// horizon → human label for catalyst tag
|
||||
function horizonLabel(h: string): string {
|
||||
if (h === 'SHORT') return 'Near-term';
|
||||
if (h === 'LONG') return 'Long-term';
|
||||
return 'Medium-term';
|
||||
}
|
||||
|
||||
function screenAll() {
|
||||
if (!sidebar.analysis) return;
|
||||
const tickers = sidebar.analysis.relatedTickers.map(rt => rt.ticker);
|
||||
onScreenTickers?.(tickers);
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Bold key phrases — wrap words > 6 chars that are all-caps or capitalised nouns
|
||||
// (simple heuristic: bold ticker-like tokens and numbers with %)
|
||||
function boldKeyTerms(text: string): string {
|
||||
// Bold anything that looks like a ticker (2–5 uppercase letters)
|
||||
return text.replace(/\b([A-Z]{2,5})\b/g, '<strong>$1</strong>');
|
||||
}
|
||||
|
||||
// Overall confidence from analysis: average sensitivity
|
||||
function overallConf(tickers: { sensitivity: number }[]): number {
|
||||
if (!tickers.length) return 50;
|
||||
return Math.round(tickers.reduce((s, t) => s + t.sensitivity, 0) / tickers.length / 5 * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if sidebar.open}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="sidebar-backdrop"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Close sidebar"
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||||
></div>
|
||||
|
||||
<!-- Panel -->
|
||||
<aside class="sidebar as-panel">
|
||||
<!-- Header -->
|
||||
<div class="sidebar-header as-header">
|
||||
<span class="as-icon">🤖</span>
|
||||
<span class="sidebar-title as-title">LLM Analysis</span>
|
||||
{#if sidebar.type}
|
||||
<span class="sidebar-type as-scope">{sidebar.type}S</span>
|
||||
{/if}
|
||||
<button class="sidebar-close" onclick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-body as-body">
|
||||
|
||||
{#if sidebar.loading}
|
||||
<div class="sidebar-loading">
|
||||
<Spinner size="lg" label="Analyzing tickers…" />
|
||||
</div>
|
||||
|
||||
{:else if sidebar.error}
|
||||
<div class="sidebar-error">{sidebar.error}</div>
|
||||
|
||||
{:else if sidebar.analysis}
|
||||
{@const a = sidebar.analysis}
|
||||
{@const conf = overallConf(a.relatedTickers ?? [])}
|
||||
|
||||
<!-- ── SENTIMENT HERO ── -->
|
||||
<div class="as-sentiment-hero">
|
||||
<div class="as-sent-top">
|
||||
<span class="as-sent-badge {sentimentClass(a.sentiment)}">
|
||||
{sentimentEmoji(a.sentiment)} {sentimentLabel(a.sentiment)}
|
||||
</span>
|
||||
<div class="as-sent-meta">
|
||||
<span class="as-sent-model">claude-sonnet</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- confidence bar -->
|
||||
<div class="as-conf-row">
|
||||
<span class="as-conf-label">Confidence</span>
|
||||
<div class="as-conf-track">
|
||||
<div class="as-conf-fill" style="width:{conf}%"></div>
|
||||
</div>
|
||||
<span class="as-conf-pct">{conf}%</span>
|
||||
</div>
|
||||
|
||||
<p class="as-summary">{a.summary}</p>
|
||||
</div>
|
||||
|
||||
<!-- ── AFFECTED INDUSTRIES ── -->
|
||||
{#if (a.affectedIndustries ?? []).length > 0}
|
||||
<div class="as-section">
|
||||
<div class="as-section-header">
|
||||
<span class="as-section-title">Affected Industries</span>
|
||||
<span class="as-section-count">{a.affectedIndustries.length}</span>
|
||||
<div class="as-section-divider"></div>
|
||||
</div>
|
||||
|
||||
<div class="as-industry-list">
|
||||
{#each a.affectedIndustries as ind}
|
||||
{@const impact = industryImpact(ind.reason)}
|
||||
<div class="as-ind-card {impact}">
|
||||
<div class="as-ind-top">
|
||||
<span class="as-ind-name">{ind.name}</span>
|
||||
{#if impact === 'bear'}
|
||||
<span class="as-impact-chip imp-bear">▼ BEAR</span>
|
||||
{:else if impact === 'bull'}
|
||||
<span class="as-impact-chip imp-bull">▲ BULL</span>
|
||||
{:else}
|
||||
<span class="as-impact-chip imp-neut">⊙ MIXED</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="as-ind-body">{@html boldKeyTerms(ind.reason)}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── RELATED TICKERS ── -->
|
||||
{#if (a.relatedTickers ?? []).length > 0}
|
||||
<div class="as-section">
|
||||
<div class="as-section-header">
|
||||
<span class="as-section-title">Tickers to Watch</span>
|
||||
<span class="as-section-count">{a.relatedTickers.length}</span>
|
||||
<div class="as-section-divider"></div>
|
||||
</div>
|
||||
|
||||
<div class="as-ticker-list">
|
||||
{#each a.relatedTickers as rt}
|
||||
<div class="as-tick-card">
|
||||
<div class="as-tick-top">
|
||||
<span class="as-tick-sym">{rt.ticker}</span>
|
||||
<span class="as-signal-chip {biasClass(rt.bias)}">{biasLabel(rt.bias)}</span>
|
||||
</div>
|
||||
<div class="as-tick-meta">
|
||||
<span class="as-conf-chip {confClass(rt.sensitivity)}">{confLabel(rt.sensitivity)}</span>
|
||||
<span
|
||||
class="as-score-tier"
|
||||
title="Sensitivity score: S{rt.sensitivity} = {rt.sensitivity}/5 — how directly this ticker is affected by the news catalyst"
|
||||
>S{rt.sensitivity}/5</span>
|
||||
<span class="as-horizon-chip">{horizonLabel(rt.horizon)}</span>
|
||||
</div>
|
||||
<p class="as-tick-thesis">{@html boldKeyTerms(rt.reason)}</p>
|
||||
<div class="as-catalyst-tag">⚡ {rt.horizon} horizon catalyst</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── SCREENER BRIDGE ── -->
|
||||
{#if onScreenTickers && (a.relatedTickers ?? []).length > 0}
|
||||
<div class="as-screener-prompt">
|
||||
<div class="as-sp-text">
|
||||
<strong>Screen these tickers</strong> to see current signals, scores, and gate results.
|
||||
</div>
|
||||
<button class="as-sp-btn" onclick={screenAll}>Screen All →</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* ── Sentiment hero ──────────────────────────────────────────────────── */
|
||||
.as-sentiment-hero {
|
||||
padding: 18px 16px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.as-sent-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.as-sent-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 24px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.sent-bullish { background: #0d2e1a; color: #34d17a; border: 1px solid #1a4a2a; }
|
||||
.sent-neutral { background: var(--blue-badge); color: var(--blue-muted); border: 1px solid #1a3a5c; }
|
||||
.sent-bearish { background: #2e0d0d; color: #f05a5a; border: 1px solid #4a1a1a; }
|
||||
|
||||
.as-sent-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.as-sent-model {
|
||||
font-size: 10px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-dimmer);
|
||||
font-family: var(--font-mono);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* confidence bar */
|
||||
.as-conf-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.as-conf-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dimmer);
|
||||
width: 68px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.as-conf-track {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.as-conf-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, var(--blue) 0%, #2dd4bf 100%);
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.as-conf-pct {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--blue-muted);
|
||||
width: 34px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.as-summary {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.as-summary :global(strong) { color: var(--text-secondary); font-weight: 600; }
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────────────────── */
|
||||
.as-section {
|
||||
padding: 14px 16px 0;
|
||||
}
|
||||
|
||||
.as-section:last-of-type {
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.as-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.as-section-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dimmer);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.as-section-count {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-dimmer);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.as-section-divider {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* ── Industry cards ──────────────────────────────────────────────────── */
|
||||
.as-industry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.as-ind-card {
|
||||
border-radius: 8px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
border-left-width: 3px;
|
||||
}
|
||||
|
||||
.as-ind-card.bear { border-left-color: #f05a5a; }
|
||||
.as-ind-card.bull { border-left-color: #34d17a; }
|
||||
.as-ind-card.neut { border-left-color: var(--border); }
|
||||
|
||||
.as-ind-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.as-ind-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.as-impact-chip {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.imp-bear { background: #2e0d0d; color: #f05a5a; border: 1px solid #4a1a1a; }
|
||||
.imp-bull { background: #0d2e1a; color: #34d17a; border: 1px solid #1a4a2a; }
|
||||
.imp-neut { background: var(--bg-elevated); color: var(--text-muted); border: 1px solid var(--border); }
|
||||
|
||||
.as-ind-body {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.as-ind-body :global(strong) { color: var(--text-secondary); font-weight: 600; }
|
||||
|
||||
/* ── Ticker cards ────────────────────────────────────────────────────── */
|
||||
.as-ticker-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.as-tick-card {
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.as-tick-card:hover {
|
||||
border-color: var(--border-input);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.as-tick-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.as-tick-sym {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.as-signal-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 20px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sig-bear { background: #2e0d0d; color: #f05a5a; border: 1px solid #4a1a1a; }
|
||||
.sig-bull { background: #0d2e1a; color: #34d17a; border: 1px solid #1a4a2a; }
|
||||
.sig-neut { background: var(--bg-elevated); color: var(--text-muted); border: 1px solid var(--border); }
|
||||
|
||||
.as-tick-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.as-conf-chip {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.conf-high { background: #0d2e1a; color: #34d17a; }
|
||||
.conf-med { background: #2e2000; color: #f0b429; }
|
||||
.conf-low { background: var(--bg-elevated); color: var(--text-dimmer); border: 1px solid var(--border); }
|
||||
|
||||
.as-score-tier {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dimmer);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
cursor: help;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.as-horizon-chip {
|
||||
font-size: 10px;
|
||||
color: var(--text-dimmer);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.as-tick-thesis {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.as-tick-thesis :global(strong) { color: var(--text-secondary); font-weight: 600; }
|
||||
|
||||
.as-catalyst-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #a78bfa;
|
||||
background: #1e1535;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #2d2050;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
/* ── Screener bridge ─────────────────────────────────────────────────── */
|
||||
.as-screener-prompt {
|
||||
margin: 4px 16px 16px;
|
||||
padding: 12px 14px;
|
||||
background: var(--blue-badge);
|
||||
border: 1px solid #1a3a5c;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.as-sp-text {
|
||||
font-size: 12px;
|
||||
color: var(--blue-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.as-sp-text :global(strong) { font-weight: 600; color: var(--blue-muted); }
|
||||
|
||||
.as-sp-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
background: var(--blue);
|
||||
color: #000;
|
||||
border: none;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.as-sp-btn:hover { background: #7ec0ff; }
|
||||
</style>
|
||||
@@ -0,0 +1,762 @@
|
||||
<script lang="ts">
|
||||
import { sigOrd, sorted, adviceFor, isQualityDip } from '$lib/utils.js';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
import GlossaryPanel from '$lib/components/screener/GlossaryPanel.svelte';
|
||||
import SignalModal from '$lib/components/screener/SignalModal.svelte';
|
||||
import TickerModal from '$lib/components/screener/TickerModal.svelte';
|
||||
import type { AssetType, AssetResult } from '$lib/types.js';
|
||||
import { watchlistStore } from '$lib/stores/watchlist.store.svelte.js';
|
||||
|
||||
let {
|
||||
type,
|
||||
rows,
|
||||
analyzeLoading = false,
|
||||
onAnalyze,
|
||||
}: {
|
||||
type: AssetType;
|
||||
rows: AssetResult[];
|
||||
analyzeLoading?: boolean;
|
||||
onAnalyze: () => void;
|
||||
} = $props();
|
||||
|
||||
let mode = $state('inflated');
|
||||
let expanded = $state<string | null>(null);
|
||||
let glossaryOpen = $state(false);
|
||||
let specModalRow = $state<AssetResult | null>(null);
|
||||
let tickerModal = $state<AssetResult | null>(null);
|
||||
let glossaryFocusKey = $state<string | null>(null);
|
||||
let sortCol = $state<string | null>(null);
|
||||
let sortAsc = $state(true);
|
||||
let filterTicker = $state('');
|
||||
let filterSignal = $state('');
|
||||
let filterStyle = $state('');
|
||||
let filterCap = $state('');
|
||||
let filterPriceMin = $state('');
|
||||
let filterPriceMax = $state('');
|
||||
let filterScoreMin = $state('');
|
||||
let filterFlags = $state(false);
|
||||
let filterTA = $state(false); // turnaround-watch only
|
||||
let filterQD = $state(false); // quality dips only
|
||||
|
||||
const STYLE_OPTIONS = ['High Growth', 'Growth', 'Value', 'Stable', 'Turnaround', 'Declining'];
|
||||
const CAP_OPTIONS = ['Mega Cap', 'Large Cap', 'Mid Cap', 'Small Cap', 'Micro Cap'];
|
||||
|
||||
function hasFilter() {
|
||||
return !!(filterTicker || filterSignal || filterStyle || filterCap || filterPriceMin || filterPriceMax || filterScoreMin || filterFlags || filterTA || filterQD);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filterTicker = ''; filterSignal = ''; filterStyle = ''; filterCap = '';
|
||||
filterPriceMin = ''; filterPriceMax = ''; filterScoreMin = ''; filterFlags = false; filterTA = false; filterQD = false;
|
||||
}
|
||||
|
||||
function filteredRows(rows: AssetResult[]): AssetResult[] {
|
||||
let out = rows;
|
||||
if (filterTA) {
|
||||
out = out.filter(r => r.turnaroundWatch);
|
||||
}
|
||||
if (filterQD) {
|
||||
out = out.filter(isQualityDip);
|
||||
}
|
||||
if (filterTicker.trim()) {
|
||||
const q = filterTicker.trim().toUpperCase();
|
||||
out = out.filter(r => r.asset.ticker.includes(q));
|
||||
}
|
||||
if (filterSignal) {
|
||||
out = out.filter(r => r.signal === filterSignal);
|
||||
}
|
||||
if (filterStyle) {
|
||||
out = out.filter(r => (r.asset.displayMetrics?.['Style'] ?? '') === filterStyle);
|
||||
}
|
||||
if (filterCap) {
|
||||
out = out.filter(r => (r.asset.displayMetrics?.['Cap Tier'] ?? '') === filterCap);
|
||||
}
|
||||
if (filterPriceMin !== '') {
|
||||
const min = parseFloat(filterPriceMin);
|
||||
out = out.filter(r => numVal(r.asset.displayMetrics?.['Price']) >= min);
|
||||
}
|
||||
if (filterPriceMax !== '') {
|
||||
const max = parseFloat(filterPriceMax);
|
||||
out = out.filter(r => numVal(r.asset.displayMetrics?.['Price']) <= max);
|
||||
}
|
||||
if (filterScoreMin !== '' && filterScoreMin !== null) {
|
||||
const min = Number(filterScoreMin);
|
||||
if (!isNaN(min)) {
|
||||
out = out.filter(r => {
|
||||
const v = r[mode as 'inflated' | 'fundamental'];
|
||||
const raw = v.scoreSummary ?? '';
|
||||
// Gate-failed rows have no numeric score — treat as 0
|
||||
const match = raw.match(/Score:\s*(\d+)/);
|
||||
const s = match ? parseInt(match[1], 10) : 0;
|
||||
return s >= min;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (filterFlags) {
|
||||
// Hide gate-failed (rejected) rows — use scoreSummary as it's always serialized
|
||||
out = out.filter(r => {
|
||||
const v = r[mode as 'inflated' | 'fundamental'];
|
||||
return !(v.scoreSummary ?? '').startsWith('Gate failed');
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toggleExpand(ticker: string) {
|
||||
expanded = expanded === ticker ? null : ticker;
|
||||
}
|
||||
|
||||
function setSort(col: string) {
|
||||
if (sortCol === col) {
|
||||
sortAsc = !sortAsc;
|
||||
} else {
|
||||
sortCol = col;
|
||||
sortAsc = col === 'ticker'; // text cols default asc; number cols default desc
|
||||
}
|
||||
expanded = null; // close any open row when re-sorting
|
||||
}
|
||||
|
||||
function sortIcon(col: string): string {
|
||||
if (sortCol !== col) return '⇅';
|
||||
return sortAsc ? '↑' : '↓';
|
||||
}
|
||||
|
||||
function numVal(s: string | number | undefined | null): number {
|
||||
if (s == null || s === '—') return -Infinity;
|
||||
return parseFloat(String(s).replace(/[%$,x]/g, '')) || 0;
|
||||
}
|
||||
|
||||
function sortedRows(rows: AssetResult[]): AssetResult[] {
|
||||
const base = filteredRows(rows);
|
||||
if (!sortCol) return sorted(base);
|
||||
const col = sortCol;
|
||||
const asc = sortAsc;
|
||||
return [...base].sort((a, b) => {
|
||||
const ma = a.asset.displayMetrics ?? {};
|
||||
const mb = b.asset.displayMetrics ?? {};
|
||||
const va = a[mode as 'inflated' | 'fundamental'];
|
||||
const vb = b[mode as 'inflated' | 'fundamental'];
|
||||
|
||||
let av: number | string = 0;
|
||||
let bv: number | string = 0;
|
||||
|
||||
if (col === 'ticker') {
|
||||
av = a.asset.ticker; bv = b.asset.ticker;
|
||||
} else if (col === 'price') {
|
||||
av = numVal(ma['Price']); bv = numVal(mb['Price']);
|
||||
} else if (col === 'signal') {
|
||||
av = sigOrd(a.signal); bv = sigOrd(b.signal);
|
||||
} else if (col === 'score') {
|
||||
av = numVal(va.scoreSummary); bv = numVal(vb.scoreSummary);
|
||||
} else if (col === 'cap') {
|
||||
const capOrder: Record<string, number> = { 'Mega Cap': 5, 'Large Cap': 4, 'Mid Cap': 3, 'Small Cap': 2, 'Micro Cap': 1 };
|
||||
av = capOrder[ma['Cap Tier'] as string] ?? 0;
|
||||
bv = capOrder[mb['Cap Tier'] as string] ?? 0;
|
||||
} else {
|
||||
// Generic display metric by display key
|
||||
const keyMap: Record<string, string> = {
|
||||
pe: 'P/E', peg: 'PEG', roe: 'ROE%', fcf: 'FCF Yld%',
|
||||
expense: 'Exp Ratio%', ret5y: '5Y Return%',
|
||||
rating: 'Rating', ytm: 'YTM%',
|
||||
};
|
||||
const metricKey = keyMap[col] ?? col;
|
||||
av = numVal(ma[metricKey]); bv = numVal(mb[metricKey]);
|
||||
}
|
||||
|
||||
if (typeof av === 'string' && typeof bv === 'string') {
|
||||
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||
}
|
||||
const diff = (av as number) - (bv as number);
|
||||
return asc ? diff : -diff;
|
||||
});
|
||||
}
|
||||
|
||||
function signClass(val: string | number | null | undefined): string {
|
||||
if (val == null) return '';
|
||||
const n = typeof val === 'number' ? val : parseFloat(String(val));
|
||||
if (isNaN(n)) return '';
|
||||
return n > 0 ? 'pos' : n < 0 ? 'neg' : '';
|
||||
}
|
||||
|
||||
// Derive the set of metric keys present in the currently-expanded row
|
||||
// so the glossary can highlight them contextually.
|
||||
const METRIC_KEYS_STOCK = ['P/E','PEG','ROE%','OpMgn%','GrossM%','FCF Yld%','D/E','52W Chg','From High','Analyst','Upside','DCF Safety'];
|
||||
const METRIC_KEYS_ETF = ['Exp Ratio%','5Y Return%','Yield%'];
|
||||
const METRIC_KEYS_BOND = ['YTM%','Duration','Rating'];
|
||||
|
||||
function activeGlossaryMetrics(): string[] {
|
||||
if (!expanded) return [];
|
||||
const row = rows.find((r) => r.asset.ticker === expanded);
|
||||
if (!row) return [];
|
||||
const m = row.asset.displayMetrics ?? {};
|
||||
const keys = type === 'STOCK' ? METRIC_KEYS_STOCK : type === 'ETF' ? METRIC_KEYS_ETF : METRIC_KEYS_BOND;
|
||||
return keys.filter((k) => m[k] != null && m[k] !== '—');
|
||||
}
|
||||
|
||||
function breakdownEntries(bd: Record<string, number> | undefined) {
|
||||
if (!bd) return [];
|
||||
return Object.entries(bd).sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
|
||||
}
|
||||
|
||||
function maxAbs(bd: Record<string, number> | undefined): number {
|
||||
if (!bd) return 1;
|
||||
const max = Math.max(...Object.values(bd).map(Math.abs));
|
||||
return max === 0 ? 1 : max;
|
||||
}
|
||||
|
||||
// Factor card helpers
|
||||
interface FactorCard {
|
||||
key: string; // glossary key
|
||||
name: string; // display name
|
||||
score: number;
|
||||
reason: string; // plain-English with embedded <b> tags
|
||||
pct: number; // bar width %
|
||||
}
|
||||
|
||||
const FACTOR_META: Record<string, { name: string; key: string; reason: (val: string | undefined, score: number, threshold?: string) => string }> = {
|
||||
'ROE': { name: 'Return on Equity', key: 'ROE%', reason: (v, s) => s > 0 ? `ROE <b>${v}</b> — above 15% threshold. Strong capital efficiency.` : `ROE <b>${v}</b> — below the 15% preferred threshold. Partial or no score.` },
|
||||
'opMargin': { name: 'Operating Margin', key: 'OpMgn%', reason: (v, s) => s > 0 ? `Op margin <b>${v}</b> — positive and above threshold. Efficient operations.` : `Op margin <b>${v}</b> — below preferred threshold (Gate: > 10%).` },
|
||||
'margin': { name: 'Gross Margin', key: 'GrossM%', reason: (v, s) => s > 0 ? `Gross margin <b>${v}</b> — strong pricing power.` : `Gross margin <b>${v}</b> — limited pricing power or high COGS.` },
|
||||
'peg': { name: 'PEG Ratio', key: 'PEG', reason: (v, s) => s > 0 ? `PEG <b>${v}</b> — below 1.0 threshold. Paying less than growth justifies. (Gate: < 1.0)` : `PEG <b>${v}</b> — above 1.0 threshold. Paying a growth premium. (Gate: < 1.0)` },
|
||||
'revenue': { name: 'Revenue Growth', key: 'Revenue', reason: (_v, s) => s > 0 ? `Revenue growing. Positive contribution to score.` : `Revenue growth below threshold or negative. Partial or no score.` },
|
||||
'fcf': { name: 'FCF Yield', key: 'FCF Yld%', reason: (v, s) => s > 0 ? `FCF yield <b>${v}</b> — strongly positive free cash flow. High weight metric. (Gate: > 0%)` : `FCF yield <b>${v}</b> — negative or zero free cash flow. Hard gate failure.` },
|
||||
'analyst': { name: 'Analyst Consensus', key: 'Analyst', reason: (v, s) => s > 0 ? `Rated <b>Buy</b> by Wall St. (Yahoo mean ≤ 2.5). Requires ≥ 3 analysts. Rating: ${v}.` : s < 0 ? `Analyst consensus <b>${v}</b> — bearish consensus or insufficient coverage.` : `Analyst consensus <b>${v}</b> — neutral range or fewer than 3 analysts.` },
|
||||
'dcf': { name: 'DCF Margin of Safety', key: 'DCF Safety', reason: (v, s) => s > 0 ? `Intrinsic value <b>${v} above</b> current price. Stock appears undervalued vs DCF model. (Gate: ≥ 20%)` : s < 0 ? `Stock priced <b>above</b> DCF intrinsic value. May be overvalued by the model.` : `DCF margin of safety near zero. No significant under/overvaluation signal.` },
|
||||
'cost': { name: 'Expense Ratio', key: 'Exp Ratio%', reason: (v, s) => s > 0 ? `Expense ratio <b>${v}</b> — low cost. Costs compound in your favour. (Gate: ≤ 0.20%)` : `Expense ratio <b>${v}</b> — above the 0.20% gate. Higher fees reduce long-run returns.` },
|
||||
'yield': { name: 'Distribution Yield', key: 'Yield%', reason: (v, s) => s > 0 ? `Yield <b>${v}</b> — strong income distribution.` : `Yield <b>${v}</b> — below preferred level.` },
|
||||
'volume': { name: 'Avg Daily Volume', key: 'Volume', reason: (_v, s) => s > 0 ? `Sufficient trading volume. Liquid, tradeable fund.` : `Low trading volume. Liquidity risk — spreads may be wide.` },
|
||||
'fiveYearReturn': { name: '5-Year Return', key: '5Y Return%', reason: (v, s) => s > 0 ? `5Y annualised return <b>${v}</b> — above the 8% S&P floor.` : `5Y return <b>${v}</b> — below the 8% gate. Underperforms long-run S&P average.` },
|
||||
'spread': { name: 'Credit Spread', key: 'YTM%', reason: (_v, s) => s > 0 ? `Yield spread above risk-free rate exceeds 1.5% gate. Adequate risk compensation.` : `Spread too narrow. Bond doesn't compensate enough for credit risk. (Gate: ≥ 1.5%)` },
|
||||
'duration': { name: 'Duration', key: 'Duration', reason: (v, s) => s > 0 ? `Duration <b>${v}y</b> — moderate interest rate risk. (Gate: ≤ 7y)` : `Duration <b>${v}y</b> — high interest rate sensitivity. (Gate: ≤ 7y)` },
|
||||
};
|
||||
|
||||
function verdictLabel(score: number): string {
|
||||
const s = Math.abs(score);
|
||||
const label = s >= 3 ? 'STRONG' : s >= 2 ? 'GOOD' : s >= 1 ? 'MODERATE' : 'NEUTRAL';
|
||||
if (score > 0) return `+${score} ${label}`;
|
||||
if (score < 0) return `${score} WEAK`;
|
||||
return '0 NEUTRAL';
|
||||
}
|
||||
|
||||
function verdictClass(score: number): string {
|
||||
if (score > 0) return 'fv-pos';
|
||||
if (score < 0) return 'fv-neg';
|
||||
return 'fv-neu';
|
||||
}
|
||||
|
||||
function factorCards(bd: Record<string, number> | undefined, displayMetrics: Record<string, unknown>): FactorCard[] {
|
||||
if (!bd) return [];
|
||||
const scale = maxAbs(bd);
|
||||
return Object.entries(bd)
|
||||
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))
|
||||
.map(([factor, score]) => {
|
||||
const meta = FACTOR_META[factor];
|
||||
const displayKey = meta?.key;
|
||||
const val = displayKey ? String(displayMetrics[displayKey] ?? '—') : '—';
|
||||
const reason = meta ? meta.reason(val, score) : `${factor}: score ${score}`;
|
||||
return {
|
||||
key: displayKey ?? factor,
|
||||
name: meta?.name ?? factor,
|
||||
score,
|
||||
reason,
|
||||
pct: Math.round((Math.abs(score) / scale) * 100),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function openSpecModal(row: AssetResult, e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
specModalRow = row;
|
||||
}
|
||||
|
||||
function openGlossaryTo(key: string) {
|
||||
glossaryFocusKey = key;
|
||||
glossaryOpen = true;
|
||||
}
|
||||
|
||||
function sigKey(signal: string | undefined): string {
|
||||
const s = signal ?? '';
|
||||
if (s.includes('Strong')) return 'strong';
|
||||
if (s.includes('Momentum')) return 'momentum';
|
||||
if (s.includes('Speculation')) return 'spec';
|
||||
if (s.includes('Neutral')) return 'neutral';
|
||||
return 'avoid';
|
||||
}
|
||||
|
||||
function googleNewsUrl(ticker: string): string {
|
||||
return `https://news.google.com/search?q=${encodeURIComponent(ticker + ' stock')}`;
|
||||
}
|
||||
|
||||
function yahooNewsUrl(ticker: string): string {
|
||||
return `https://finance.yahoo.com/quote/${encodeURIComponent(ticker)}/news/`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>{type}S</h2>
|
||||
<span class="count">{filteredRows(rows).length === rows.length ? rows.length : `${filteredRows(rows).length} / ${rows.length}`}</span>
|
||||
{#if type === 'STOCK'}
|
||||
<button
|
||||
class="ta-filter-btn"
|
||||
class:active={filterTA}
|
||||
onclick={() => (filterTA = !filterTA)}
|
||||
title="Turnaround style AND score improved vs the previous screen. Needs 2+ days of snapshot history per ticker (run screen:daily) — a candidate flag, not a prediction."
|
||||
>↗ Turnaround watch ({rows.filter(r => r.turnaroundWatch).length})</button>
|
||||
<button
|
||||
class="ta-filter-btn qd"
|
||||
class:active={filterQD}
|
||||
onclick={() => (filterQD = !filterQD)}
|
||||
title="Passes strict OR market-adjusted quality gates AND trades 10%+ below its 52-week high — solid companies knocked down, candidates to recover."
|
||||
>💎 Quality dips ({rows.filter(isQualityDip).length})</button>
|
||||
{/if}
|
||||
{#if hasFilter()}
|
||||
<button class="filter-clear-btn" onclick={clearFilters}>✕ Clear filters</button>
|
||||
{/if}
|
||||
|
||||
<div class="mode-tabs">
|
||||
<button class:active={mode === 'inflated'} onclick={() => mode = 'inflated'}>Mkt-Adjusted</button>
|
||||
<button class:active={mode === 'fundamental'} onclick={() => mode = 'fundamental'}>Graham</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn-glossary"
|
||||
class:btn-glossary-active={glossaryOpen}
|
||||
onclick={() => (glossaryOpen = !glossaryOpen)}
|
||||
title="Open metrics glossary"
|
||||
>
|
||||
? Glossary
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn-analyze"
|
||||
onclick={onAnalyze}
|
||||
disabled={analyzeLoading}
|
||||
title="AI analysis of news for these tickers"
|
||||
>
|
||||
{#if analyzeLoading}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
✦ Analyze
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="asset-table">
|
||||
<thead>
|
||||
<!-- ── Column headers ── -->
|
||||
<tr>
|
||||
<th class="col-expand"></th>
|
||||
<th class="col-ticker sort-th" onclick={() => setSort('ticker')}>
|
||||
<span class="col-tip" data-tip="Stock, ETF, or bond ticker symbol">Ticker</span>
|
||||
<span class="sort-icon">{sortIcon('ticker')}</span>
|
||||
</th>
|
||||
<th class="sort-th" onclick={() => setSort('price')}>
|
||||
<span class="col-tip" data-tip="Current market price">Price</span>
|
||||
<span class="sort-icon">{sortIcon('price')}</span>
|
||||
</th>
|
||||
<th class="sort-th" onclick={() => setSort('signal')}>
|
||||
<span class="col-tip" data-tip="Overall verdict: Strong Buy passes both fundamental and market-adjusted gates; Avoid fails both">Signal</span>
|
||||
<span class="sort-icon">{sortIcon('signal')}</span>
|
||||
</th>
|
||||
<th class="sort-th" onclick={() => setSort('score')}>
|
||||
<span class="col-tip" data-tip="Weighted factor score (ROE, FCF, margins, PEG, analyst, DCF). Shown as dots out of 5 + raw number. ✗ means gate failed before scoring.">Score</span>
|
||||
<span class="sort-icon">{sortIcon('score')}</span>
|
||||
</th>
|
||||
{#if type === 'STOCK'}
|
||||
<th class="sort-th" onclick={() => setSort('cap')}>
|
||||
<span class="col-tip" data-tip="Market cap tier: Mega (>$200B), Large ($10–200B), Mid ($2–10B), Small ($300M–$2B), Micro (<$300M)">Cap</span>
|
||||
<span class="sort-icon">{sortIcon('cap')}</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="col-tip" data-tip="Growth/style: High Growth (rev ≥15%), Growth (5–15%), Value (low growth + yield ≥3%), Stable, Turnaround, Declining">Style</span>
|
||||
</th>
|
||||
<th>
|
||||
<span class="col-tip" data-tip="Risk flags: momentum extremes, valuation outliers, analyst divergence, DCF divergence. Hover the badge to see individual flags.">Flags</span>
|
||||
</th>
|
||||
{:else if type === 'ETF'}
|
||||
<th class="sort-th" onclick={() => setSort('expense')}>
|
||||
<span class="col-tip" data-tip="Annual management fee as % of AUM. Gate: ≤ 0.20%. Lower is always better — costs compound against returns.">Expense</span>
|
||||
<span class="sort-icon">{sortIcon('expense')}</span>
|
||||
</th>
|
||||
<th class="sort-th" onclick={() => setSort('ret5y')}>
|
||||
<span class="col-tip" data-tip="5-year annualised return. Gate: ≥ 8% (S&P long-run floor). Benchmark: S&P 500 ≈ 10% historically.">5Y Ret</span>
|
||||
<span class="sort-icon">{sortIcon('ret5y')}</span>
|
||||
</th>
|
||||
{:else}
|
||||
<th class="sort-th" onclick={() => setSort('rating')}>
|
||||
<span class="col-tip" data-tip="Credit rating: AAA → BBB = investment grade. Gate: ≥ BBB. BB and below = junk / high yield.">Rating</span>
|
||||
<span class="sort-icon">{sortIcon('rating')}</span>
|
||||
</th>
|
||||
<th class="sort-th" onclick={() => setSort('ytm')}>
|
||||
<span class="col-tip" data-tip="Yield to Maturity — total return if held to maturity. Must exceed risk-free rate by ≥ 1.5% (spread gate).">YTM</span>
|
||||
<span class="sort-icon">{sortIcon('ytm')}</span>
|
||||
</th>
|
||||
{/if}
|
||||
</tr>
|
||||
|
||||
<!-- ── Inline filter row ── -->
|
||||
<tr class="filter-row">
|
||||
<td></td>
|
||||
<td class="col-ticker">
|
||||
<input class="th-filter" type="text" placeholder="Ticker…" bind:value={filterTicker} />
|
||||
</td>
|
||||
<td>
|
||||
<div class="th-filter-pair">
|
||||
<input class="th-filter th-filter-num" type="number" placeholder="$ min" bind:value={filterPriceMin} />
|
||||
<input class="th-filter th-filter-num" type="number" placeholder="$ max" bind:value={filterPriceMax} />
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select class="th-filter" bind:value={filterSignal}>
|
||||
<option value="">All signals</option>
|
||||
<option value="✅ Strong Buy">Strong Buy</option>
|
||||
<option value="⚡ Momentum">Momentum</option>
|
||||
<option value="⚠️ Speculation">Speculation</option>
|
||||
<option value="🔄 Neutral">Neutral</option>
|
||||
<option value="❌ Avoid">Avoid</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input class="th-filter th-filter-num" type="number" placeholder="Score ≥" min="0" max="20" bind:value={filterScoreMin} />
|
||||
</td>
|
||||
{#if type === 'STOCK'}
|
||||
<td>
|
||||
<select class="th-filter" bind:value={filterCap}>
|
||||
<option value="">All caps</option>
|
||||
{#each CAP_OPTIONS as c}<option value={c}>{c}</option>{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select class="th-filter" bind:value={filterStyle}>
|
||||
<option value="">All styles</option>
|
||||
{#each STYLE_OPTIONS as s}<option value={s}>{s}</option>{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<label class="th-filter-check" title="Show only rows that passed all gates">
|
||||
<input type="checkbox" bind:checked={filterFlags} />
|
||||
<span>Passed only</span>
|
||||
</label>
|
||||
</td>
|
||||
{:else}
|
||||
<td></td>
|
||||
<td></td>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedRows(rows) as r}
|
||||
{@const m = r.asset.displayMetrics ?? {}}
|
||||
{@const v = r[mode as 'inflated' | 'fundamental']}
|
||||
{@const isOpen = expanded === r.asset.ticker}
|
||||
{@const colCount = type === 'STOCK' ? 8 : 7}
|
||||
{@const flags = v.audit?.riskFlags ?? []}
|
||||
{@const rawScore = v.score ?? parseInt(v.scoreSummary?.match(/-?\d+/)?.[0] ?? '0', 10)}
|
||||
{@const cov = v.audit?.coverage}
|
||||
{@const noData = cov != null && cov.active === 0}
|
||||
|
||||
<!-- ── Summary row ── -->
|
||||
<tr
|
||||
class="data-row summary-row"
|
||||
class:row-open={isOpen}
|
||||
data-signal={sigOrd(r.signal)}
|
||||
onclick={() => toggleExpand(r.asset.ticker)}
|
||||
>
|
||||
<td class="col-expand">
|
||||
<span class="row-toggle">{isOpen ? '▾' : '▸'}</span>
|
||||
<button
|
||||
class="pin-btn"
|
||||
class:pinned={watchlistStore.isPinned(r.asset.ticker)}
|
||||
onclick={(e) => { e.stopPropagation(); watchlistStore.toggle(r.asset.ticker); }}
|
||||
title={watchlistStore.isPinned(r.asset.ticker) ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||
>{watchlistStore.isPinned(r.asset.ticker) ? '📌' : '🔖'}</button>
|
||||
</td>
|
||||
<td class="ticker">
|
||||
<button
|
||||
class="ticker-btn"
|
||||
onclick={(e) => { e.stopPropagation(); tickerModal = r; }}
|
||||
title="Company details, chart & news"
|
||||
>{r.asset.ticker}</button>
|
||||
{#if r.turnaroundWatch}
|
||||
<span class="ta-badge" title="Turnaround watch: style is Turnaround AND score improved vs previous screen. A candidate flag — not a prediction.">↗ TA</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">{m.Price ?? '—'}</td>
|
||||
<!-- Signal pill + plain-language advice -->
|
||||
<td>
|
||||
<div class="signal-verdict-cell">
|
||||
<button
|
||||
class="sv-pill sv-{sigKey(r.signal)} sv-pill-link"
|
||||
onclick={(e) => { if (r.signal) openSpecModal(r, e); }}
|
||||
title="Click to explain this signal"
|
||||
>
|
||||
{(r.signal ?? '').replace(/^[^\w\s]+\s*/, '').trim() || '—'}
|
||||
</button>
|
||||
{#if r.signal}
|
||||
{@const adv = adviceFor(r)}
|
||||
{#if adv.addsInfo}
|
||||
<div class="advice-line advice-{adv.tone}" title={adv.detail}>{adv.text}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<!-- Score as dot scale -->
|
||||
<td class="score-cell" title={cov ? `${v.scoreSummary} — ${cov.active}/${cov.total} factors had data` : v.scoreSummary}>
|
||||
{#if v.scoreSummary?.startsWith('Gate failed')}
|
||||
<span class="score-fail">✗</span>
|
||||
{:else if noData}
|
||||
<span class="score-nodata">No data</span>
|
||||
{:else}
|
||||
<span class="score-dots">
|
||||
{#each Array(5) as _, i}
|
||||
<span class="score-dot" class:on={rawScore > 0 && i < Math.round(rawScore / 4)}></span>
|
||||
{/each}
|
||||
</span>
|
||||
<span class="score-num">{rawScore}</span>
|
||||
{#if cov && cov.active / cov.total < 0.5}
|
||||
<span class="score-cov" title="Only {cov.active} of {cov.total} scoring factors had data — treat this score with caution">{cov.active}/{cov.total}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</td>
|
||||
{#if type === 'STOCK'}
|
||||
<td><span class="tag sm cap-tag">{m['Cap Tier'] ?? '—'}</span></td>
|
||||
<td><span class="tag sm style-tag">{m['Style'] ?? '—'}</span></td>
|
||||
<!-- Flags: count badge with hover expand tooltip -->
|
||||
<td class="flags-cell">
|
||||
{#if flags.length > 0}
|
||||
<div class="flags-badge">
|
||||
<span class="flags-count">⚠ {flags.length}</span>
|
||||
<div class="flags-tooltip">
|
||||
<div class="flags-tt-title">Risk Flags</div>
|
||||
{#each flags as flag}
|
||||
<div class="flags-tt-item">⚠ {flag}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
{:else if type === 'ETF'}
|
||||
<td class="num">{m['Exp Ratio%'] ?? '—'}</td>
|
||||
<td class="num">{m['5Y Return%'] ?? '—'}</td>
|
||||
{:else}
|
||||
<td class="num">{m['Rating'] ?? '—'}</td>
|
||||
<td class="num">{m['YTM%'] ?? '—'}</td>
|
||||
{/if}
|
||||
</tr>
|
||||
|
||||
<!-- ── Inline detail row ── -->
|
||||
{#if isOpen}
|
||||
{@const mktPass = r.inflated.audit?.passedGates}
|
||||
{@const grahamPass = r.fundamental.audit?.passedGates}
|
||||
<tr class="detail-row">
|
||||
<td colspan={colCount} class="detail-cell">
|
||||
<div class="detail-panel">
|
||||
|
||||
<!-- ══ LEFT — metric grid ══════════════════════════════════ -->
|
||||
<div class="dp-left">
|
||||
<div class="dp-title">Metrics <span class="dp-mode-note">— click any card for full definition</span></div>
|
||||
<div class="dp-metric-grid">
|
||||
{#if type === 'STOCK'}
|
||||
{@const failures = [...(r.inflated.audit?.failures ?? []), ...(r.fundamental.audit?.failures ?? [])]}
|
||||
{@const failedKeys = failures.map(f => f.toLowerCase())}
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('P/E')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('P/E')} class:dp-mc-fail={failedKeys.some(f => f.includes('p/e'))} class:dp-mc-pass={!failedKeys.some(f => f.includes('p/e')) && m['P/E'] && m['P/E'] !== '—'}>
|
||||
<span class="dp-mc-label">P/E <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value">{m['P/E'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('PEG')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('PEG')} class:dp-mc-fail={failedKeys.some(f => f.includes('peg'))} class:dp-mc-pass={m['PEG'] !== '—' && m['PEG'] != null && parseFloat(String(m['PEG'])) < 1.0}>
|
||||
<span class="dp-mc-label">PEG <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value">{m['PEG'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('ROE%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('ROE%')} class:dp-mc-fail={failedKeys.some(f => f.includes('roe'))} class:dp-mc-pass={parseFloat(String(m['ROE%'])) >= 15}>
|
||||
<span class="dp-mc-label">ROE% <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value {signClass(m['ROE%'])}">{m['ROE%'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('OpMgn%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('OpMgn%')} class:dp-mc-fail={failedKeys.some(f => f.includes('margin') || f.includes('op'))} class:dp-mc-pass={parseFloat(String(m['OpMgn%'])) >= 10}>
|
||||
<span class="dp-mc-label">Op Mgn% <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value {signClass(m['OpMgn%'])}">{m['OpMgn%'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('GrossM%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('GrossM%')}>
|
||||
<span class="dp-mc-label">Gross M% <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value {signClass(m['GrossM%'])}">{m['GrossM%'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('FCF Yld%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('FCF Yld%')} class:dp-mc-fail={failedKeys.some(f => f.includes('fcf') || f.includes('cash'))} class:dp-mc-pass={parseFloat(String(m['FCF Yld%'])) > 0}>
|
||||
<span class="dp-mc-label">FCF Yld% <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value {signClass(m['FCF Yld%'])}">{m['FCF Yld%'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('D/E')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('D/E')} class:dp-mc-fail={failedKeys.some(f => f.includes('debt') || f.includes('d/e'))} class:dp-mc-pass={parseFloat(String(m['D/E'])) <= 1.5}>
|
||||
<span class="dp-mc-label">D/E <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value">{m['D/E'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('52W Chg')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('52W Chg')}>
|
||||
<span class="dp-mc-label">52W Chg <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value {signClass(m['52W Chg'])}">{m['52W Chg'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('From High')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('From High')}>
|
||||
<span class="dp-mc-label">From High <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value {signClass(m['From High'])}">{m['From High'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('Analyst')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('Analyst')}>
|
||||
<span class="dp-mc-label">Analyst <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value">{m['Analyst'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('Analyst')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('Analyst')}>
|
||||
<span class="dp-mc-label">Upside <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value {signClass(m['Upside'])}">{m['Upside'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('DCF Safety')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('DCF Safety')} class:dp-mc-pass={parseFloat(String(m['DCF Safety'])) >= 20}>
|
||||
<span class="dp-mc-label">DCF Safety <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value {signClass(m['DCF Safety'])}">{m['DCF Safety'] ?? '—'}</span>
|
||||
</div>
|
||||
{:else if type === 'ETF'}
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('Yield%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('Yield%')}>
|
||||
<span class="dp-mc-label">Yield% <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value">{m['Yield%'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('Exp Ratio%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('Exp Ratio%')}>
|
||||
<span class="dp-mc-label">AUM <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value">{m['AUM'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('5Y Return%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('5Y Return%')}>
|
||||
<span class="dp-mc-label">5Y Ret% <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value {signClass(m['5Y Return%'])}">{m['5Y Return%'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('Exp Ratio%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('Exp Ratio%')} class:dp-mc-pass={parseFloat(String(m['Exp Ratio%'])) <= 0.2} class:dp-mc-fail={parseFloat(String(m['Exp Ratio%'])) > 0.2}>
|
||||
<span class="dp-mc-label">Exp Ratio% <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value">{m['Exp Ratio%'] ?? '—'}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('YTM%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('YTM%')}>
|
||||
<span class="dp-mc-label">YTM% <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value">{m['YTM%'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('Duration')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('Duration')} class:dp-mc-fail={parseFloat(String(m['Duration'])) > 7} class:dp-mc-pass={parseFloat(String(m['Duration'])) <= 7}>
|
||||
<span class="dp-mc-label">Duration <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value">{m['Duration'] ?? '—'}</span>
|
||||
</div>
|
||||
<div class="dp-metric-card" onclick={() => openGlossaryTo('Rating')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('Rating')}>
|
||||
<span class="dp-mc-label">Rating <span class="dp-mc-help">?</span></span>
|
||||
<span class="dp-mc-value">{m['Rating'] ?? '—'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Gate badge chips — show which mode passed/failed + the failing rule ── -->
|
||||
<div class="dp-gates-row">
|
||||
<span class="dp-gate-chip" class:dp-gate-chip-pass={mktPass} class:dp-gate-chip-fail={!mktPass}>
|
||||
MKT {mktPass ? '✓' : '✗'}{#if !mktPass && r.inflated.audit?.failures?.[0]} — {r.inflated.audit.failures[0]}{/if}
|
||||
</span>
|
||||
<span class="dp-gate-chip" class:dp-gate-chip-pass={grahamPass} class:dp-gate-chip-fail={!grahamPass}>
|
||||
GRAHAM {grahamPass ? '✓' : '✗'}{#if !grahamPass && r.fundamental.audit?.failures?.[0]} — {r.fundamental.audit.failures[0]}{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Risk flag pills ── -->
|
||||
{#if v.audit?.riskFlags?.length}
|
||||
<div class="dp-risk-row">
|
||||
{#each v.audit.riskFlags as flag}
|
||||
<span class="dp-risk-pill">⚠ {flag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── News links ── -->
|
||||
<div class="dp-news-row">
|
||||
<span class="dp-news-label">News:</span>
|
||||
<a href={googleNewsUrl(r.asset.ticker)} target="_blank" rel="noopener noreferrer" class="dp-news-link">
|
||||
Google News ↗
|
||||
</a>
|
||||
<a href={yahooNewsUrl(r.asset.ticker)} target="_blank" rel="noopener noreferrer" class="dp-news-link">
|
||||
Yahoo Finance ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ RIGHT — factor score cards ════════════════════════ -->
|
||||
<div class="dp-right">
|
||||
<div class="dp-title">
|
||||
Factor Scores
|
||||
<span class="dp-mode-note">({mode === 'inflated' ? 'Mkt-Adj' : 'Graham'}) — click to learn more</span>
|
||||
</div>
|
||||
|
||||
{#if !v.audit?.passedGates && v.audit?.failures?.length}
|
||||
<!-- Gate failures shown when gates didn't pass -->
|
||||
<div class="dp-failures">
|
||||
{#each v.audit.failures as f}
|
||||
<div class="dp-failure-item">✗ {f}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if factorCards(v.audit?.breakdown, m).length}
|
||||
{@const cards = factorCards(v.audit?.breakdown, m)}
|
||||
<div class="dp-factor-list">
|
||||
{#each cards as card}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="dp-factor-item" role="button" tabindex="0" onclick={() => openGlossaryTo(card.key)} onkeypress={(e) => e.key === 'Enter' && openGlossaryTo(card.key)}>
|
||||
<div class="dp-factor-top">
|
||||
<span class="dp-factor-name">{card.name}</span>
|
||||
<span class="dp-factor-verdict {verdictClass(card.score)}">{verdictLabel(card.score)}</span>
|
||||
</div>
|
||||
<div class="dp-factor-reason">{@html card.reason}</div>
|
||||
<div class="dp-bar-track">
|
||||
<div class="dp-bar-fill {card.score > 0 ? 'dp-bar-pos' : 'dp-bar-neg'}" style="width:{card.pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="dp-no-factors">No factor data — gates failed before scoring</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<tr class="empty-row">
|
||||
<td colspan="10">
|
||||
{#if filterTA && rows.length > 0}
|
||||
No turnaround-watch stocks right now. The ↗ flag needs: Turnaround style AND a score
|
||||
that improved vs the previous screen — so it requires 2+ days of snapshot history
|
||||
(run the daily screen) and at least one Turnaround-style stock in your results.
|
||||
{:else if filterQD && rows.length > 0}
|
||||
No quality dips right now: nothing you screened both passes a quality gate AND
|
||||
trades 10%+ below its 52-week high. That's a real answer, not an error.
|
||||
{:else if hasFilter()}
|
||||
No rows match the active filters.
|
||||
{:else}
|
||||
No results yet — run a screen.
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Glossary panel — z-index 99, below tearsheet/analysis sidebar (z 101) -->
|
||||
<GlossaryPanel
|
||||
open={glossaryOpen}
|
||||
activeMetrics={activeGlossaryMetrics()}
|
||||
focusKey={glossaryFocusKey}
|
||||
onClose={() => { glossaryOpen = false; glossaryFocusKey = null; }}
|
||||
/>
|
||||
|
||||
<!-- Signal modal — explains why? for any signal -->
|
||||
<SignalModal
|
||||
open={specModalRow !== null}
|
||||
row={specModalRow}
|
||||
onClose={() => (specModalRow = null)}
|
||||
/>
|
||||
|
||||
<!-- Ticker modal — company profile, price chart, latest news -->
|
||||
{#if tickerModal}
|
||||
<TickerModal
|
||||
ticker={tickerModal.asset.ticker}
|
||||
advice={adviceFor(tickerModal)}
|
||||
onClose={() => (tickerModal = null)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -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 | 2–5% NORMAL | > 5% HIGH. HIGH rates compress growth stock P/E multipliers.',
|
||||
goodRange: { val: '2–4%', label: 'Normal, accommodative' },
|
||||
neutralRange: { val: '4–5%', 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 | 15–25 NORMAL | > 25 ELEVATED | > 35 EXTREME',
|
||||
goodRange: { val: '< 15', label: 'Calm market, low fear' },
|
||||
neutralRange: { val: '15–25', 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 2–5% | 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: '15–35×', 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.0–2.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 | 0–20% → +1 | -20–0% → -1 | < -20% → negative score',
|
||||
goodRange: { val: '> +20%', label: 'Significant discount' },
|
||||
neutralRange: { val: '0–20%', 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: '+5–20%', label: 'Moderate consensus upside' },
|
||||
neutralRange: { val: '0–5%', 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: '10–20%', 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: '5–20%', 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: '15–50%', 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: '0–5%', 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 1–5 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.0–2.5', label: 'Buy consensus' },
|
||||
neutralRange: { val: '2.5–4.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: '0–10%', 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.5–1.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: '+5–30%', 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: '-5–25%', label: 'Healthy pullback' },
|
||||
neutralRange: { val: '-25–40%', label: 'Larger drawdown' },
|
||||
badRange: { val: '0–3%', 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: '6–12', 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 ($10–200B), Mid ($2–10B), 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 (5–15%), 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.10–0.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: '8–12%', 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: '1–3%', 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: '1–2%', 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: '4–7 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: 'AAA–A', 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}
|
||||
@@ -0,0 +1,174 @@
|
||||
<script lang="ts">
|
||||
import { screenerStore } from '$lib/stores/screener.store.svelte.js';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
import TickerModal from '$lib/components/screener/TickerModal.svelte';
|
||||
import { adviceFor } from '$lib/utils.js';
|
||||
import type { AssetResult } from '$lib/types.js';
|
||||
|
||||
const s = screenerStore;
|
||||
|
||||
let sortBy = $state<'today' | 'year'>('today');
|
||||
let tickerModal = $state<AssetResult | null>(null);
|
||||
|
||||
function num(v: string | number | null | undefined): number {
|
||||
if (v == null || v === '—') return -Infinity;
|
||||
const n = parseFloat(String(v).replace(/[%$,+]/g, ''));
|
||||
return Number.isFinite(n) ? n : -Infinity;
|
||||
}
|
||||
|
||||
const sortedStocks = $derived.by(() => {
|
||||
const stocks = s.sectorDetail?.stocks ?? [];
|
||||
const key = sortBy === 'today' ? 'Day %' : '52W Chg';
|
||||
return [...stocks].sort(
|
||||
(a, b) => num(b.asset.displayMetrics?.[key]) - num(a.asset.displayMetrics?.[key]),
|
||||
);
|
||||
});
|
||||
|
||||
const pulseEntry = $derived(
|
||||
s.sectorPulse?.sectors.find((sec) => sec.sector === s.sectorFilter) ?? null,
|
||||
);
|
||||
|
||||
function signCls(v: string | number | null | undefined): string {
|
||||
const n = num(v);
|
||||
return n === -Infinity ? '' : n >= 0 ? 'pos' : 'neg';
|
||||
}
|
||||
|
||||
// Same signal→class mapping AssetTable uses for sv-pill colors
|
||||
function sigKey(signal: string | undefined): string {
|
||||
const sig = signal ?? '';
|
||||
if (sig.includes('Strong')) return 'strong';
|
||||
if (sig.includes('Momentum')) return 'momentum';
|
||||
if (sig.includes('Speculation')) return 'spec';
|
||||
if (sig.includes('Neutral')) return 'neutral';
|
||||
return 'avoid';
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if s.sectorFilter}
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>{(s.sectorDetail?.name ?? s.sectorFilter).toUpperCase()} — TOP HOLDINGS</h2>
|
||||
<span class="count">{sortedStocks.length}</span>
|
||||
{#if pulseEntry?.changePct != null}
|
||||
<span class="secp-pct" class:pos={pulseEntry.changePct >= 0} class:neg={pulseEntry.changePct < 0}>
|
||||
{pulseEntry.changePct >= 0 ? '+' : ''}{pulseEntry.changePct.toFixed(2)}% today
|
||||
</span>
|
||||
{/if}
|
||||
{#if s.sectorDetail?.etf}
|
||||
<span class="secp-etf">via {s.sectorDetail.etf}</span>
|
||||
{/if}
|
||||
|
||||
<div class="mode-tabs">
|
||||
<button class:active={sortBy === 'today'} onclick={() => (sortBy = 'today')}>Today's gain</button>
|
||||
<button class:active={sortBy === 'year'} onclick={() => (sortBy = 'year')}>1Y gain</button>
|
||||
</div>
|
||||
|
||||
<button class="btn-ghost secp-close" onclick={() => void s.selectSector(null)} title="Close sector panel">✕</button>
|
||||
</div>
|
||||
|
||||
{#if s.sectorDetailLoading}
|
||||
<div class="secp-loading"><Spinner size="md" label="Screening sector holdings…" /></div>
|
||||
{:else if s.sectorDetail}
|
||||
{#if sortedStocks.length > 0}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticker</th>
|
||||
<th class="num">Price</th>
|
||||
<th class="num">Today</th>
|
||||
<th class="num">1Y</th>
|
||||
<th>Signal</th>
|
||||
<th>Advice</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedStocks as r}
|
||||
{@const m = r.asset.displayMetrics ?? {}}
|
||||
{@const adv = adviceFor(r)}
|
||||
<tr>
|
||||
<td class="ticker">
|
||||
<button class="ticker-btn" onclick={() => (tickerModal = r)} title="Company details, chart & news">
|
||||
{r.asset.ticker}
|
||||
</button>
|
||||
</td>
|
||||
<td class="num">{m['Price'] ?? '—'}</td>
|
||||
<td class="num {signCls(m['Day %'])}">{m['Day %'] ?? '—'}</td>
|
||||
<td class="num {signCls(m['52W Chg'])}">{m['52W Chg'] ?? '—'}</td>
|
||||
<td>
|
||||
<span class="sv-pill sv-{sigKey(r.signal)}">
|
||||
{(r.signal ?? '—').replace(/^[^\w\s]+\s*/, '').trim()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="advice-line advice-{adv.tone}" title={adv.detail}>{adv.text}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="secp-empty">Couldn't load holdings for this sector right now.</div>
|
||||
{/if}
|
||||
|
||||
<div class="secp-news-block">
|
||||
<div class="secp-news-title">Recent sector news (3 days)</div>
|
||||
{#if s.sectorDetail.news.length > 0}
|
||||
<ul class="secp-news">
|
||||
{#each s.sectorDetail.news.slice(0, 6) as story}
|
||||
<li>
|
||||
<a href={story.url} target="_blank" rel="noopener noreferrer">{story.headline}</a>
|
||||
<span class="secp-news-meta">{story.tickers.join(', ')} · {fmtDate(story.publishedAt)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div class="secp-empty">
|
||||
No stored stories for these tickers in the last 3 days — often the honest answer is
|
||||
"no sector-specific catalyst; the whole market moved." News accumulates as the pollers run.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tickerModal}
|
||||
<TickerModal
|
||||
ticker={tickerModal.asset.ticker}
|
||||
advice={adviceFor(tickerModal)}
|
||||
onClose={() => (tickerModal = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Only panel-specific bits — table/section/pill styling comes from the
|
||||
global design system so this matches every other table on the page. */
|
||||
.secp-pct { font-family: var(--font-mono); font-size: 12px; }
|
||||
.secp-pct.pos { color: var(--green); }
|
||||
.secp-pct.neg { color: var(--red); }
|
||||
.secp-etf { font-size: 10.5px; color: var(--text-muted); }
|
||||
.secp-close { margin-left: 8px; }
|
||||
.secp-loading { display: grid; place-items: center; min-height: 90px; }
|
||||
|
||||
td.pos { color: var(--green); }
|
||||
td.neg { color: var(--red); }
|
||||
|
||||
.secp-news-block { padding: 10px var(--space-xl) 14px; }
|
||||
.secp-news-title {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dimmer);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.secp-news { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.secp-news a { font-size: 12px; color: var(--text-secondary); text-decoration: none; }
|
||||
.secp-news a:hover { color: var(--blue); }
|
||||
.secp-news-meta { font-size: 10px; color: var(--text-muted); margin-left: 8px; }
|
||||
.secp-empty { font-size: 11.5px; color: var(--text-muted); font-style: italic; padding: 10px var(--space-xl); }
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import { screenerStore } from '$lib/stores/screener.store.svelte.js';
|
||||
|
||||
const s = screenerStore;
|
||||
|
||||
function toggleSector(sector: string) {
|
||||
void s.selectSector(s.sectorFilter === sector ? null : sector);
|
||||
}
|
||||
|
||||
function fmtPct(v: number | null): string {
|
||||
if (v == null) return '—';
|
||||
return `${v >= 0 ? '+' : ''}${v.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
const asOfLabel = $derived(
|
||||
s.sectorPulse?.asOf
|
||||
? new Date(s.sectorPulse.asOf).toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mp-band">
|
||||
{#if s.sectorPulseLoading}
|
||||
<div class="mp-head">
|
||||
<span class="mp-eyebrow">Market pulse</span>
|
||||
<span class="mp-asof">loading sector data…</span>
|
||||
</div>
|
||||
{:else if !s.sectorPulse || s.sectorPulse.sectors.length === 0}
|
||||
<div class="mp-head">
|
||||
<span class="mp-eyebrow">Market pulse</span>
|
||||
<span class="mp-asof">sector data unavailable right now — retrying on next page load</span>
|
||||
</div>
|
||||
{:else}
|
||||
{@const pulse = s.sectorPulse}
|
||||
<div class="mp-head">
|
||||
<span class="mp-eyebrow">Market pulse</span>
|
||||
{#if pulse.leader}
|
||||
<span class="mp-leader">
|
||||
{pulse.leader.name} leads today
|
||||
<span class="mp-leader-pct">{fmtPct(pulse.leader.changePct)}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if asOfLabel}
|
||||
<span class="mp-asof">sector ETFs · {asOfLabel}</span>
|
||||
{/if}
|
||||
{#if s.sectorFilter}
|
||||
<button class="mp-clear" onclick={() => void s.selectSector(null)}>
|
||||
✕ Close {pulse.sectors.find((x) => x.sector === s.sectorFilter)?.name ?? 'sector'} panel
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mp-bubbles">
|
||||
{#each pulse.sectors as sec}
|
||||
<button
|
||||
class="mp-bubble"
|
||||
class:up={sec.changePct != null && sec.changePct >= 0}
|
||||
class:down={sec.changePct != null && sec.changePct < 0}
|
||||
class:active={s.sectorFilter === sec.sector}
|
||||
onclick={() => toggleSector(sec.sector)}
|
||||
title="{sec.name} ({sec.etf}): {fmtPct(sec.changePct)} today — click to open the sector panel (top holdings + news). Does not filter the tables below."
|
||||
>
|
||||
<span class="mp-bubble-pct">{fmtPct(sec.changePct)}</span>
|
||||
<span class="mp-bubble-name">{sec.name}</span>
|
||||
<span class="mp-bubble-etf">{sec.etf}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Full-bleed page header band — stretches across main's padding so it
|
||||
reads as page chrome, not as another table widget. */
|
||||
.mp-band {
|
||||
margin: -28px -32px 22px;
|
||||
padding: 12px 32px 14px;
|
||||
background: var(--bg-elevated, #0e1626);
|
||||
border-bottom: 1px solid var(--border, #1e293b);
|
||||
}
|
||||
|
||||
.mp-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mp-eyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-dimmer, #3d5166);
|
||||
}
|
||||
|
||||
.mp-leader {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.mp-leader-pct {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--green, #4ade80);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.mp-asof {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted, #3d5166);
|
||||
}
|
||||
|
||||
.mp-clear {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: 1px solid var(--border, #1e293b);
|
||||
border-radius: 10px;
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mp-clear:hover { color: var(--red, #f87171); border-color: var(--red, #f87171); }
|
||||
|
||||
/* ── Bubble cards ── */
|
||||
.mp-bubbles {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
/* A scroll container clips vertically too — leave room for the 1px
|
||||
hover lift and the 1px active ring so card tops never get shaved. */
|
||||
padding: 3px 2px 4px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.mp-bubble {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
min-width: 96px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border, #1e293b);
|
||||
background: var(--bg-card, #111a2c);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.12s,
|
||||
background 0.15s;
|
||||
flex-shrink: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mp-bubble:hover { transform: translateY(-1px); border-color: var(--text-muted, #3d5166); }
|
||||
|
||||
.mp-bubble.up { background: rgba(74, 222, 128, 0.05); }
|
||||
.mp-bubble.down { background: rgba(248, 113, 113, 0.05); }
|
||||
|
||||
.mp-bubble.active {
|
||||
border-color: var(--blue, #60a5fa);
|
||||
box-shadow: 0 0 0 1px var(--blue, #60a5fa);
|
||||
background: rgba(96, 165, 250, 0.08);
|
||||
}
|
||||
|
||||
.mp-bubble-pct {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mp-bubble.up .mp-bubble-pct { color: var(--green, #4ade80); }
|
||||
.mp-bubble.down .mp-bubble-pct { color: var(--red, #f87171); }
|
||||
|
||||
.mp-bubble-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #94a3b8);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mp-bubble-etf {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dimmer, #3d5166);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,396 @@
|
||||
<script lang="ts">
|
||||
import type { AssetResult } from '$lib/types.js';
|
||||
|
||||
let {
|
||||
open = false,
|
||||
row,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
row: AssetResult | null;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
function handleBackdrop(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
|
||||
type SignalKey = 'strong' | 'momentum' | 'spec' | 'neutral' | 'avoid';
|
||||
|
||||
function sigKey(signal: string | undefined): SignalKey {
|
||||
const s = signal ?? '';
|
||||
if (s.includes('Strong')) return 'strong';
|
||||
if (s.includes('Momentum')) return 'momentum';
|
||||
if (s.includes('Speculation')) return 'spec';
|
||||
if (s.includes('Neutral')) return 'neutral';
|
||||
return 'avoid';
|
||||
}
|
||||
|
||||
const SIGNAL_META: Record<SignalKey, {
|
||||
emoji: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tip: string;
|
||||
color: 'green' | 'blue' | 'amber' | 'gray' | 'red';
|
||||
}> = {
|
||||
strong: {
|
||||
emoji: '✅',
|
||||
title: 'Strong Buy',
|
||||
summary: 'Passes both market-adjusted AND Graham\'s fundamental gates. The stock meets strict value criteria regardless of current market conditions. This is the highest confidence signal.',
|
||||
tip: 'Strong Buy signals have the widest margin of safety. Size position accordingly — typically 3–5% of portfolio.',
|
||||
color: 'green',
|
||||
},
|
||||
momentum: {
|
||||
emoji: '⚡',
|
||||
title: 'Momentum',
|
||||
summary: 'Passes market-adjusted gates but only partially satisfies Graham criteria. The stock is attractively priced relative to today\'s market, though it wouldn\'t clear value investors\' stricter standards. Suitable for trend-following strategies.',
|
||||
tip: 'Momentum signals ride the current market environment. They work until they don\'t — set a stop-loss or monitor quarterly.',
|
||||
color: 'blue',
|
||||
},
|
||||
spec: {
|
||||
emoji: '⚠️',
|
||||
title: 'Speculation',
|
||||
summary: 'Passes market-adjusted gates but fails Graham\'s fundamental rules. The stock is acceptable at today\'s elevated valuations, but would not meet a strict value investor\'s criteria. Treat as a growth or momentum play — not a deep value position.',
|
||||
tip: 'Speculation signals are valid positions in a bull market. Size smaller than a Strong Buy and monitor P/E compression risk.',
|
||||
color: 'amber',
|
||||
},
|
||||
neutral: {
|
||||
emoji: '🔄',
|
||||
title: 'Neutral',
|
||||
summary: 'Borderline — passes some gates, fails others, or falls in hold territory in one or both scoring lenses. Not a strong buy or a clear sell. Suitable for existing positions you\'re monitoring, but not a new entry signal.',
|
||||
tip: 'Neutral means "hold, don\'t add." If you own it, keep watching the metrics that are borderline.',
|
||||
color: 'gray',
|
||||
},
|
||||
avoid: {
|
||||
emoji: '❌',
|
||||
title: 'Avoid',
|
||||
summary: 'Fails both market-adjusted AND fundamental scoring lenses. The stock does not meet quality gates or the risk-adjusted return does not justify the price. This is a high-risk entry.',
|
||||
tip: 'Avoid signals are not necessarily bad businesses — they may be great companies at bad prices. Revisit when the verdict changes.',
|
||||
color: 'red',
|
||||
},
|
||||
};
|
||||
|
||||
function gateRows(row: AssetResult | null) {
|
||||
if (!row) return [];
|
||||
const m = row.asset.displayMetrics ?? {};
|
||||
const graham = row.fundamental.audit;
|
||||
|
||||
const items: { pass: boolean; label: string; detail: string; value: string }[] = [];
|
||||
|
||||
const pe = m['P/E'];
|
||||
if (pe && pe !== '—') {
|
||||
const peNum = parseFloat(String(pe).replace('×',''));
|
||||
const grahamFail = graham.failures?.some((f: string) => f.toLowerCase().includes('p/e'));
|
||||
const mktPass = row.inflated.audit?.passedGates;
|
||||
if (!isNaN(peNum)) {
|
||||
items.push({
|
||||
pass: !grahamFail,
|
||||
label: 'P/E Ratio',
|
||||
detail: grahamFail ? `Graham gate (15×) failed${mktPass ? ' — mkt-adjusted passed' : ''}` : 'Passes both P/E gates',
|
||||
value: `${pe}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const peg = m['PEG'];
|
||||
if (peg && peg !== '—') {
|
||||
const pegNum = parseFloat(String(peg));
|
||||
if (!isNaN(pegNum)) {
|
||||
items.push({
|
||||
pass: pegNum < 1.0,
|
||||
label: 'PEG Ratio',
|
||||
detail: pegNum < 1.0 ? 'Below 1.0 — growth is reasonably priced' : 'Above 1.0 — paying a premium for growth',
|
||||
value: `${peg}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const de = m['D/E'];
|
||||
if (de && de !== '—') {
|
||||
const deNum = parseFloat(String(de));
|
||||
if (!isNaN(deNum)) {
|
||||
items.push({
|
||||
pass: deNum < 1.5,
|
||||
label: 'Debt / Equity',
|
||||
detail: deNum < 1.5 ? 'Conservative leverage' : 'Elevated leverage — adds risk',
|
||||
value: `${de}×`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fcf = m['FCF Yld%'];
|
||||
if (fcf && fcf !== '—') {
|
||||
const fcfNum = parseFloat(String(fcf));
|
||||
if (!isNaN(fcfNum)) {
|
||||
items.push({
|
||||
pass: fcfNum > 0,
|
||||
label: 'FCF Yield',
|
||||
detail: fcfNum > 0 ? 'Positive free cash flow' : 'Negative FCF — company is burning cash',
|
||||
value: `${fcf}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const roe = m['ROE%'];
|
||||
if (roe && roe !== '—') {
|
||||
const roeNum = parseFloat(String(roe));
|
||||
if (!isNaN(roeNum)) {
|
||||
items.push({
|
||||
pass: roeNum > 15,
|
||||
label: 'Return on Equity',
|
||||
detail: roeNum > 15 ? 'Above 15% — good capital efficiency' : 'Below 15% preferred threshold',
|
||||
value: `${roe}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if open && row}
|
||||
{@const sk = sigKey(row.signal)}
|
||||
{@const meta = SIGNAL_META[sk]}
|
||||
{@const m = row.asset.displayMetrics ?? {}}
|
||||
{@const mktPass = row.inflated.audit?.passedGates}
|
||||
{@const graOk = row.fundamental.audit?.passedGates}
|
||||
{@const gates = gateRows(row)}
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="sm-backdrop" onclick={handleBackdrop}>
|
||||
<div class="sm-modal sm-modal-{meta.color}">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="sm-header">
|
||||
<div>
|
||||
<div class="sm-title">{meta.emoji} Why "{meta.title}"?</div>
|
||||
<div class="sm-sub">{row.asset.ticker} · {m['Price'] ?? ''}</div>
|
||||
</div>
|
||||
<button class="sm-close" onclick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary box -->
|
||||
<div class="sm-summary sm-summary-{meta.color}">
|
||||
<p>{meta.summary}</p>
|
||||
</div>
|
||||
|
||||
<!-- Side-by-side verdict comparison (stocks only) -->
|
||||
{#if row.inflated && row.fundamental}
|
||||
<div class="sm-verdict-compare">
|
||||
<div class="sm-vb" class:sm-vb-pass={mktPass} class:sm-vb-fail={!mktPass}>
|
||||
<div class="sm-vb-label" class:pass={mktPass} class:fail={!mktPass}>Mkt-Adjusted Mode</div>
|
||||
<div class="sm-vb-verdict" class:pass={mktPass} class:fail={!mktPass}>{mktPass ? '✅ PASSES' : '✗ FAILS'}</div>
|
||||
<div class="sm-vb-detail">
|
||||
{mktPass ? 'Gates calibrated to live S&P P/E. Passes the relaxed market-adjusted threshold.' : 'Fails even the relaxed market-adjusted gates.'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm-vb" class:sm-vb-pass={graOk} class:sm-vb-fail={!graOk}>
|
||||
<div class="sm-vb-label" class:pass={graOk} class:fail={!graOk}>Graham (Fundamental) Mode</div>
|
||||
<div class="sm-vb-verdict" class:pass={graOk} class:fail={!graOk}>{graOk ? '✅ PASSES' : '✗ FAILS'}</div>
|
||||
<div class="sm-vb-detail">
|
||||
{graOk ? 'Passes Graham\'s strict 15× P/E and quality gates.' : 'Graham\'s strict 15× P/E gate is a hard rule. Fails at elevated valuations.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Gate breakdown (if we have data) -->
|
||||
{#if gates.length > 0}
|
||||
<div class="sm-gates">
|
||||
<div class="sm-gates-title">Gate Breakdown</div>
|
||||
{#each gates as g}
|
||||
<div class="sm-gate-row" class:g-pass={g.pass} class:g-fail={!g.pass}>
|
||||
<span class="sm-gate-icon">{g.pass ? '✓' : '✗'}</span>
|
||||
<span class="sm-gate-text"><b>{g.label}</b> — {g.detail}</span>
|
||||
<span class="sm-gate-value">{g.value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Footer tip -->
|
||||
<div class="sm-footer">
|
||||
💡 <strong>What to do:</strong> {meta.tip}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.sm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #00000088;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.sm-modal {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
box-shadow: 0 24px 64px #00000088;
|
||||
animation: modal-in 0.18s ease;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from { opacity: 0; transform: scale(0.96) translateY(8px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.sm-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sm-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sm-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 3px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.sm-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.sm-close:hover { background: var(--bg-elevated); color: var(--text-primary); }
|
||||
|
||||
.sm-summary {
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.sm-summary p { font-size: 13px; line-height: 1.6; margin: 0; }
|
||||
.sm-summary-green { background: #0a2e1a; border: 1px solid #1a4a2a; }
|
||||
.sm-summary-green p { color: #86efac; }
|
||||
.sm-summary-blue { background: #0a1e3a; border: 1px solid #1a3a6a; }
|
||||
.sm-summary-blue p { color: #93c5fd; }
|
||||
.sm-summary-amber { background: #2a1a00; border: 1px solid #4a3000; }
|
||||
.sm-summary-amber p { color: #fbbf24; }
|
||||
.sm-summary-gray { background: var(--bg-elevated); border: 1px solid var(--border); }
|
||||
.sm-summary-gray p { color: var(--text-muted); }
|
||||
.sm-summary-red { background: #2a0d0d; border: 1px solid #4a1a1a; }
|
||||
.sm-summary-red p { color: #fca5a5; }
|
||||
|
||||
.sm-verdict-compare {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sm-vb {
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.sm-vb.sm-vb-pass { background: #0a2e1a; border-color: #1a4a2a; }
|
||||
.sm-vb.sm-vb-fail { background: #2a0d0d; border-color: #4a1a1a; }
|
||||
|
||||
.sm-vb-label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.sm-vb-label.pass { color: #4ade80; }
|
||||
.sm-vb-label.fail { color: #f87171; }
|
||||
|
||||
.sm-vb-verdict {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.sm-vb-verdict.pass { color: #4ade80; }
|
||||
.sm-vb-verdict.fail { color: #f87171; }
|
||||
|
||||
.sm-vb-detail {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sm-gates { margin-bottom: 16px; }
|
||||
|
||||
.sm-gates-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sm-gate-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sm-gate-row.g-pass { background: #0a2e1a; border: 1px solid #1a4a2a; }
|
||||
.sm-gate-row.g-fail { background: #2a0d0d; border: 1px solid #4a1a1a; }
|
||||
|
||||
.sm-gate-icon { font-size: 13px; flex-shrink: 0; }
|
||||
.g-pass .sm-gate-icon { color: #4ade80; }
|
||||
.g-fail .sm-gate-icon { color: #f87171; }
|
||||
|
||||
.sm-gate-text { flex: 1; color: var(--text-secondary); }
|
||||
.sm-gate-text :global(b) { color: var(--text-primary); }
|
||||
|
||||
.sm-gate-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sm-footer {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import type { AssetResult } from '$lib/types.js';
|
||||
|
||||
let {
|
||||
open = false,
|
||||
row,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
row: AssetResult | null;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
function handleBackdrop(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
|
||||
// Build gate breakdown from audit data
|
||||
function gateRows(row: AssetResult | null) {
|
||||
if (!row) return [];
|
||||
const mkt = row.inflated.audit;
|
||||
const graham = row.fundamental.audit;
|
||||
const m = row.asset.displayMetrics ?? {};
|
||||
|
||||
const items: { pass: boolean; label: string; detail: string; value: string }[] = [];
|
||||
|
||||
// P/E — the key differentiator for Speculation
|
||||
const pe = m['P/E'];
|
||||
if (pe && pe !== '—') {
|
||||
if (graham.failures?.some((f: string) => f.toLowerCase().includes('p/e'))) {
|
||||
items.push({ pass: false, label: 'P/E Ratio', detail: 'Graham gate (15×) failed', value: `${pe} > 15×` });
|
||||
}
|
||||
if (mkt.passedGates) {
|
||||
items.push({ pass: true, label: 'P/E Ratio', detail: 'Mkt-adjusted gate passed', value: `${pe} < mkt threshold` });
|
||||
}
|
||||
}
|
||||
|
||||
// PEG
|
||||
const peg = m['PEG'];
|
||||
if (peg && peg !== '—') {
|
||||
const pegNum = parseFloat(String(peg));
|
||||
items.push({ pass: !isNaN(pegNum) && pegNum < 1.0, label: 'PEG Ratio', detail: pegNum < 1.0 ? 'Paying less than growth justifies' : 'PEG above 1.0 — paying a growth premium', value: `${peg} ${pegNum < 1.0 ? '<' : '>'} 1.0` });
|
||||
}
|
||||
|
||||
// D/E
|
||||
const de = m['D/E'];
|
||||
if (de && de !== '—') {
|
||||
const deNum = parseFloat(String(de));
|
||||
items.push({ pass: !isNaN(deNum) && deNum < 1.5, label: 'Debt / Equity', detail: deNum < 1.5 ? 'Conservative leverage' : 'Elevated leverage', value: `${de}` });
|
||||
}
|
||||
|
||||
// FCF
|
||||
const fcf = m['FCF Yld%'];
|
||||
if (fcf && fcf !== '—') {
|
||||
const fcfNum = parseFloat(String(fcf));
|
||||
items.push({ pass: !isNaN(fcfNum) && fcfNum > 0, label: 'FCF Yield', detail: fcfNum > 0 ? 'Positive free cash flow' : 'Negative free cash flow — cash burn', value: `${fcf}` });
|
||||
}
|
||||
|
||||
// ROE
|
||||
const roe = m['ROE%'];
|
||||
if (roe && roe !== '—') {
|
||||
const roeNum = parseFloat(String(roe));
|
||||
items.push({ pass: !isNaN(roeNum) && roeNum > 15, label: 'Return on Equity', detail: roeNum > 15 ? 'Above 15% threshold — quality signal' : 'Below 15% preferred threshold', value: `${roe}` });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if open && row}
|
||||
{@const m = row.asset.displayMetrics ?? {}}
|
||||
{@const mktPass = row.inflated.audit?.passedGates}
|
||||
{@const grahamOk = row.fundamental.audit?.passedGates}
|
||||
{@const gates = gateRows(row)}
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="spec-backdrop" onclick={handleBackdrop}>
|
||||
<div class="spec-modal">
|
||||
<!-- Header -->
|
||||
<div class="spec-modal-header">
|
||||
<div>
|
||||
<div class="spec-modal-title">⚡ Why "Speculation"?</div>
|
||||
<div class="spec-modal-sub">{row.asset.ticker} · {m['Price'] ?? ''}</div>
|
||||
</div>
|
||||
<button class="spec-modal-close" onclick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<!-- Plain-English summary -->
|
||||
<div class="spec-summary">
|
||||
<p><strong>Passes market-adjusted gates but fails Graham's strict fundamental rules.</strong> This stock is attractive at today's market valuations, but would not meet a value investor's stricter criteria. Treat as a momentum or growth play — not a deep value position.</p>
|
||||
</div>
|
||||
|
||||
<!-- Side-by-side verdict comparison -->
|
||||
<div class="spec-verdict-compare">
|
||||
<div class="spec-vb" class:spec-vb-pass={mktPass} class:spec-vb-fail={!mktPass}>
|
||||
<div class="spec-vb-label" class:pass={mktPass} class:fail={!mktPass}>Mkt-Adjusted Mode</div>
|
||||
<div class="spec-vb-verdict" class:pass={mktPass} class:fail={!mktPass}>{mktPass ? '✅ PASSES' : '✗ FAILS'}</div>
|
||||
<div class="spec-vb-detail">
|
||||
{#if mktPass}
|
||||
Gates calibrated to live S&P P/E. P/E passes the market-adjusted threshold at today's valuations.
|
||||
{:else}
|
||||
Failed even relaxed market-adjusted gates — rare for Speculation signal.
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="spec-vb" class:spec-vb-pass={grahamOk} class:spec-vb-fail={!grahamOk}>
|
||||
<div class="spec-vb-label" class:pass={grahamOk} class:fail={!grahamOk}>Graham (Fundamental) Mode</div>
|
||||
<div class="spec-vb-verdict" class:pass={grahamOk} class:fail={!grahamOk}>{grahamOk ? '✅ PASSES' : '✗ FAILS'}</div>
|
||||
<div class="spec-vb-detail">
|
||||
{#if !grahamOk}
|
||||
Graham's strict 15× P/E gate is a hard rule. Fails immediately at elevated valuations.
|
||||
{:else}
|
||||
Passes Graham's fundamental gates — unusual for a Speculation signal.
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gate-by-gate breakdown -->
|
||||
<div class="spec-gate-breakdown">
|
||||
<div class="spec-gb-title">Gate Breakdown — which rule failed</div>
|
||||
{#each gates as g}
|
||||
<div class="spec-gate-row" class:g-pass={g.pass} class:g-fail={!g.pass}>
|
||||
<span class="spec-gate-icon">{g.pass ? '✓' : '✗'}</span>
|
||||
<span class="spec-gate-text"><b>{g.label}</b> — {g.detail}</span>
|
||||
<span class="spec-gate-value">{g.value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Footer tip -->
|
||||
<div class="spec-modal-footer">
|
||||
💡 <strong>What to do:</strong> Speculation signals are valid positions — they pass today's market standards. Consider sizing smaller than a Strong Buy and monitoring P/E compression risk if the market re-rates growth stocks downward.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.spec-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--bg-overlay);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.spec-modal {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 520px;
|
||||
width: 90%;
|
||||
box-shadow: 0 24px 64px #00000088;
|
||||
animation: modal-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from { opacity: 0; transform: scale(0.96) translateY(8px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.spec-modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.spec-modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.spec-modal-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.spec-modal-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition);
|
||||
|
||||
&:hover { background: var(--bg-elevated); color: var(--text-primary); }
|
||||
}
|
||||
|
||||
.spec-summary {
|
||||
background: var(--amber-dim);
|
||||
border: 1px solid #4a3000;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
p { font-size: 12px; color: var(--amber); line-height: 1.6; }
|
||||
}
|
||||
|
||||
.spec-verdict-compare {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.spec-vb {
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
|
||||
&.spec-vb-pass { background: var(--green-dim); border-color: var(--green-mid); }
|
||||
&.spec-vb-fail { background: var(--red-dim); border-color: #4a1a1a; }
|
||||
}
|
||||
|
||||
.spec-vb-label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&.pass { color: var(--green); }
|
||||
&.fail { color: var(--red); }
|
||||
}
|
||||
|
||||
.spec-vb-verdict {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
|
||||
&.pass { color: var(--green); }
|
||||
&.fail { color: var(--red); }
|
||||
}
|
||||
|
||||
.spec-vb-detail {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.spec-gate-breakdown {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.spec-gb-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.spec-gate-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
&.g-pass { background: var(--green-dim); }
|
||||
&.g-fail { background: var(--red-dim); }
|
||||
}
|
||||
|
||||
.spec-gate-icon {
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.g-pass & { color: var(--green); }
|
||||
.g-fail & { color: var(--red); }
|
||||
}
|
||||
|
||||
.spec-gate-text {
|
||||
flex: 1;
|
||||
color: var(--text-secondary);
|
||||
|
||||
b { color: var(--text-primary); }
|
||||
}
|
||||
|
||||
.spec-gate-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spec-modal-footer {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,589 @@
|
||||
<script lang="ts">
|
||||
import { fetchProfile, fetchChart, fetchTickerNews } from '$lib/api.js';
|
||||
import type { ChartRange, CompanyProfile, PricePoint, TickerNewsStory } from '$lib/api/screener.js';
|
||||
import type { Advice } from '$lib/utils/advice.js';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
|
||||
let {
|
||||
ticker,
|
||||
advice = null,
|
||||
onClose,
|
||||
}: { ticker: string; advice?: Advice | null; onClose: () => void } = $props();
|
||||
|
||||
const RANGES: Array<{ key: ChartRange; label: string }> = [
|
||||
{ key: '1d', label: '1D' },
|
||||
{ key: '5d', label: '5D' },
|
||||
{ key: '1mo', label: '1M' },
|
||||
{ key: '3mo', label: '3M' },
|
||||
{ key: '6mo', label: '6M' },
|
||||
{ key: 'ytd', label: 'YTD' },
|
||||
{ key: '1y', label: '1Y' },
|
||||
{ key: '5y', label: '5Y' },
|
||||
];
|
||||
|
||||
let loading = $state(true);
|
||||
let chartLoading = $state(false);
|
||||
let range = $state<ChartRange>('6mo');
|
||||
let profile = $state<CompanyProfile | null>(null);
|
||||
let points = $state<PricePoint[]>([]);
|
||||
let news = $state<TickerNewsStory[]>([]);
|
||||
let expanded = $state(false); // company summary read-more
|
||||
|
||||
// Profile + news load once per ticker
|
||||
$effect(() => {
|
||||
loading = true;
|
||||
Promise.all([fetchProfile(ticker), fetchTickerNews(ticker, 14)])
|
||||
.then(([p, n]) => {
|
||||
profile = p;
|
||||
news = n;
|
||||
})
|
||||
.finally(() => (loading = false));
|
||||
});
|
||||
|
||||
// Chart reloads whenever the range changes
|
||||
$effect(() => {
|
||||
chartLoading = true;
|
||||
fetchChart(ticker, range)
|
||||
.then((c) => (points = c))
|
||||
.finally(() => (chartLoading = false));
|
||||
});
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
|
||||
// ── SVG line chart (no chart library — a simple path over closes) ────────
|
||||
const W = 560;
|
||||
const H = 160;
|
||||
const PAD = 6;
|
||||
|
||||
type ChartGeo = { path: string; area: string; up: boolean; min: number; max: number };
|
||||
|
||||
function chartGeo(pts: PricePoint[]): ChartGeo | null {
|
||||
if (pts.length < 2) return null;
|
||||
const closes = pts.map((p) => p.close);
|
||||
const min = Math.min(...closes);
|
||||
const max = Math.max(...closes);
|
||||
const span = max - min || 1;
|
||||
const x = (i: number) => PAD + (i / (pts.length - 1)) * (W - 2 * PAD);
|
||||
const y = (c: number) => PAD + (1 - (c - min) / span) * (H - 2 * PAD);
|
||||
const path = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(p.close).toFixed(1)}`).join(' ');
|
||||
const area = `${path} L${x(pts.length - 1).toFixed(1)},${H - PAD} L${x(0).toFixed(1)},${H - PAD} Z`;
|
||||
return { path, area, up: closes[closes.length - 1] >= closes[0], min, max };
|
||||
}
|
||||
|
||||
const geo = $derived(chartGeo(points));
|
||||
const changePct = $derived(
|
||||
points.length >= 2 ? ((points[points.length - 1].close - points[0].close) / points[0].close) * 100 : null,
|
||||
);
|
||||
|
||||
const rangeLabel = $derived(RANGES.find((r) => r.key === range)?.label ?? range);
|
||||
const isIntraday = $derived(range === '1d' || range === '5d');
|
||||
|
||||
/** Axis label: time-of-day for intraday ranges, date otherwise. */
|
||||
function fmtAxis(d: string): string {
|
||||
if (!isIntraday) return d;
|
||||
return new Date(d).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// ── Hover crosshair (Robinhood-style scrub) ───────────────────────────────
|
||||
let hoverIdx = $state<number | null>(null);
|
||||
|
||||
function onChartMove(e: PointerEvent): void {
|
||||
if (points.length < 2) return;
|
||||
const rect = (e.currentTarget as SVGElement).getBoundingClientRect();
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
const usable = (W - 2 * PAD) / W; // chart area as fraction of viewBox width
|
||||
const adjusted = (ratio - PAD / W) / usable;
|
||||
hoverIdx = Math.min(points.length - 1, Math.max(0, Math.round(adjusted * (points.length - 1))));
|
||||
}
|
||||
|
||||
const hover = $derived.by(() => {
|
||||
if (hoverIdx == null || points.length < 2) return null;
|
||||
const p = points[hoverIdx];
|
||||
const closes = points.map((q) => q.close);
|
||||
const min = Math.min(...closes);
|
||||
const span = (Math.max(...closes) - min) || 1;
|
||||
const cx = PAD + (hoverIdx / (points.length - 1)) * (W - 2 * PAD);
|
||||
const cy = PAD + (1 - (p.close - min) / span) * (H - 2 * PAD);
|
||||
const fromStart = ((p.close - points[0].close) / points[0].close) * 100;
|
||||
const label = isIntraday
|
||||
? new Date(p.date).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
: p.date;
|
||||
return { p, cx, cy, fromStart, label, flip: cx > W * 0.6 };
|
||||
});
|
||||
|
||||
/** Position of a price between target low and high, clamped 0–100%. */
|
||||
function targetPos(price: number, low: number, high: number): number {
|
||||
if (high <= low) return 50;
|
||||
return Math.min(100, Math.max(0, ((price - low) / (high - low)) * 100));
|
||||
}
|
||||
|
||||
function recLabel(mean: number | null): string {
|
||||
if (mean == null) return '—';
|
||||
if (mean <= 1.5) return 'Strong Buy';
|
||||
if (mean <= 2.5) return 'Buy';
|
||||
if (mean <= 3.5) return 'Hold';
|
||||
if (mean <= 4.5) return 'Sell';
|
||||
return 'Strong Sell';
|
||||
}
|
||||
|
||||
function fmtCap(v: number | null): string {
|
||||
if (v == null) return '—';
|
||||
if (v >= 1e12) return `$${(v / 1e12).toFixed(2)}T`;
|
||||
if (v >= 1e9) return `$${(v / 1e9).toFixed(1)}B`;
|
||||
if (v >= 1e6) return `$${(v / 1e6).toFixed(0)}M`;
|
||||
return `$${v}`;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
const sourceLabel: Record<string, string> = { edgar: 'SEC filing', prwire: 'Press release', yahoo: 'Yahoo' };
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="tm-backdrop" role="presentation" onclick={onClose}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="tm-modal"
|
||||
role="dialog"
|
||||
aria-label="Company details for {ticker}"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="tm-header">
|
||||
<div>
|
||||
<span class="tm-ticker">{ticker}</span>
|
||||
{#if profile}<span class="tm-name">{profile.name}</span>{/if}
|
||||
</div>
|
||||
<div class="tm-header-right">
|
||||
{#if profile?.currentPrice != null}
|
||||
<span class="tm-price">${profile.currentPrice.toFixed(2)}</span>
|
||||
{/if}
|
||||
{#if changePct != null}
|
||||
<span class="tm-change" class:up={changePct >= 0} class:down={changePct < 0}>
|
||||
{changePct >= 0 ? '+' : ''}{changePct.toFixed(1)}% / {rangeLabel}
|
||||
</span>
|
||||
{/if}
|
||||
<button class="tm-close" onclick={onClose} title="Close (Esc)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="tm-loading"><Spinner size="md" label="Loading {ticker}…" /></div>
|
||||
{:else}
|
||||
<div class="tm-body">
|
||||
<!-- ── Plain-language advice ── -->
|
||||
{#if advice}
|
||||
<div class="tm-advice tm-advice-{advice.tone}" title={advice.detail}>
|
||||
{advice.text}
|
||||
<span class="tm-advice-note">— based on your screener’s data, not financial advice</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Range switcher ── -->
|
||||
<div class="tm-ranges" role="tablist" aria-label="Chart range">
|
||||
{#each RANGES as r}
|
||||
<button
|
||||
class="tm-range-btn"
|
||||
class:active={range === r.key}
|
||||
role="tab"
|
||||
aria-selected={range === r.key}
|
||||
onclick={() => (range = r.key)}
|
||||
>{r.label}</button>
|
||||
{/each}
|
||||
{#if chartLoading}<span class="tm-chart-spin"><Spinner size="sm" /></span>{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Price chart ── -->
|
||||
{#if geo}
|
||||
<svg
|
||||
viewBox="0 0 {W} {H}"
|
||||
class="tm-chart"
|
||||
class:dim={chartLoading}
|
||||
role="img"
|
||||
aria-label="{rangeLabel} price chart"
|
||||
onpointermove={onChartMove}
|
||||
onpointerleave={() => (hoverIdx = null)}
|
||||
>
|
||||
<path d={geo.area} class="tm-chart-area" class:up={geo.up} class:down={!geo.up} />
|
||||
<path d={geo.path} class="tm-chart-line" class:up={geo.up} class:down={!geo.up} fill="none" />
|
||||
{#if hover}
|
||||
<line x1={hover.cx} y1={PAD} x2={hover.cx} y2={H - PAD} class="tm-xhair-line" />
|
||||
<circle cx={hover.cx} cy={hover.cy} r="3.5" class="tm-xhair-dot" class:up={geo.up} class:down={!geo.up} />
|
||||
{@const tipW = 132}
|
||||
{@const tipX = hover.flip ? hover.cx - tipW - 10 : hover.cx + 10}
|
||||
<g class="tm-xhair-tip">
|
||||
<rect x={tipX} y={PAD} width={tipW} height="34" rx="5" />
|
||||
<text x={tipX + 8} y={PAD + 14}>${hover.p.close.toFixed(2)}
|
||||
<tspan class:pos={hover.fromStart >= 0} class:neg={hover.fromStart < 0}>
|
||||
({hover.fromStart >= 0 ? '+' : ''}{hover.fromStart.toFixed(1)}%)
|
||||
</tspan>
|
||||
</text>
|
||||
<text x={tipX + 8} y={PAD + 27} class="tm-xhair-date">{hover.label}</text>
|
||||
</g>
|
||||
{/if}
|
||||
</svg>
|
||||
<div class="tm-chart-range">
|
||||
<span>{fmtAxis(points[0]?.date ?? '')}</span>
|
||||
<span>low ${geo.min.toFixed(2)} · high ${geo.max.toFixed(2)}</span>
|
||||
<span>{fmtAxis(points[points.length - 1]?.date ?? '')}</span>
|
||||
</div>
|
||||
{:else if !chartLoading}
|
||||
<div class="tm-empty">No price history for this range</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Analyst price targets ── -->
|
||||
{#if profile?.targets?.mean != null}
|
||||
{@const t = profile.targets}
|
||||
<div class="tm-targets">
|
||||
<div class="tm-targets-head">
|
||||
<span class="tm-targets-title">Analyst targets</span>
|
||||
<span class="tm-targets-meta">
|
||||
{recLabel(t.recommendationMean)}{t.analysts != null ? ` · ${t.analysts} analysts` : ''}
|
||||
{#if t.upsidePct != null}
|
||||
· <span class:up={t.upsidePct >= 0} class:down={t.upsidePct < 0} class="tm-upside">
|
||||
{t.upsidePct >= 0 ? '+' : ''}{t.upsidePct}% to mean
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if t.mean != null && t.low != null && t.high != null && profile.currentPrice != null}
|
||||
<div class="tm-target-bar">
|
||||
<div class="tm-target-track"></div>
|
||||
<div class="tm-target-mark mean" style="left: {targetPos(t.mean, t.low, t.high)}%" title="Mean target ${t.mean}"></div>
|
||||
<div class="tm-target-mark price" style="left: {targetPos(profile.currentPrice, t.low, t.high)}%" title="Current price ${profile.currentPrice}"></div>
|
||||
</div>
|
||||
<div class="tm-target-labels">
|
||||
<span>Low ${t.low.toFixed(2)}</span>
|
||||
<span class="tm-target-mean">Mean ${t.mean.toFixed(2)}</span>
|
||||
<span>High ${t.high.toFixed(2)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="tm-target-foot">
|
||||
Source: Yahoo Finance consensus ·
|
||||
<a href="https://www.zacks.com/stock/quote/{ticker}" target="_blank" rel="noopener noreferrer">Zacks view ↗</a>
|
||||
<span class="tm-target-note">(Zacks has no free API — opens their page)</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Company info ── -->
|
||||
{#if profile}
|
||||
<div class="tm-facts">
|
||||
{#if profile.sector}<span class="tm-fact">{profile.sector}</span>{/if}
|
||||
{#if profile.industry}<span class="tm-fact">{profile.industry}</span>{/if}
|
||||
<span class="tm-fact">Mkt cap {fmtCap(profile.marketCap)}</span>
|
||||
{#if profile.employees}<span class="tm-fact">{profile.employees.toLocaleString()} employees</span>{/if}
|
||||
{#if profile.website}
|
||||
<a class="tm-fact tm-link" href={profile.website} target="_blank" rel="noopener noreferrer">Website ↗</a>
|
||||
{/if}
|
||||
</div>
|
||||
{#if profile.summary}
|
||||
<p class="tm-summary" class:clamped={!expanded}>{profile.summary}</p>
|
||||
{#if profile.summary.length > 280}
|
||||
<button class="tm-more" onclick={() => (expanded = !expanded)}>
|
||||
{expanded ? 'Show less' : 'Read more'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="tm-empty">No company profile available</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Latest news ── -->
|
||||
<div class="tm-news-title">Latest news (14 days)</div>
|
||||
{#if news.length === 0}
|
||||
<div class="tm-empty">
|
||||
No stored stories for {ticker} yet — news accumulates as the pollers run.
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="tm-news">
|
||||
{#each news.slice(0, 8) as story}
|
||||
<li>
|
||||
<a href={story.url} target="_blank" rel="noopener noreferrer">{story.headline}</a>
|
||||
<div class="tm-news-meta">
|
||||
{#if story.catalyst}<span class="tm-cat tm-cat-{story.catalyst}">{story.catalyst}</span>{/if}
|
||||
<span>{sourceLabel[story.source] ?? story.source}</span>
|
||||
<span>·</span>
|
||||
<span>{fmtDate(story.publishedAt)}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 100;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.tm-modal {
|
||||
background: var(--bg-base, #0b1220);
|
||||
border: 1px solid var(--border, #1e293b);
|
||||
border-radius: 12px;
|
||||
width: min(640px, 100%);
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border, #1e293b);
|
||||
}
|
||||
|
||||
.tm-ticker { font-size: 18px; font-weight: 700; color: var(--text-primary); }
|
||||
.tm-name { margin-left: 10px; color: var(--text-muted); font-size: 13px; }
|
||||
.tm-header-right { display: flex; align-items: center; gap: 10px; }
|
||||
.tm-price { font-family: var(--font-mono); font-size: 15px; color: var(--text-primary); }
|
||||
|
||||
.tm-change { font-family: var(--font-mono); font-size: 12px; }
|
||||
.tm-change.up { color: var(--green, #4ade80); }
|
||||
.tm-change.down { color: var(--red, #f87171); }
|
||||
|
||||
.tm-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.tm-close:hover { color: var(--text-primary); }
|
||||
|
||||
.tm-loading { display: grid; place-items: center; min-height: 240px; }
|
||||
.tm-body { padding: 16px 18px; overflow-y: auto; }
|
||||
|
||||
/* ── Plain-language advice banner ── */
|
||||
.tm-advice {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
cursor: help;
|
||||
}
|
||||
.tm-advice-note { font-size: 10px; font-weight: 400; opacity: 0.65; }
|
||||
.tm-advice-buy { color: var(--green, #4ade80); background: rgba(74, 222, 128, 0.07); }
|
||||
.tm-advice-mindful { color: var(--amber, #f0b429); background: rgba(240, 180, 41, 0.07); }
|
||||
.tm-advice-caution { color: var(--orange, #f0b429); background: rgba(240, 180, 41, 0.07); }
|
||||
.tm-advice-wait { color: var(--blue, #60a5fa); background: rgba(96, 165, 250, 0.07); }
|
||||
.tm-advice-skip { color: var(--red, #f87171); background: rgba(248, 113, 113, 0.07); }
|
||||
.tm-advice-unknown { color: var(--text-muted, #64748b); font-style: italic; }
|
||||
|
||||
.tm-ranges { display: flex; align-items: center; gap: 4px; margin-bottom: 8px; }
|
||||
|
||||
.tm-range-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 3px 9px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tm-range-btn:hover { color: var(--text-primary); }
|
||||
.tm-range-btn.active {
|
||||
color: var(--blue, #60a5fa);
|
||||
border-color: var(--blue, #60a5fa);
|
||||
background: rgba(96, 165, 250, 0.08);
|
||||
}
|
||||
|
||||
.tm-chart-spin { margin-left: 6px; }
|
||||
|
||||
.tm-chart { width: 100%; height: auto; display: block; cursor: crosshair; touch-action: none; }
|
||||
.tm-chart.dim { opacity: 0.45; }
|
||||
|
||||
/* ── Hover crosshair ── */
|
||||
.tm-xhair-line {
|
||||
stroke: var(--text-muted, #3d5166);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 3 3;
|
||||
}
|
||||
.tm-xhair-dot { stroke: var(--bg-base, #0b1220); stroke-width: 1.5; }
|
||||
.tm-xhair-dot.up { fill: var(--green, #4ade80); }
|
||||
.tm-xhair-dot.down { fill: var(--red, #f87171); }
|
||||
|
||||
.tm-xhair-tip rect {
|
||||
fill: var(--bg-card, #111a2c);
|
||||
stroke: var(--border, #1e293b);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.tm-xhair-tip text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
fill: var(--text-primary, #e2e8f0);
|
||||
}
|
||||
.tm-xhair-tip tspan.pos { fill: var(--green, #4ade80); }
|
||||
.tm-xhair-tip tspan.neg { fill: var(--red, #f87171); }
|
||||
.tm-xhair-tip .tm-xhair-date { font-size: 9.5px; fill: var(--text-muted, #64748b); }
|
||||
|
||||
/* ── Analyst targets ── */
|
||||
.tm-targets {
|
||||
border: 1px solid var(--border, #1e293b);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
|
||||
.tm-targets-head { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
||||
.tm-targets-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dimmer);
|
||||
}
|
||||
.tm-targets-meta { font-size: 11.5px; color: var(--text-dim); }
|
||||
.tm-upside.up { color: var(--green, #4ade80); }
|
||||
.tm-upside.down { color: var(--red, #f87171); }
|
||||
|
||||
.tm-target-bar { position: relative; height: 14px; margin: 10px 4px 2px; }
|
||||
.tm-target-track {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--border-input, #263447);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.tm-target-mark {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.tm-target-mark.mean { background: var(--blue, #60a5fa); }
|
||||
.tm-target-mark.price { background: var(--text-primary, #e2e8f0); border: 2px solid var(--bg-base, #0b1220); }
|
||||
|
||||
.tm-target-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tm-target-mean { color: var(--blue, #60a5fa); }
|
||||
|
||||
.tm-target-foot { font-size: 10.5px; color: var(--text-muted); margin-top: 8px; }
|
||||
.tm-target-foot a { color: var(--blue, #60a5fa); text-decoration: none; }
|
||||
.tm-target-note { font-style: italic; }
|
||||
.tm-chart-line { stroke-width: 1.75; }
|
||||
.tm-chart-line.up { stroke: var(--green, #4ade80); }
|
||||
.tm-chart-line.down { stroke: var(--red, #f87171); }
|
||||
.tm-chart-area.up { fill: rgba(74, 222, 128, 0.08); }
|
||||
.tm-chart-area.down { fill: rgba(248, 113, 113, 0.08); }
|
||||
|
||||
.tm-chart-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
|
||||
.tm-facts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
|
||||
|
||||
.tm-fact {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
background: var(--bg-card, #111a2c);
|
||||
border: 1px solid var(--border, #1e293b);
|
||||
border-radius: 10px;
|
||||
padding: 2px 9px;
|
||||
}
|
||||
|
||||
.tm-link { color: var(--blue, #60a5fa); text-decoration: none; }
|
||||
|
||||
.tm-summary {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.tm-summary.clamped {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tm-more {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--blue, #60a5fa);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tm-news-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dimmer);
|
||||
margin: 14px 0 8px;
|
||||
border-top: 1px solid var(--border, #1e293b);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.tm-news { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 9px; }
|
||||
.tm-news a { color: var(--text-secondary); text-decoration: none; font-size: 12.5px; line-height: 1.4; }
|
||||
.tm-news a:hover { color: var(--blue, #60a5fa); }
|
||||
|
||||
.tm-news-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tm-cat {
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.05em;
|
||||
border-radius: 8px;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
.tm-cat-ma { color: var(--purple, #a78bfa); }
|
||||
.tm-cat-earnings { color: var(--blue, #60a5fa); }
|
||||
.tm-cat-guidance { color: var(--amber, #f0b429); }
|
||||
.tm-cat-regulatory { color: var(--orange, #f0b429); }
|
||||
.tm-cat-macro { color: var(--text-dim); }
|
||||
|
||||
.tm-empty { font-size: 12px; color: var(--text-muted); font-style: italic; padding: 8px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import { watchlistStore } from '$lib/stores/watchlist.store.svelte.js';
|
||||
import { screenerStore } from '$lib/stores/screener.store.svelte.js';
|
||||
import { screenTickers, analyzeTickers } from '$lib/api.js';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
import AssetTable from '$lib/components/screener/AssetTable.svelte';
|
||||
import type { AssetType, ScreenerResult } from '$lib/types.js';
|
||||
|
||||
let results = $state<ScreenerResult | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let screenedAt = $state('');
|
||||
let collapsed = $state(false);
|
||||
|
||||
// Re-screen whenever the pin set changes (debounced 300ms to batch quick adds)
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
$effect(() => {
|
||||
const tickers = watchlistStore.tickers; // reactive dependency
|
||||
clearTimeout(debounceTimer);
|
||||
if (tickers.length === 0) { results = null; return; }
|
||||
debounceTimer = setTimeout(() => screen(tickers), 300);
|
||||
return () => clearTimeout(debounceTimer);
|
||||
});
|
||||
|
||||
async function screen(tickers: string[]) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
results = await screenTickers(tickers);
|
||||
screenedAt = new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Run LLM analysis for the given asset type using watchlist tickers,
|
||||
// then open the shared AnalysisSidebar via screenerStore.
|
||||
async function runAnalysis(type: AssetType): Promise<void> {
|
||||
const tickers = (results?.[type] ?? []).map((r) => r.asset.ticker);
|
||||
if (!tickers.length) return;
|
||||
screenerStore.sidebar = { open: true, loading: true, analysis: null, type, error: null };
|
||||
try {
|
||||
const res = await analyzeTickers(tickers);
|
||||
const reason = res.reason === 'no_stories' ? 'No recent news found for these tickers.' : null;
|
||||
screenerStore.sidebar = {
|
||||
open: true,
|
||||
loading: false,
|
||||
analysis: res.analysis,
|
||||
type,
|
||||
error: res.analysis ? null : (reason ?? 'Analysis failed — check server logs for details.'),
|
||||
};
|
||||
} catch (e) {
|
||||
screenerStore.sidebar = {
|
||||
open: true, loading: false, analysis: null, type,
|
||||
error: (e as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if watchlistStore.count > 0 || loading}
|
||||
<div class="wl-wrapper">
|
||||
<div class="wl-header">
|
||||
<button
|
||||
class="wl-collapse-btn"
|
||||
onclick={() => collapsed = !collapsed}
|
||||
title={collapsed ? 'Expand watchlist' : 'Collapse watchlist'}
|
||||
>{collapsed ? '▸' : '▾'}</button>
|
||||
<span class="wl-title">📌 Watchlist</span>
|
||||
<span class="wl-count">{watchlistStore.count}</span>
|
||||
{#if screenedAt && !collapsed}
|
||||
<span class="wl-screened-at">screened {screenedAt}</span>
|
||||
{/if}
|
||||
{#if loading}<Spinner size="sm" />{/if}
|
||||
</div>
|
||||
|
||||
{#if !collapsed}
|
||||
{#if error}
|
||||
<div class="error-banner">⚠ {error}</div>
|
||||
{:else if loading && !results}
|
||||
<div class="wl-loading"><Spinner size="lg" label="Screening watchlist…" /></div>
|
||||
{:else if results}
|
||||
<!-- Reuse AssetTable for each type — full feature parity with the screener -->
|
||||
{#each (['STOCK', 'ETF', 'BOND'] as const) as assetType}
|
||||
{#if results[assetType]?.length}
|
||||
<AssetTable
|
||||
type={assetType}
|
||||
rows={results[assetType]}
|
||||
analyzeLoading={screenerStore.sidebar.loading && screenerStore.sidebar.type === assetType}
|
||||
onAnalyze={() => runAnalysis(assetType)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if results.ERROR?.length}
|
||||
<div class="wl-errors">
|
||||
{#each results.ERROR as e}
|
||||
<span class="wl-error-item">
|
||||
⚠ {e.ticker}: {e.message}
|
||||
<button
|
||||
class="wl-unpin-btn"
|
||||
onclick={() => watchlistStore.remove(e.ticker)}
|
||||
title="Remove"
|
||||
>✕</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.wl-wrapper {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.wl-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
|
||||
.wl-collapse-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wl-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wl-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: var(--blue);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wl-screened-at {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wl-loading {
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.wl-errors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.wl-error-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.wl-unpin-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.wl-unpin-btn:hover { color: #f87171; }
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as AssetTable } from './AssetTable.svelte';
|
||||
export { default as AnalysisSidebar } from './AnalysisSidebar.svelte';
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { MarketContext } from '$lib/types.js';
|
||||
|
||||
let { ctx, collapsible = false }: { ctx: MarketContext; collapsible?: boolean } = $props();
|
||||
|
||||
// Read collapsible once for initial state — untrack avoids a reactive dep on the prop
|
||||
let expanded = $state(untrack(() => !collapsible));
|
||||
|
||||
const cards = $derived.by(() => {
|
||||
const b = ctx?.benchmarks ?? {};
|
||||
return [
|
||||
{
|
||||
label: '10Y Yield',
|
||||
value: ctx?.riskFreeRate != null ? ctx.riskFreeRate.toFixed(2) + '%' : '—',
|
||||
tip: 'US 10-year Treasury yield — the risk-free rate benchmark. Higher = tighter conditions for stocks and bonds.',
|
||||
},
|
||||
{
|
||||
label: 'VIX',
|
||||
value: ctx?.vixLevel?.toFixed(1) ?? '—',
|
||||
tip: 'CBOE Volatility Index — measures expected market volatility. Above 20 = elevated fear; above 30 = high stress.',
|
||||
},
|
||||
{
|
||||
label: 'S&P 500',
|
||||
value: ctx?.sp500Price?.toLocaleString() ?? '—',
|
||||
tip: 'Live S&P 500 index price — broad US large-cap benchmark.',
|
||||
},
|
||||
{
|
||||
label: 'S&P P/E',
|
||||
value: b.marketPE != null ? b.marketPE.toFixed(1) + 'x' : '—',
|
||||
tip: 'Trailing P/E ratio of SPY. Used to set the INFLATED mode P/E gate (S&P P/E × 1.5 in normal rates).',
|
||||
},
|
||||
{
|
||||
label: 'Tech P/E',
|
||||
value: b.techPE != null ? b.techPE.toFixed(1) + 'x' : '—',
|
||||
tip: 'Trailing P/E of XLK (tech sector ETF). Sets the tech-sector gate in INFLATED mode (XLK P/E × 1.3).',
|
||||
},
|
||||
{
|
||||
label: 'REIT Yield',
|
||||
value: b.reitYield != null ? b.reitYield.toFixed(2) + '%' : '—',
|
||||
tip: 'Dividend yield of XLRE (real estate ETF). Used as the REIT minimum yield gate in INFLATED mode.',
|
||||
},
|
||||
{
|
||||
label: 'IG Spread',
|
||||
value: b.igSpread != null ? b.igSpread.toFixed(2) + '%' : '—',
|
||||
tip: 'Investment-grade bond spread (LQD yield − 10Y yield). Sets the bond minimum spread gate in INFLATED mode.',
|
||||
},
|
||||
{
|
||||
label: 'Rate Regime',
|
||||
value: ctx?.rateRegime ?? '—',
|
||||
tip: 'HIGH (>4.5%) compresses P/E gates and tightens bond/REIT requirements. NORMAL uses looser INFLATED gates.',
|
||||
},
|
||||
{
|
||||
label: 'Volatility',
|
||||
value: ctx?.volatilityRegime ?? '—',
|
||||
tip: 'Derived from VIX level — LOW (<15), NORMAL (15–25), HIGH (>25). Informational; not currently gating scores.',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="ctx-wrap">
|
||||
{#if collapsible}
|
||||
<button class="ctx-toggle" onclick={() => expanded = !expanded}>
|
||||
<span class="ctx-toggle-label">Market Context</span>
|
||||
<span class="ctx-toggle-chevron">{expanded ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if expanded}
|
||||
<div class="ctx-grid">
|
||||
{#each cards as c}
|
||||
<div class="ctx-card">
|
||||
<div class="ctx-label-row">
|
||||
<span class="ctx-card-label">{c.label}</span>
|
||||
<span class="tip-wrap">
|
||||
<span class="tip-anchor">?</span>
|
||||
<span class="tip-box">{c.tip}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ctx-value">{c.value}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { fmtPE } from '$lib/utils.js';
|
||||
import type { MarketContext } from '$lib/types.js';
|
||||
let { ctx }: { ctx: MarketContext } = $props();
|
||||
|
||||
// color: bg, border, text (all as hex/rgba strings)
|
||||
const chips = $derived([
|
||||
{ label: '10Y', value: ctx.riskFreeRate != null ? ctx.riskFreeRate.toFixed(1) + '%' : '—', color: 'indigo' },
|
||||
{ label: 'VIX', value: ctx.vixLevel != null ? ctx.vixLevel.toFixed(1) : '—', color: 'rose' },
|
||||
{ label: 'S&P', value: ctx.sp500Price?.toLocaleString() ?? '—', color: 'emerald' },
|
||||
{ label: 'S&P P/E', value: fmtPE(ctx.benchmarks?.marketPE), color: 'sky' },
|
||||
{ label: 'Tech P/E', value: fmtPE(ctx.benchmarks?.techPE), color: 'violet' },
|
||||
{ label: 'REIT Yld', value: ctx.benchmarks?.reitYield != null ? ctx.benchmarks.reitYield.toFixed(1) + '%' : '—', color: 'amber' },
|
||||
{ label: 'IG Sprd', value: ctx.benchmarks?.igSpread != null ? ctx.benchmarks.igSpread.toFixed(2) + '%' : '—', color: 'teal' },
|
||||
{ label: 'Rates', value: ctx.rateRegime, regime: ctx.rateRegime, color: ctx.rateRegime === 'HIGH' ? 'red' : ctx.rateRegime === 'LOW' ? 'blue' : 'slate' },
|
||||
{ label: 'Vol', value: ctx.volatilityRegime, regime: ctx.volatilityRegime, color: ctx.volatilityRegime === 'HIGH' ? 'orange' : ctx.volatilityRegime === 'LOW' ? 'blue' : 'slate' },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="bubble-strip">
|
||||
{#each chips as chip}
|
||||
<div class="bubble bubble-{chip.color}">
|
||||
<span class="bubble-val">{chip.value ?? '—'}</span>
|
||||
<span class="bubble-label">{chip.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { Signal } from '$lib/types.js';
|
||||
let { signal }: { signal: Signal | null | undefined } = $props();
|
||||
|
||||
const cls = () => {
|
||||
if (signal?.includes('Strong')) return 'strong';
|
||||
if (signal?.includes('Momentum')) return 'momentum';
|
||||
if (signal?.includes('Speculation')) return 'spec';
|
||||
if (signal?.includes('Neutral')) return 'neutral';
|
||||
return 'avoid';
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class="badge {cls()}">{signal ?? '—'}</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.strong { background: #14532d33; color: #4ade80; }
|
||||
.momentum { background: #1e3a5f33; color: #60a5fa; }
|
||||
.spec { background: #7c2d1233; color: #fb923c; }
|
||||
.neutral { background: #1e293b; color: #94a3b8; }
|
||||
.avoid { background: #450a0a33; color: #f87171; }
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
let { size = 'md', label = null }: { size?: 'sm' | 'md' | 'lg'; label?: string | null } = $props();
|
||||
</script>
|
||||
|
||||
{#if size === 'sm'}
|
||||
<!-- Compact dot-pulse for buttons -->
|
||||
<span class="dot-pulse">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
{:else}
|
||||
<!-- Market chart line animation for md / lg -->
|
||||
<div class="chart-wrap" data-size={size}>
|
||||
<svg
|
||||
viewBox="0 0 160 60"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="chart-svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- Faint grid lines -->
|
||||
<line x1="0" y1="15" x2="160" y2="15" stroke="#1e293b" stroke-width="1" />
|
||||
<line x1="0" y1="30" x2="160" y2="30" stroke="#1e293b" stroke-width="1" />
|
||||
<line x1="0" y1="45" x2="160" y2="45" stroke="#1e293b" stroke-width="1" />
|
||||
|
||||
<!-- The market line — rises, dips, spikes, recovers -->
|
||||
<polyline
|
||||
class="chart-line"
|
||||
points="
|
||||
0,45
|
||||
12,38
|
||||
22,42
|
||||
32,28
|
||||
42,32
|
||||
52,18
|
||||
62,24
|
||||
72,14
|
||||
82,20
|
||||
92,10
|
||||
104,22
|
||||
114,16
|
||||
124,28
|
||||
134,20
|
||||
148,8
|
||||
160,12
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- Glowing dot at the leading edge -->
|
||||
<circle class="chart-dot" cx="160" cy="12" r="3" />
|
||||
</svg>
|
||||
|
||||
{#if label}
|
||||
<span class="chart-label">{label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* ── Dot pulse (sm) ─────────────────────────────────────────────── */
|
||||
.dot-pulse {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dot-pulse span {
|
||||
display: block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #60a5fa;
|
||||
animation: dot-bounce 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot-pulse span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.dot-pulse span:nth-child(3) { animation-delay: 0.30s; }
|
||||
|
||||
@keyframes dot-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Chart wrap (md / lg) ───────────────────────────────────────── */
|
||||
.chart-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.chart-wrap[data-size="md"] .chart-svg { width: 120px; height: 45px; }
|
||||
.chart-wrap[data-size="lg"] .chart-svg { width: 200px; height: 75px; }
|
||||
|
||||
.chart-svg { overflow: visible; }
|
||||
|
||||
/* The animated line */
|
||||
.chart-line {
|
||||
stroke: #3b82f6;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
/* total path length ≈ 220 — animate draw-in then loop */
|
||||
stroke-dasharray: 220;
|
||||
stroke-dashoffset: 220;
|
||||
animation: draw-line 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes draw-line {
|
||||
0% { stroke-dashoffset: 220; opacity: 1; }
|
||||
70% { stroke-dashoffset: 0; opacity: 1; }
|
||||
85% { stroke-dashoffset: 0; opacity: 0; }
|
||||
100% { stroke-dashoffset: 220; opacity: 0; }
|
||||
}
|
||||
|
||||
/* Glowing dot that appears when the line finishes drawing */
|
||||
.chart-dot {
|
||||
fill: #3b82f6;
|
||||
filter: drop-shadow(0 0 4px #3b82f6);
|
||||
opacity: 0;
|
||||
animation: dot-appear 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes dot-appear {
|
||||
0% { opacity: 0; }
|
||||
60% { opacity: 0; }
|
||||
70% { opacity: 1; }
|
||||
85% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { verdictShort, vClass } from '$lib/utils.js';
|
||||
let { label }: { label: string | null | undefined } = $props();
|
||||
</script>
|
||||
|
||||
<span class="verdict-pill {vClass(label)}">{verdictShort(label)}</span>
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as Spinner } from './Spinner.svelte';
|
||||
export { default as VerdictPill } from './VerdictPill.svelte';
|
||||
export { default as SignalBadge } from './SignalBadge.svelte';
|
||||
export { default as MarketContext } from './MarketContext.svelte';
|
||||
export { default as MarketContextStrip } from './MarketContextStrip.svelte';
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Auth store — holds current user + JWT token.
|
||||
* Persists token to sessionStorage so it survives page refresh within the tab.
|
||||
*/
|
||||
|
||||
import type { AuthUser, Role } from '$lib/types.js';
|
||||
|
||||
const TOKEN_KEY = 'ms_token';
|
||||
|
||||
function createAuthStore() {
|
||||
// Hydrate from sessionStorage on first load
|
||||
const stored = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(TOKEN_KEY) : null;
|
||||
|
||||
let token = $state<string | null>(stored);
|
||||
let user = $state<AuthUser | null>(stored ? parseTokenUser(stored) : null);
|
||||
|
||||
function setAuth(newToken: string, newUser: AuthUser) {
|
||||
token = newToken;
|
||||
user = newUser;
|
||||
sessionStorage.setItem(TOKEN_KEY, newToken);
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
token = null;
|
||||
user = null;
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
return {
|
||||
get token() {
|
||||
return token;
|
||||
},
|
||||
get user() {
|
||||
return user;
|
||||
},
|
||||
get isLoggedIn() {
|
||||
return token !== null && user !== null;
|
||||
},
|
||||
get role(): Role | null {
|
||||
return user?.role ?? null;
|
||||
},
|
||||
get isTrader() {
|
||||
return user?.role === 'trader' || user?.role === 'admin';
|
||||
},
|
||||
setAuth,
|
||||
clearAuth,
|
||||
};
|
||||
}
|
||||
|
||||
/** Decode the JWT payload (base64url middle segment) to extract user info. */
|
||||
function parseTokenUser(token: string): AuthUser | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||
if (!payload.sub || !payload.email || !payload.role) return null;
|
||||
// Check expiry
|
||||
if (payload.exp && payload.exp * 1000 < Date.now()) return null;
|
||||
return {
|
||||
id: payload.sub as string,
|
||||
email: payload.email as string,
|
||||
role: payload.role as Role,
|
||||
createdAt: '',
|
||||
lastLogin: null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const authStore = createAuthStore();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { addHolding, removeHolding } from '$lib/api.js';
|
||||
import { addHolding, removeHolding, authFetch } from '$lib/api.js';
|
||||
import type { MarketContext, AdviceRow, PersonalFinance, HoldingFormData } from '$lib/types.js';
|
||||
|
||||
interface PortfolioData {
|
||||
@@ -23,8 +23,7 @@ class PortfolioStore {
|
||||
else this.refreshing = true;
|
||||
this.loadError = null;
|
||||
|
||||
window
|
||||
.fetch('/api/finance/portfolio')
|
||||
authFetch('/api/finance/portfolio')
|
||||
.then((res) =>
|
||||
res.ok
|
||||
? res.json()
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { fetchCatalysts, screenTickers, analyzeTickers } from '$lib/api.js';
|
||||
import {
|
||||
fetchCatalysts,
|
||||
screenTickers,
|
||||
analyzeTickers,
|
||||
fetchSectorPulse,
|
||||
fetchSectorDetail,
|
||||
} from '$lib/api.js';
|
||||
import type { SectorPulse, SectorDetail } from '$lib/api/screener.js';
|
||||
import { sorted } from '$lib/utils.js';
|
||||
import type { ScreenerResult, AssetType, SidebarState } from '$lib/types.js';
|
||||
|
||||
@@ -21,13 +28,56 @@ class ScreenerStore {
|
||||
// ── Derived ────────────────────────────────────────────────────────
|
||||
ctx = $derived(this.results?.marketContext ?? null);
|
||||
|
||||
/** P0.4 data-sanity sentinel — dismissible per screen run. */
|
||||
healthDismissed = $state(false);
|
||||
dataHealth = $derived(
|
||||
!this.healthDismissed && this.results?.dataHealth?.degraded ? this.results.dataHealth : null,
|
||||
);
|
||||
|
||||
allAssets = $derived(
|
||||
this.results ? sorted([...this.results.STOCK, ...this.results.ETF, ...this.results.BOND]) : [],
|
||||
);
|
||||
|
||||
// ── Sector pulse (daily % change per sector via SPDR ETFs) ──────────────
|
||||
sectorPulse = $state<SectorPulse | null>(null);
|
||||
sectorPulseLoading = $state(true);
|
||||
/** Selected sector — drives the sector drill-down panel only. */
|
||||
sectorFilter = $state<string | null>(null);
|
||||
|
||||
// Sector drill-down panel (top holdings screened + sector news)
|
||||
sectorDetail = $state<SectorDetail | null>(null);
|
||||
sectorDetailLoading = $state(false);
|
||||
|
||||
async loadSectorPulse(): Promise<void> {
|
||||
this.sectorPulseLoading = true;
|
||||
try {
|
||||
this.sectorPulse = await fetchSectorPulse();
|
||||
} catch {
|
||||
this.sectorPulse = null;
|
||||
} finally {
|
||||
this.sectorPulseLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Select a sector: filter the table and load the drill-down panel. */
|
||||
async selectSector(sector: string | null): Promise<void> {
|
||||
this.sectorFilter = sector;
|
||||
this.sectorDetail = null;
|
||||
if (!sector) return;
|
||||
this.sectorDetailLoading = true;
|
||||
try {
|
||||
this.sectorDetail = await fetchSectorDetail(sector);
|
||||
} catch {
|
||||
this.sectorDetail = null;
|
||||
} finally {
|
||||
this.sectorDetailLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
async screen(): Promise<void> {
|
||||
this.error = null;
|
||||
this.healthDismissed = false;
|
||||
this.loading = true;
|
||||
try {
|
||||
const tickers = this.input
|
||||
@@ -46,6 +96,7 @@ class ScreenerStore {
|
||||
async reloadCatalysts(): Promise<void> {
|
||||
this.loadingCats = true;
|
||||
this.error = null;
|
||||
this.healthDismissed = false;
|
||||
try {
|
||||
const cat = await fetchCatalysts();
|
||||
this.input = cat.tickers.join(', ');
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { fetchWatchlist, pinTicker, unpinTicker } from '$lib/api/watchlist.js';
|
||||
|
||||
class WatchlistStore {
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
pins = $state<Set<string>>(new Set());
|
||||
loading = $state(false);
|
||||
ready = $state(false); // true once initial load completes
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
get tickers(): string[] {
|
||||
return [...this.pins];
|
||||
}
|
||||
get count(): number {
|
||||
return this.pins.size;
|
||||
}
|
||||
|
||||
isPinned(ticker: string): boolean {
|
||||
return this.pins.has(ticker);
|
||||
}
|
||||
|
||||
// ── Load from server ───────────────────────────────────────────────────────
|
||||
async load(): Promise<void> {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const { tickers } = await fetchWatchlist();
|
||||
this.pins = new Set(tickers);
|
||||
} catch {
|
||||
// Silently fail — user sees empty watchlist, can retry by visiting page
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.ready = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toggle (optimistic) ────────────────────────────────────────────────────
|
||||
async toggle(ticker: string): Promise<void> {
|
||||
const wasPin = this.pins.has(ticker);
|
||||
|
||||
// Optimistic update — update UI immediately
|
||||
const next = new Set(this.pins);
|
||||
wasPin ? next.delete(ticker) : next.add(ticker);
|
||||
this.pins = next;
|
||||
|
||||
try {
|
||||
if (wasPin) {
|
||||
await unpinTicker(ticker);
|
||||
} else {
|
||||
await pinTicker(ticker);
|
||||
}
|
||||
} catch {
|
||||
// Rollback on failure
|
||||
const rollback = new Set(this.pins);
|
||||
wasPin ? rollback.add(ticker) : rollback.delete(ticker);
|
||||
this.pins = rollback;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remove ─────────────────────────────────────────────────────────────────
|
||||
async remove(ticker: string): Promise<void> {
|
||||
const next = new Set(this.pins);
|
||||
next.delete(ticker);
|
||||
this.pins = next;
|
||||
try {
|
||||
await unpinTicker(ticker);
|
||||
} catch {
|
||||
// Rollback
|
||||
const rollback = new Set(this.pins);
|
||||
rollback.add(ticker);
|
||||
this.pins = rollback;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const watchlistStore = new WatchlistStore();
|
||||
+6
-170
@@ -1,170 +1,6 @@
|
||||
// ── UI type layer ─────────────────────────────────────────────────────────
|
||||
// Shared domain types are imported from the server's canonical model files
|
||||
// via the $types alias (→ server/types/). Only UI-specific types live here.
|
||||
//
|
||||
// All consumers should import from '$lib/types.js' as before — nothing changes
|
||||
// at the call site.
|
||||
|
||||
// ── Re-export shared domain types ────────────────────────────────────────
|
||||
export type {
|
||||
Signal,
|
||||
AssetType,
|
||||
ScoreMode,
|
||||
ScoreResult,
|
||||
AssetResult,
|
||||
ScreenerResult,
|
||||
} from '$types/asset.model.js';
|
||||
|
||||
export type {
|
||||
RateRegime,
|
||||
VolatilityRegime,
|
||||
Benchmarks,
|
||||
MarketContext,
|
||||
} from '$types/market.model.js';
|
||||
|
||||
export type { HoldingType, PortfolioHolding, PortfolioAdvice } from '$types/portfolio.model.js';
|
||||
|
||||
export type { TickerSnapshot, MarketCall } from '$types/calls.model.js';
|
||||
|
||||
export type { LLMAnalysis, CatalystStory, CalendarEvent } from '$types/finance.model.js';
|
||||
|
||||
// ── UI-only types (not on the server) ────────────────────────────────────
|
||||
|
||||
import type { AssetType } from '$types/asset.model.js';
|
||||
import type { LLMAnalysis } from '$types/finance.model.js';
|
||||
|
||||
/** Detailed display metrics rendered per asset row in the screener table. */
|
||||
export interface AssetDisplayMetrics {
|
||||
// ── Common ──────────────────────────────────────────────────────────
|
||||
Price?: string;
|
||||
|
||||
// ── Stock: classification ────────────────────────────────────────────
|
||||
Sector?: string;
|
||||
'Cap Tier'?: string; // Mega Cap / Large Cap / Mid Cap / Small Cap / Micro Cap
|
||||
Style?: string; // High Growth / Growth / Stable / Value / Turnaround / Declining
|
||||
|
||||
// ── Stock: valuation ─────────────────────────────────────────────────
|
||||
'P/E'?: string;
|
||||
PEG?: string;
|
||||
'P/B'?: string;
|
||||
|
||||
// ── Stock: quality ───────────────────────────────────────────────────
|
||||
'GrossM%'?: string; // gross margin — key for tech/software moat
|
||||
'ROE%'?: string;
|
||||
'OpMgn%'?: string;
|
||||
'NetMgn%'?: string;
|
||||
'FCF Yld%'?: string;
|
||||
'Div%'?: string;
|
||||
|
||||
// ── Stock: risk ───────────────────────────────────────────────────────
|
||||
'D/E'?: string;
|
||||
Quick?: string;
|
||||
Beta?: string;
|
||||
|
||||
// ── Stock: 52-week movement ───────────────────────────────────────────
|
||||
'52W Pos'?: string; // % position within the 52-week range
|
||||
'52W Chg'?: string; // total price return over last 52 weeks (signed %)
|
||||
'From High'?: string; // % below 52-week high (negative = drawdown)
|
||||
'From Low'?: string; // % above 52-week low (positive = recovery)
|
||||
|
||||
// ── Stock: analyst consensus ──────────────────────────────────────────
|
||||
Analyst?: string; // Strong Buy / Buy / Hold / Sell / Strong Sell
|
||||
'# Analysts'?: string;
|
||||
Target?: string; // analyst consensus price target
|
||||
Upside?: string; // % upside to analyst target (signed %)
|
||||
|
||||
// ── Stock: DCF intrinsic value ────────────────────────────────────────
|
||||
'DCF Value'?: string; // intrinsic value per share
|
||||
'DCF Safety'?: string; // margin of safety % (positive = undervalued)
|
||||
|
||||
// ── Stock: REIT-specific ──────────────────────────────────────────────
|
||||
'P/FFO'?: string;
|
||||
|
||||
// ── ETF ───────────────────────────────────────────────────────────────
|
||||
'Exp Ratio%'?: string;
|
||||
'Yield%'?: string;
|
||||
AUM?: string;
|
||||
'5Y Return%'?: string;
|
||||
|
||||
// ── Bond ──────────────────────────────────────────────────────────────
|
||||
'YTM%'?: string;
|
||||
Duration?: string;
|
||||
Rating?: string;
|
||||
|
||||
[key: string]: string | null | undefined;
|
||||
}
|
||||
|
||||
/** State object for the LLM analysis slide-over sidebar. */
|
||||
export interface SidebarState {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
analysis: LLMAnalysis | null;
|
||||
type: AssetType | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Transient state for inline row editing in the portfolio table. */
|
||||
export interface InlineEdit {
|
||||
ticker: string;
|
||||
shares: string;
|
||||
costBasis: string;
|
||||
type: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// ── Portfolio component types ─────────────────────────────────────────────
|
||||
|
||||
import type { Signal } from '$types/asset.model.js';
|
||||
|
||||
/** A single row in the portfolio advice table. */
|
||||
export interface AdviceRow {
|
||||
ticker: string;
|
||||
type: string;
|
||||
source: string;
|
||||
shares: number;
|
||||
costBasis: number;
|
||||
currentPrice: string | null;
|
||||
marketValue: string | null;
|
||||
gainLossPct: string | null;
|
||||
signal: Signal | null;
|
||||
advice: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Form data for adding or updating a holding. */
|
||||
export interface HoldingFormData {
|
||||
ticker: string;
|
||||
shares: number;
|
||||
costBasis: number;
|
||||
type: 'stock' | 'etf' | 'bond' | 'crypto';
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface SimpleFINAccount {
|
||||
name: string;
|
||||
type: string;
|
||||
org: string;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
interface CategoryBreakdown {
|
||||
category: string;
|
||||
amount: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
/** Personal finance summary from SimpleFIN. */
|
||||
export interface PersonalFinance {
|
||||
netWorth: number;
|
||||
totalAssets: number;
|
||||
totalLiabilities: number;
|
||||
totalCash: number;
|
||||
totalInvestments: number;
|
||||
totalIncome: number;
|
||||
totalSpend: number;
|
||||
cashPct: number;
|
||||
investPct: number;
|
||||
savingsRate: string | null;
|
||||
accounts: SimpleFINAccount[];
|
||||
categoryBreakdown: CategoryBreakdown[];
|
||||
}
|
||||
/**
|
||||
* Backward-compatibility shim.
|
||||
* Types have been split into lib/types/ subdirectory.
|
||||
* Existing '$lib/types.js' imports continue to work unchanged.
|
||||
*/
|
||||
export * from './types/index.js';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './shared.js';
|
||||
export * from './ui.types.js';
|
||||
export * from './portfolio.types.js';
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Signal } from '$types/asset.model.js';
|
||||
|
||||
/** A single row in the portfolio advice table. */
|
||||
export interface AdviceRow {
|
||||
ticker: string;
|
||||
type: string;
|
||||
source: string;
|
||||
shares: number;
|
||||
costBasis: number;
|
||||
currentPrice: string | null;
|
||||
marketValue: string | null;
|
||||
gainLossPct: string | null;
|
||||
signal: Signal | null;
|
||||
advice: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Form data for adding or updating a holding. */
|
||||
export interface HoldingFormData {
|
||||
ticker: string;
|
||||
shares: number;
|
||||
costBasis: number;
|
||||
type: 'stock' | 'etf' | 'bond' | 'crypto';
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface SimpleFINAccount {
|
||||
name: string;
|
||||
type: string;
|
||||
org: string;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
interface CategoryBreakdown {
|
||||
category: string;
|
||||
amount: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
/** Personal finance summary from SimpleFIN. */
|
||||
export interface PersonalFinance {
|
||||
netWorth: number;
|
||||
totalAssets: number;
|
||||
totalLiabilities: number;
|
||||
totalCash: number;
|
||||
totalInvestments: number;
|
||||
totalIncome: number;
|
||||
totalSpend: number;
|
||||
cashPct: number;
|
||||
investPct: number;
|
||||
savingsRate: string | null;
|
||||
accounts: SimpleFINAccount[];
|
||||
categoryBreakdown: CategoryBreakdown[];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export type {
|
||||
Signal,
|
||||
AssetType,
|
||||
ScoreMode,
|
||||
ScoreResult,
|
||||
AssetResult,
|
||||
ScreenerResult,
|
||||
} from '$types/asset.model.js';
|
||||
|
||||
export type {
|
||||
RateRegime,
|
||||
VolatilityRegime,
|
||||
Benchmarks,
|
||||
MarketContext,
|
||||
} from '$types/market.model.js';
|
||||
|
||||
export type { HoldingType, PortfolioHolding, PortfolioAdvice } from '$types/portfolio.model.js';
|
||||
|
||||
export type { TickerSnapshot, MarketCall } from '$types/calls.model.js';
|
||||
|
||||
export type { LLMAnalysis, CatalystStory, CalendarEvent } from '$types/finance.model.js';
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { AssetType } from '$types/asset.model.js';
|
||||
import type { LLMAnalysis } from '$types/finance.model.js';
|
||||
|
||||
// ── Auth types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Role = 'trader' | 'viewer' | 'admin';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
createdAt: string;
|
||||
lastLogin: string | null;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
/** Detailed display metrics rendered per asset row in the screener table. */
|
||||
export interface AssetDisplayMetrics {
|
||||
// ── Common ──────────────────────────────────────────────────────────
|
||||
Price?: string;
|
||||
|
||||
// ── Stock: classification ────────────────────────────────────────────
|
||||
Sector?: string;
|
||||
'Cap Tier'?: string;
|
||||
Style?: string;
|
||||
|
||||
// ── Stock: valuation ─────────────────────────────────────────────────
|
||||
'P/E'?: string;
|
||||
PEG?: string;
|
||||
'P/B'?: string;
|
||||
|
||||
// ── Stock: quality ───────────────────────────────────────────────────
|
||||
'GrossM%'?: string;
|
||||
'ROE%'?: string;
|
||||
'OpMgn%'?: string;
|
||||
'NetMgn%'?: string;
|
||||
'FCF Yld%'?: string;
|
||||
'Div%'?: string;
|
||||
|
||||
// ── Stock: risk ───────────────────────────────────────────────────────
|
||||
'D/E'?: string;
|
||||
Quick?: string;
|
||||
Beta?: string;
|
||||
|
||||
// ── Stock: 52-week movement ───────────────────────────────────────────
|
||||
'52W Pos'?: string;
|
||||
'52W Chg'?: string;
|
||||
'From High'?: string;
|
||||
'From Low'?: string;
|
||||
|
||||
// ── Stock: analyst consensus ──────────────────────────────────────────
|
||||
Analyst?: string;
|
||||
'# Analysts'?: string;
|
||||
Target?: string;
|
||||
Upside?: string;
|
||||
|
||||
// ── Stock: DCF intrinsic value ────────────────────────────────────────
|
||||
'DCF Value'?: string;
|
||||
'DCF Safety'?: string;
|
||||
|
||||
// ── Stock: REIT-specific ──────────────────────────────────────────────
|
||||
'P/FFO'?: string;
|
||||
|
||||
// ── ETF ───────────────────────────────────────────────────────────────
|
||||
'Exp Ratio%'?: string;
|
||||
'Yield%'?: string;
|
||||
AUM?: string;
|
||||
'5Y Return%'?: string;
|
||||
|
||||
// ── Bond ──────────────────────────────────────────────────────────────
|
||||
'YTM%'?: string;
|
||||
Duration?: string;
|
||||
Rating?: string;
|
||||
|
||||
[key: string]: string | null | undefined;
|
||||
}
|
||||
|
||||
/** State object for the LLM analysis slide-over sidebar. */
|
||||
export interface SidebarState {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
analysis: LLMAnalysis | null;
|
||||
type: AssetType | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Transient state for inline row editing in the portfolio table. */
|
||||
export interface InlineEdit {
|
||||
ticker: string;
|
||||
shares: string;
|
||||
costBasis: string;
|
||||
type: string;
|
||||
source: string;
|
||||
}
|
||||
+4
-103
@@ -1,105 +1,6 @@
|
||||
/**
|
||||
* Shared pure utility functions used across screener, portfolio, and safe-buys pages.
|
||||
* All functions are stateless and framework-agnostic.
|
||||
* Backward-compatibility shim.
|
||||
* New code should import from '$lib/utils/index.js' or the specific submodule.
|
||||
* Existing '$lib/utils.js' imports continue to work unchanged.
|
||||
*/
|
||||
|
||||
// ── Signal ordering ───────────────────────────────────────────────────────────
|
||||
|
||||
export type Signal =
|
||||
| '✅ Strong Buy'
|
||||
| '⚡ Momentum'
|
||||
| '🔄 Neutral'
|
||||
| '⚠️ Speculation'
|
||||
| '❌ Avoid';
|
||||
|
||||
const SIGNAL_ORDER: Record<string, number> = {
|
||||
'✅ Strong Buy': 0,
|
||||
'⚡ Momentum': 1,
|
||||
'🔄 Neutral': 2,
|
||||
'⚠️ Speculation': 3,
|
||||
'❌ Avoid': 4,
|
||||
};
|
||||
|
||||
/** Returns sort order for a signal string (lower = stronger). Unknown signals → 5. */
|
||||
export function sigOrd(signal: string | null | undefined): number {
|
||||
return SIGNAL_ORDER[signal ?? ''] ?? 5;
|
||||
}
|
||||
|
||||
/** Sorts an array of screener result rows by signal strength (strongest first). */
|
||||
export function sorted<T extends { signal?: string | null }>(arr: T[]): T[] {
|
||||
return [...(arr ?? [])].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal));
|
||||
}
|
||||
|
||||
// ── Verdict label helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Converts a long verdict label into a short display string.
|
||||
* e.g. "🟢 BUY (High Conviction)" → "Strong"
|
||||
*/
|
||||
export function verdictShort(label: string | null | undefined): string {
|
||||
if (!label) return '—';
|
||||
if (label.includes('High Conviction')) return 'Strong';
|
||||
if (label.includes('Speculative')) return 'Speculative';
|
||||
if (label.includes('BUY')) return 'Buy';
|
||||
if (label.includes('Efficient')) return 'Efficient';
|
||||
if (label.includes('Attractive')) return 'Attractive';
|
||||
if (label.includes('Neutral')) return 'Hold';
|
||||
if (label.includes('REJECT')) return 'Reject';
|
||||
if (label.includes('Avoid')) return 'Avoid';
|
||||
return label.replace(/[🟢🟡🔴]/u, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a CSS colour class ('green' | 'yellow' | 'red') based on
|
||||
* the emoji prefix of a verdict label.
|
||||
*/
|
||||
export function vClass(label: string | null | undefined): 'green' | 'yellow' | 'red' {
|
||||
if (label?.startsWith('🟢')) return 'green';
|
||||
if (label?.startsWith('🟡')) return 'yellow';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
// ── Number formatters ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Formats a P/E ratio — e.g. 22.5 → "22.5x", null → "—" */
|
||||
export function fmtPE(v: number | null | undefined): string {
|
||||
return v != null ? v + 'x' : '—';
|
||||
}
|
||||
|
||||
/** Full currency format — e.g. 1234.5 → "$1,234.50" */
|
||||
export function fmt(n: number | null | undefined): string {
|
||||
return n != null
|
||||
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n)
|
||||
: '—';
|
||||
}
|
||||
|
||||
/** Compact currency format (no cents) — e.g. 1234.5 → "$1,235" */
|
||||
export function fmtShort(n: number | null | undefined): string {
|
||||
return n != null
|
||||
? new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(n)
|
||||
: '—';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns 'green' for non-negative G/L percentage, 'red' otherwise.
|
||||
* Accepts string (e.g. "12.5") or number.
|
||||
*/
|
||||
export function glClass(pct: string | number | null | undefined): 'green' | 'red' {
|
||||
return parseFloat(String(pct ?? 0)) >= 0 ? 'green' : 'red';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a CSS colour class for a portfolio advice string based on its emoji prefix.
|
||||
* 🟢 → 'green', 🟡 → 'yellow', 🟠 → 'orange', 🔴 → 'red', else 'gray'.
|
||||
*/
|
||||
export function advClass(advice: string | null | undefined): 'green' | 'yellow' | 'orange' | 'red' | 'gray' {
|
||||
if (advice?.includes('🟢')) return 'green';
|
||||
if (advice?.includes('🟡')) return 'yellow';
|
||||
if (advice?.includes('🟠')) return 'orange';
|
||||
if (advice?.includes('🔴')) return 'red';
|
||||
return 'gray';
|
||||
}
|
||||
export * from './utils/index.js';
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Plain-language advice line (personal-use layer).
|
||||
*
|
||||
* Translates the screener's signal + volatility markers into one sentence a
|
||||
* human can act on. Deterministic and derived ONLY from data already on the
|
||||
* row — it adds wording, never new judgment. Tones:
|
||||
* buy → green "buy — stable growth"
|
||||
* mindful → amber "buy, but expect dips"
|
||||
* caution → orange "risky / expensive"
|
||||
* wait → blue "no edge right now"
|
||||
* skip → red "fundamentals don't support it"
|
||||
* unknown → gray "not enough data"
|
||||
*/
|
||||
|
||||
import type { AssetResult } from '$lib/types.js';
|
||||
|
||||
export type AdviceTone = 'buy' | 'mindful' | 'caution' | 'wait' | 'skip' | 'unknown';
|
||||
|
||||
export interface Advice {
|
||||
text: string;
|
||||
tone: AdviceTone;
|
||||
detail: string; // tooltip — why this advice
|
||||
/** True when the advice says something the signal pill doesn't already convey. */
|
||||
addsInfo: boolean;
|
||||
}
|
||||
|
||||
/** Parse "1.85" / "-23.4%" / "—" out of a display metric. */
|
||||
function num(v: string | number | null | undefined): number | null {
|
||||
if (v == null || v === '—') return null;
|
||||
const n = parseFloat(String(v).replace(/[%$,x+]/g, ''));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function adviceFor(row: AssetResult): Advice {
|
||||
const m = row.asset.displayMetrics ?? {};
|
||||
const signal = row.signal ?? '';
|
||||
const coverage = row.fundamental?.audit?.coverage;
|
||||
|
||||
// Not enough data → say so, don't fake confidence
|
||||
if (coverage && coverage.active === 0) {
|
||||
return {
|
||||
text: "Can't judge — not enough data",
|
||||
tone: 'unknown',
|
||||
detail: 'No scoring factors had data for this asset. Treat any verdict as meaningless.',
|
||||
addsInfo: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Volatility / drawdown markers (any one makes a buy "bumpy")
|
||||
const beta = num(m['Beta']);
|
||||
const fromHigh = num(m['From High']);
|
||||
const chg52 = num(m['52W Chg']);
|
||||
const style = String(m['Style'] ?? '');
|
||||
const bumpy =
|
||||
(beta != null && beta > 1.25) ||
|
||||
(fromHigh != null && fromHigh <= -20) ||
|
||||
(chg52 != null && chg52 <= -25) ||
|
||||
style === 'High Growth' ||
|
||||
style === 'Turnaround';
|
||||
|
||||
if (signal.includes('Strong Buy')) {
|
||||
return bumpy
|
||||
? {
|
||||
text: 'Buy, but expect dips — long-term growth',
|
||||
tone: 'mindful',
|
||||
detail:
|
||||
'Passes both the strict value gates and market-adjusted gates, but it moves sharply ' +
|
||||
`(beta ${beta ?? '—'}, ${fromHigh ?? '—'}% off its high). Falls are likely along the way; ` +
|
||||
'the fundamentals say the trend supports holding through them.',
|
||||
addsInfo: true,
|
||||
}
|
||||
: {
|
||||
text: 'Buy — stable growth',
|
||||
tone: 'buy',
|
||||
detail:
|
||||
'Passes both the strict value gates and market-adjusted gates with calm price behavior. ' +
|
||||
'The closest thing this screener has to a steady compounder.',
|
||||
addsInfo: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (signal.includes('Momentum')) {
|
||||
return {
|
||||
text: 'Be mindful — rising on momentum, can fall fast',
|
||||
tone: 'mindful',
|
||||
detail:
|
||||
'Acceptable at today’s market prices but does not pass the strict value gates. ' +
|
||||
'Fine while the market is kind; expect sharper falls when it isn’t.',
|
||||
addsInfo: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (signal.includes('Speculation')) {
|
||||
return {
|
||||
text: 'Caution — priced for perfection',
|
||||
tone: 'caution',
|
||||
detail:
|
||||
'Only passes the loosened market-adjusted gates and fails fundamentals. ' +
|
||||
'Buy only with money you can watch swing hard.',
|
||||
addsInfo: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (signal.includes('Neutral')) {
|
||||
return {
|
||||
text: 'Wait — no clear edge right now',
|
||||
tone: 'wait',
|
||||
detail:
|
||||
'Neither clearly cheap nor clearly strong. Nothing here argues for buying today; ' +
|
||||
'keep it on the watchlist and let the daily digest tell you if that changes.',
|
||||
addsInfo: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (signal.includes('Avoid')) {
|
||||
return {
|
||||
text: 'Skip — fundamentals don’t support it',
|
||||
tone: 'skip',
|
||||
detail: 'Fails both the strict and the market-adjusted gates. The data says no.',
|
||||
addsInfo: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: '—',
|
||||
tone: 'unknown',
|
||||
detail: 'No signal computed for this asset.',
|
||||
addsInfo: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 💎 Quality dip: passes strict OR market-adjusted quality gates AND trades
|
||||
* 10%+ below its 52-week high. A dip with a sound base — candidate to recover.
|
||||
*/
|
||||
export function isQualityDip(row: AssetResult): boolean {
|
||||
const fromHigh = num(row.asset.displayMetrics?.['From High'] as string | undefined);
|
||||
const quality = row.fundamental?.tier === 'PASS' || row.inflated?.tier === 'PASS';
|
||||
return quality && fromHigh != null && fromHigh <= -10;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Number and currency formatting utilities.
|
||||
*/
|
||||
|
||||
/** Formats a P/E ratio — e.g. 26.72091 → "26.7x", null → "—" */
|
||||
export function fmtPE(v: number | null | undefined): string {
|
||||
return v != null ? v.toFixed(1) + 'x' : '—';
|
||||
}
|
||||
|
||||
/** Full currency format — e.g. 1234.5 → "$1,234.50" */
|
||||
export function fmt(n: number | null | undefined): string {
|
||||
return n != null
|
||||
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n)
|
||||
: '—';
|
||||
}
|
||||
|
||||
/** Compact currency format (no cents) — e.g. 1234.5 → "$1,235" */
|
||||
export function fmtShort(n: number | null | undefined): string {
|
||||
return n != null
|
||||
? new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(n)
|
||||
: '—';
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './sorting.js';
|
||||
export * from './verdicts.js';
|
||||
export * from './formatting.js';
|
||||
export * from './advice.js';
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Signal ordering and sorting utilities.
|
||||
*/
|
||||
|
||||
export type Signal =
|
||||
| '✅ Strong Buy'
|
||||
| '⚡ Momentum'
|
||||
| '🔄 Neutral'
|
||||
| '⚠️ Speculation'
|
||||
| '❌ Avoid';
|
||||
|
||||
const SIGNAL_ORDER: Record<string, number> = {
|
||||
'✅ Strong Buy': 0,
|
||||
'⚡ Momentum': 1,
|
||||
'🔄 Neutral': 2,
|
||||
'⚠️ Speculation': 3,
|
||||
'❌ Avoid': 4,
|
||||
};
|
||||
|
||||
/** Returns sort order for a signal string (lower = stronger). Unknown signals → 5. */
|
||||
export function sigOrd(signal: string | null | undefined): number {
|
||||
return SIGNAL_ORDER[signal ?? ''] ?? 5;
|
||||
}
|
||||
|
||||
/** Sorts an array of screener result rows by signal strength (strongest first). */
|
||||
export function sorted<T extends { signal?: string | null }>(arr: T[]): T[] {
|
||||
return [...(arr ?? [])].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Verdict label helpers — convert long verdict strings to short display values
|
||||
* and derive CSS colour classes from emoji prefixes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converts a long verdict label into a short display string.
|
||||
* e.g. "🟢 BUY (High Conviction)" → "Strong"
|
||||
*/
|
||||
export function verdictShort(label: string | null | undefined): string {
|
||||
if (!label) return '—';
|
||||
if (label.includes('No Data')) return 'No Data';
|
||||
if (label.includes('High Conviction')) return 'Strong Buy';
|
||||
if (label.includes('Speculative')) return 'Speculative';
|
||||
if (label.includes('Momentum')) return 'Momentum';
|
||||
if (label.includes('BUY')) return 'Buy';
|
||||
if (label.includes('Efficient')) return 'Efficient';
|
||||
if (label.includes('Attractive')) return 'Attractive';
|
||||
if (label.includes('Neutral')) return 'Hold';
|
||||
if (label.includes('REJECT')) return 'Reject';
|
||||
if (label.includes('Avoid')) return 'Avoid';
|
||||
return label.replace(/[\u{1F7E2}\u{1F7E1}\u{1F534}]/u, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a CSS colour class based on the verdict label content.
|
||||
*
|
||||
* Signal mapping:
|
||||
* 🟢 / High Conviction / Efficient / Attractive → green
|
||||
* 🟡 / Speculative / Momentum → yellow
|
||||
* Neutral / Hold / no signal → blue (calm, not alarming)
|
||||
* 🔴 / Avoid / Reject / REJECT → red
|
||||
*/
|
||||
export function vClass(
|
||||
label: string | null | undefined,
|
||||
): 'green' | 'yellow' | 'red' | 'blue' | 'gray' {
|
||||
if (!label) return 'gray';
|
||||
// Insufficient data is unknown, not a neutral opinion — render gray
|
||||
if (label.includes('No Data')) return 'gray';
|
||||
if (
|
||||
label.startsWith('🟢') ||
|
||||
label.includes('High Conviction') ||
|
||||
label.includes('Efficient') ||
|
||||
label.includes('Attractive')
|
||||
)
|
||||
return 'green';
|
||||
if (label.startsWith('🟡') || label.includes('Speculative') || label.includes('Momentum'))
|
||||
return 'yellow';
|
||||
if (
|
||||
label.startsWith('🔴') ||
|
||||
label.includes('Avoid') ||
|
||||
label.includes('Reject') ||
|
||||
label.includes('REJECT')
|
||||
)
|
||||
return 'red';
|
||||
if (label.includes('Neutral') || label.includes('Hold') || label.includes('BUY')) return 'blue';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a CSS colour class for a portfolio advice string based on its emoji prefix.
|
||||
* 🟢 → 'green', 🟡 → 'yellow', 🟠 → 'orange', 🔴 → 'red', else 'gray'.
|
||||
*/
|
||||
export function advClass(
|
||||
advice: string | null | undefined,
|
||||
): 'green' | 'yellow' | 'orange' | 'red' | 'gray' {
|
||||
if (advice?.includes('🟢')) return 'green';
|
||||
if (advice?.includes('🟡')) return 'yellow';
|
||||
if (advice?.includes('🟠')) return 'orange';
|
||||
if (advice?.includes('🔴')) return 'red';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns 'green' for non-negative G/L percentage, 'red' otherwise.
|
||||
* Accepts string (e.g. "12.5") or number.
|
||||
*/
|
||||
export function glClass(pct: string | number | null | undefined): 'green' | 'red' {
|
||||
return parseFloat(String(pct ?? 0)) >= 0 ? 'green' : 'red';
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { page, navigating } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import '../styles/app.scss';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
import { authStore } from '$lib/stores/auth.store.svelte.js';
|
||||
import { watchlistStore } from '$lib/stores/watchlist.store.svelte.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
@@ -9,6 +12,21 @@
|
||||
// so the nav link highlights immediately on click, not after load completes.
|
||||
const activePath = $derived($navigating?.to?.url?.pathname ?? $page.url.pathname);
|
||||
|
||||
// All routes except /auth/* require login
|
||||
$effect(() => {
|
||||
const path = $page.url.pathname;
|
||||
if (!path.startsWith('/auth/') && !authStore.isLoggedIn) {
|
||||
goto('/auth/login');
|
||||
}
|
||||
});
|
||||
|
||||
// Load watchlist once when user is authenticated
|
||||
$effect(() => {
|
||||
if (authStore.isLoggedIn && !watchlistStore.ready && !watchlistStore.loading) {
|
||||
watchlistStore.load();
|
||||
}
|
||||
});
|
||||
|
||||
const navLabel = $derived(
|
||||
activePath === '/portfolio' ? 'Loading portfolio…' :
|
||||
activePath?.startsWith('/calls') ? 'Loading market calls…' :
|
||||
@@ -21,10 +39,22 @@
|
||||
<nav>
|
||||
<span class="brand">📊 Market Screener</span>
|
||||
<div class="links">
|
||||
<a href="/" class:active={activePath === '/'}>Screener</a>
|
||||
<a href="/portfolio" class:active={activePath === '/portfolio'}>Portfolio</a>
|
||||
<a href="/calls" class:active={activePath?.startsWith('/calls')}>Market Calls</a>
|
||||
<a href="/safe-buys" class:active={activePath === '/safe-buys'}>🛡 Safe Buys</a>
|
||||
{#if authStore.isLoggedIn}
|
||||
<a href="/" class:active={activePath === '/'}>Screener</a>
|
||||
<a href="/portfolio" class:active={activePath === '/portfolio'}>Portfolio</a>
|
||||
<a href="/calls" class:active={activePath?.startsWith('/calls')}>Market Calls</a>
|
||||
<a href="/safe-buys" class:active={activePath === '/safe-buys'}>🛡 Safe Buys</a>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="nav-auth">
|
||||
{#if authStore.isLoggedIn}
|
||||
<span class="nav-user">{authStore.user?.email}</span>
|
||||
<button class="btn-ghost btn-sm" onclick={() => { authStore.clearAuth(); goto('/auth/login'); }}>
|
||||
Sign out
|
||||
</button>
|
||||
{:else}
|
||||
<a href="/auth/login" class="btn-ghost btn-sm">Sign in</a>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
+28
-95
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { screenerStore } from '$lib/stores/screener.store.svelte.js';
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import VerdictPill from '$lib/VerdictPill.svelte';
|
||||
import MarketContextStrip from '$lib/MarketContextStrip.svelte';
|
||||
import AssetTable from '$lib/AssetTable.svelte';
|
||||
import AnalysisSidebar from '$lib/AnalysisSidebar.svelte';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
import MarketContextStrip from '$lib/components/shared/MarketContextStrip.svelte';
|
||||
import AssetTable from '$lib/components/screener/AssetTable.svelte';
|
||||
import AnalysisSidebar from '$lib/components/screener/AnalysisSidebar.svelte';
|
||||
import WatchlistPanel from '$lib/components/screener/WatchlistPanel.svelte';
|
||||
import SectorPulse from '$lib/components/screener/SectorPulse.svelte';
|
||||
import SectorPanel from '$lib/components/screener/SectorPanel.svelte';
|
||||
|
||||
const s = screenerStore;
|
||||
|
||||
@@ -20,17 +21,19 @@
|
||||
if (_booted) return;
|
||||
_booted = true;
|
||||
s.reloadCatalysts();
|
||||
s.loadSectorPulse();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="screener-page">
|
||||
|
||||
<!-- ── Market pulse — page-level header band (sectors today) ──────── -->
|
||||
<SectorPulse />
|
||||
<SectorPanel />
|
||||
|
||||
<!-- ── Toolbar ────────────────────────────────────────────────────── -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-top">
|
||||
<button onclick={() => s.reloadCatalysts()} disabled={s.loading || s.loadingCats} class="btn-catalyst">
|
||||
{#if s.loadingCats}<Spinner size="sm" />{:else}📰 Today Market{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => searchOpen = !searchOpen}
|
||||
class="btn-search-toggle"
|
||||
@@ -46,6 +49,7 @@
|
||||
{#if searchOpen}
|
||||
<div class="search-row">
|
||||
<input
|
||||
class="search-input"
|
||||
bind:value={s.input}
|
||||
placeholder="AAPL, MSFT, VOO …"
|
||||
onkeydown={e => e.key === 'Enter' && s.screen()}
|
||||
@@ -65,6 +69,13 @@
|
||||
<div class="error-banner">⚠ {s.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if s.dataHealth}
|
||||
<div class="warn-banner" role="alert">
|
||||
<span>⚠ {s.dataHealth.message}</span>
|
||||
<button class="warn-dismiss" onclick={() => s.healthDismissed = true} title="Dismiss">✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if s.loading || s.loadingCats}
|
||||
<div class="loading-area">
|
||||
<Spinner size="lg" label={s.loadingCats ? 'Fetching news catalysts…' : 'Screening tickers…'} />
|
||||
@@ -72,43 +83,6 @@
|
||||
{/if}
|
||||
|
||||
{#if s.results && !s.loading && !s.loadingCats}
|
||||
<!-- ── Signal Summary ───────────────────────────────────────────── -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Signal Summary</h2>
|
||||
<span class="count">{s.allAssets.length} assets</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-ticker">Ticker</th>
|
||||
<th>Type</th>
|
||||
<th>Signal</th>
|
||||
<th>Mkt-Adjusted</th>
|
||||
<th>Fundamental</th>
|
||||
<th title="Market cap tier (stocks only)">Cap</th>
|
||||
<th title="Growth / style classification (stocks only)">Style</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each s.allAssets as r}
|
||||
{@const dm = r.asset.displayMetrics ?? {}}
|
||||
<tr>
|
||||
<td class="ticker">{r.asset.ticker}</td>
|
||||
<td><span class="tag">{r.asset.type}</span></td>
|
||||
<td><SignalBadge signal={r.signal} /></td>
|
||||
<td><VerdictPill label={r.inflated.label} /></td>
|
||||
<td><VerdictPill label={r.fundamental.label} /></td>
|
||||
<td class="dim-cell">{dm['Cap Tier'] ?? '—'}</td>
|
||||
<td class="dim-cell">{dm['Style'] ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Per-type detail tables ────────────────────────────────────── -->
|
||||
{#each (['STOCK', 'ETF', 'BOND'] as const) as type}
|
||||
{#if s.results[type]?.length}
|
||||
@@ -133,53 +107,12 @@
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<WatchlistPanel />
|
||||
</div>
|
||||
|
||||
<AnalysisSidebar sidebar={s.sidebar} onClose={() => s.closeSidebar()} />
|
||||
|
||||
<style>
|
||||
.page { max-width: 1400px; padding-bottom: 60px; }
|
||||
|
||||
.toolbar { margin-bottom: 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.toolbar-top { display: flex; align-items: center; gap: 8px; }
|
||||
.search-row { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
padding: 10px var(--space-lg);
|
||||
font-size: var(--fs-md);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
letter-spacing: 0.02em;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
|
||||
&:focus { border-color: var(--blue); box-shadow: 0 0 0 2px #3b82f620; }
|
||||
}
|
||||
|
||||
.btn-search-toggle {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border-input);
|
||||
font-size: 12px;
|
||||
padding: 8px var(--space-lg);
|
||||
|
||||
&:hover { background: #263347; color: var(--text-muted); }
|
||||
}
|
||||
|
||||
.screened-at {
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-dimmer);
|
||||
}
|
||||
|
||||
.dim-cell { font-size: var(--fs-sm); color: var(--text-dim); white-space: nowrap; }
|
||||
|
||||
.error-list { padding: 12px var(--space-xl); display: flex; flex-direction: column; gap: 6px; }
|
||||
.error-item { color: var(--text-dim); font-size: 12px; }
|
||||
.error-item :global(.ticker) { color: var(--red); font-weight: 700; margin-right: 8px; }
|
||||
</style>
|
||||
<AnalysisSidebar
|
||||
sidebar={s.sidebar}
|
||||
onClose={() => s.closeSidebar()}
|
||||
onScreenTickers={(tickers) => { s.input = tickers.join(', '); s.screen(); }}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.store.svelte.js';
|
||||
|
||||
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
let email = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let success = $state(false);
|
||||
let loading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (authStore.isLoggedIn) goto('/');
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`${BASE}/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const { error: msg } = await res.json().catch(() => ({ error: 'Request failed' }));
|
||||
throw new Error(msg);
|
||||
}
|
||||
success = true;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Something went wrong';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-wrap">
|
||||
<form class="login-form" onsubmit={handleSubmit}>
|
||||
<h1 class="login-title">Forgot password</h1>
|
||||
<p class="login-subtitle">Enter your email and check the server console for a reset link.</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<div class="success-banner">
|
||||
Reset link printed to server console. Copy it and open it in your browser.
|
||||
</div>
|
||||
<p class="auth-switch">
|
||||
<a href="/auth/login">Back to sign in</a>
|
||||
</p>
|
||||
{:else}
|
||||
<label class="field">
|
||||
<span>Email</span>
|
||||
<input type="email" autocomplete="email" required bind:value={email} disabled={loading} />
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn-primary" disabled={loading}>
|
||||
{loading ? 'Sending…' : 'Send reset link'}
|
||||
</button>
|
||||
|
||||
<p class="auth-switch">
|
||||
<a href="/auth/login">Back to sign in</a>
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-wrap {
|
||||
/* Grid centering — robust regardless of parent flex context */
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 180px); /* viewport minus nav + main padding */
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: min(380px, 100%);
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: -0.75rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-banner {
|
||||
background: color-mix(in srgb, var(--signal-buy) 15%, transparent);
|
||||
border: 1px solid var(--signal-buy);
|
||||
color: var(--signal-buy);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-dim);
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { login } from '$lib/api/auth.js';
|
||||
import { authStore } from '$lib/stores/auth.store.svelte.js';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
// If already logged in, redirect to home
|
||||
$effect(() => {
|
||||
if (authStore.isLoggedIn) goto('/');
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
loading = true;
|
||||
try {
|
||||
const { token, user } = await login(email, password);
|
||||
authStore.setAuth(token, user);
|
||||
await goto('/');
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Login failed';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-wrap">
|
||||
<form class="login-form" onsubmit={handleSubmit}>
|
||||
<h1 class="login-title">Market Screener</h1>
|
||||
<p class="login-subtitle">Sign in to continue</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner">{error}</div>
|
||||
{/if}
|
||||
|
||||
<label class="field">
|
||||
<span>Email</span>
|
||||
<input
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
bind:value={email}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
minlength="8"
|
||||
bind:value={password}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn-primary" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
|
||||
<p class="auth-switch">
|
||||
<a href="/auth/forgot-password">Forgot password?</a>
|
||||
</p>
|
||||
<p class="auth-switch">
|
||||
Don't have an account? <a href="/auth/register">Register</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Auth page layout only — input styles come from global _forms.scss */
|
||||
.login-wrap {
|
||||
/* Grid centering — robust regardless of parent flex context */
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 180px); /* viewport minus nav + main padding */
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: min(380px, 100%);
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: -0.75rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-dim);
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { register } from '$lib/api/auth.js';
|
||||
import { authStore } from '$lib/stores/auth.store.svelte.js';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let confirm = $state('');
|
||||
let inviteCode = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (authStore.isLoggedIn) goto('/');
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
if (password !== confirm) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const { token, user } = await register(email, password, 'trader', inviteCode);
|
||||
authStore.setAuth(token, user);
|
||||
await goto('/');
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Registration failed';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-wrap">
|
||||
<form class="login-form" onsubmit={handleSubmit}>
|
||||
<h1 class="login-title">Create account</h1>
|
||||
<p class="login-subtitle">Market Screener</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner">{error}</div>
|
||||
{/if}
|
||||
|
||||
<label class="field">
|
||||
<span>Email</span>
|
||||
<input type="email" autocomplete="email" required bind:value={email} disabled={loading} />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Password <small>(min 8 characters)</small></span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="8"
|
||||
bind:value={password}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Confirm password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="8"
|
||||
bind:value={confirm}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Invite code</span>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ask the admin for an invite code"
|
||||
bind:value={inviteCode}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn-primary" disabled={loading}>
|
||||
{loading ? 'Creating account…' : 'Create account'}
|
||||
</button>
|
||||
|
||||
<p class="auth-switch">
|
||||
Already have an account? <a href="/auth/login">Sign in</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Auth page layout only — input/select styles come from global _forms.scss */
|
||||
.login-wrap {
|
||||
/* Grid centering — robust regardless of parent flex context */
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 180px); /* viewport minus nav + main padding */
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: min(380px, 100%);
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: -0.75rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.field small {
|
||||
font-weight: 400;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-dim);
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { authStore } from '$lib/stores/auth.store.svelte.js';
|
||||
|
||||
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
const token = $derived($page.url.searchParams.get('token') ?? '');
|
||||
|
||||
let password = $state('');
|
||||
let confirm = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let success = $state(false);
|
||||
let loading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (authStore.isLoggedIn) goto('/');
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
if (password !== confirm) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
error = 'Missing reset token — please use the full link from the console.';
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`${BASE}/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const { error: msg } = await res.json().catch(() => ({ error: 'Reset failed' }));
|
||||
throw new Error(msg);
|
||||
}
|
||||
success = true;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Something went wrong';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-wrap">
|
||||
<form class="login-form" onsubmit={handleSubmit}>
|
||||
<h1 class="login-title">Reset password</h1>
|
||||
<p class="login-subtitle">Choose a new password for your account.</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<div class="success-banner">
|
||||
Password updated. You can now sign in with your new password.
|
||||
</div>
|
||||
<a href="/auth/login" class="btn-primary" style="text-align:center;">Go to sign in</a>
|
||||
{:else}
|
||||
<label class="field">
|
||||
<span>New password <small>(min 8 characters)</small></span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="8"
|
||||
bind:value={password}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Confirm password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="8"
|
||||
bind:value={confirm}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn-primary" disabled={loading}>
|
||||
{loading ? 'Updating…' : 'Set new password'}
|
||||
</button>
|
||||
|
||||
<p class="auth-switch">
|
||||
<a href="/auth/login">Back to sign in</a>
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-wrap {
|
||||
/* Grid centering — robust regardless of parent flex context */
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 180px); /* viewport minus nav + main padding */
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: min(380px, 100%);
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: -0.75rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-banner {
|
||||
background: color-mix(in srgb, var(--signal-buy) 15%, transparent);
|
||||
border: 1px solid var(--signal-buy);
|
||||
color: var(--signal-buy);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-dim);
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.field small {
|
||||
font-weight: 400;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { createCall, deleteCall } from '$lib/api.js';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import CallForm from '$lib/calls/CallForm.svelte';
|
||||
import CallCard from '$lib/calls/CallCard.svelte';
|
||||
import CalendarSection from '$lib/calls/CalendarSection.svelte';
|
||||
import CallForm from '$lib/components/calls/CallForm.svelte';
|
||||
import CallCard from '$lib/components/calls/CallCard.svelte';
|
||||
import CalendarSection from '$lib/components/calls/CalendarSection.svelte';
|
||||
import type { CalendarEvent } from '$lib/types.js';
|
||||
|
||||
interface MarketCall {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { portfolioStore } from '$lib/stores/portfolio.store.svelte.js';
|
||||
import MarketContext from '$lib/MarketContext.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import AddHoldingForm from '$lib/portfolio/AddHoldingForm.svelte';
|
||||
import AdviceTable from '$lib/portfolio/AdviceTable.svelte';
|
||||
import AccountsTable from '$lib/portfolio/AccountsTable.svelte';
|
||||
import MarketContext from '$lib/components/shared/MarketContext.svelte';
|
||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||
import AddHoldingForm from '$lib/components/portfolio/AddHoldingForm.svelte';
|
||||
import AdviceTable from '$lib/components/portfolio/AdviceTable.svelte';
|
||||
import AccountsTable from '$lib/components/portfolio/AccountsTable.svelte';
|
||||
|
||||
const p = portfolioStore;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{:else if p.loadError}
|
||||
<div class="error">{p.loadError}</div>
|
||||
|
||||
{:else if p.data?.advice}
|
||||
{:else if p.data}
|
||||
<div class="portfolio-toolbar">
|
||||
<button class="btn-add" onclick={() => p.formOpen ? p.closeForm() : p.openForm()}>
|
||||
{p.formOpen ? '✕ Cancel' : '+ Add Holding'}
|
||||
@@ -41,14 +41,20 @@
|
||||
<AddHoldingForm saving={p.saving} error={p.formError} onSubmit={d => p.add(d)} onClose={() => p.closeForm()} />
|
||||
{/if}
|
||||
|
||||
{#if p.data.marketContext}
|
||||
<MarketContext ctx={p.data.marketContext} collapsible={true} />
|
||||
{/if}
|
||||
{#if p.data.advice.length === 0 && !p.formOpen}
|
||||
<div class="empty-state">
|
||||
<p>No holdings yet. Add your first position to get started.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if p.data.marketContext}
|
||||
<MarketContext ctx={p.data.marketContext} collapsible={true} />
|
||||
{/if}
|
||||
|
||||
<AdviceTable rows={p.data.advice} onUpdate={(t, d) => p.update(t, d)} onDelete={t => p.remove(t)} />
|
||||
<AdviceTable rows={p.data.advice} onUpdate={(t, d) => p.update(t, d)} onDelete={t => p.remove(t)} />
|
||||
|
||||
{#if p.data.personalFinance}
|
||||
<AccountsTable pf={p.data.personalFinance} />
|
||||
{#if p.data.personalFinance}
|
||||
<AccountsTable pf={p.data.personalFinance} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import MarketContext from '$lib/MarketContext.svelte';
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import VerdictPill from '$lib/VerdictPill.svelte';
|
||||
import MarketContext from '$lib/components/shared/MarketContext.svelte';
|
||||
import SignalBadge from '$lib/components/shared/SignalBadge.svelte';
|
||||
import VerdictPill from '$lib/components/shared/VerdictPill.svelte';
|
||||
import { sorted } from '$lib/utils.js';
|
||||
import type { AssetResult, MarketContext as MarketContextType } from '$lib/types.js';
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
const totalStrong = $derived(strongEtfs.length + strongBonds.length);
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="safe-buys-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>🛡 Safe Buys</h1>
|
||||
@@ -231,60 +231,3 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Page ── unique to this route ──────────────────────────────── */
|
||||
.page { max-width: 1100px; padding-bottom: 60px; }
|
||||
|
||||
.page-header { margin-bottom: 20px; }
|
||||
h1 { font-size: var(--fs-2xl); font-weight: 700; color: var(--text-primary); margin-bottom: 6px; }
|
||||
.subtitle { font-size: 12px; color: var(--text-dimmer); line-height: 1.5; }
|
||||
.subtitle strong { color: var(--text-muted); }
|
||||
|
||||
/* ── Strong Buy banner ───────────────────────────────────────────── */
|
||||
.strong-header { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
|
||||
.strong-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--green);
|
||||
background: var(--green-bg);
|
||||
padding: 4px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.strong-sub { font-size: var(--fs-sm); color: var(--text-dimmer); }
|
||||
|
||||
.empty-strong {
|
||||
padding: var(--space-3xl) 20px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Watch List ──────────────────────────────────────────────────── */
|
||||
.watch-header { display: flex; align-items: center; gap: 12px; margin-top: 28px; margin-bottom: 12px; }
|
||||
|
||||
.watch-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-card);
|
||||
padding: 4px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.watch-sub { font-size: var(--fs-sm); color: var(--text-dimmer); }
|
||||
|
||||
/* Watch sections are slightly dimmed — hover to focus */
|
||||
.watch-section { opacity: 0.75; }
|
||||
.watch-section:hover { opacity: 1; transition: opacity 0.2s; }
|
||||
|
||||
/* ── Score cell ─────────────────────────────────────────────────── */
|
||||
.score { color: var(--text-dimmer); font-size: var(--fs-sm); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
onMount(() => goto('/'));
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -13,15 +13,20 @@
|
||||
// Unified: replaces both .verdict-pill (screener) and .vpill (safe-buys)
|
||||
|
||||
$verdict-variants: (
|
||||
'green': (color: var(--green), bg: var(--green-bg)),
|
||||
'yellow': (color: var(--yellow), bg: var(--yellow-bg)),
|
||||
'red': (color: var(--red), bg: var(--red-bg)),
|
||||
'green': (color: var(--green), bg: var(--green-bg)),
|
||||
'yellow': (color: var(--yellow), bg: var(--yellow-bg)),
|
||||
'red': (color: var(--red), bg: var(--red-bg)),
|
||||
'blue': (color: #60a5fa, bg: #1e3a5f33),
|
||||
'gray': (color: var(--text-muted), bg: #1e293b),
|
||||
);
|
||||
|
||||
.verdict-pill {
|
||||
@extend %pill-base;
|
||||
font-size: var(--fs-sm);
|
||||
letter-spacing: 0.02em;
|
||||
// Ensure all pills have a consistent look — fallback to gray
|
||||
background: #1e293b;
|
||||
color: var(--text-muted);
|
||||
|
||||
@each $name, $vals in $verdict-variants {
|
||||
&.#{$name} {
|
||||
|
||||
@@ -75,6 +75,34 @@ button {
|
||||
&:hover:not(:disabled) { background: #163356; }
|
||||
}
|
||||
|
||||
// ── btn-glossary (ghost — "? Glossary" in section header) ──────────────
|
||||
|
||||
.btn-glossary {
|
||||
@extend %btn-inline-flex;
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
border: 1px solid var(--border);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 4px 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border-input);
|
||||
}
|
||||
|
||||
&.btn-glossary-active {
|
||||
color: #60a5fa;
|
||||
background: #1e3a5f22;
|
||||
border-color: #1e3a5f88;
|
||||
}
|
||||
}
|
||||
|
||||
// ── btn-analyze (ghost blue — "✦ Analyze" in section header) ────────────
|
||||
|
||||
.btn-analyze {
|
||||
|
||||
@@ -23,7 +23,6 @@ nav {
|
||||
.links {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
margin-left: auto;
|
||||
|
||||
a {
|
||||
color: var(--text-dim);
|
||||
@@ -42,6 +41,29 @@ nav {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nav auth (sign in / user + sign out) ─────────────────────────────────
|
||||
|
||||
.nav-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.nav-user {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-dim);
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
main { flex: 1; padding: 28px 32px; }
|
||||
|
||||
// ── Navigation progress bar ───────────────────────────────────────────────
|
||||
@@ -91,3 +113,20 @@ main { flex: 1; padding: 28px 32px; }
|
||||
align-items: center;
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
margin-left: 5px;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--blue);
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,19 @@
|
||||
&:hover { background: var(--blue-darker); }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-dimmer);
|
||||
font-size: var(--fs-md);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 20px;
|
||||
|
||||
p { margin: 0; }
|
||||
}
|
||||
|
||||
.refreshing-hint {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-dimmer);
|
||||
@@ -203,6 +216,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Reason column — wraps so it doesn't blow out the table width
|
||||
.col-reason {
|
||||
white-space: normal !important;
|
||||
max-width: 260px;
|
||||
min-width: 160px;
|
||||
line-height: 1.4;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.advice-table .sortable { cursor: pointer; user-select: none; &:hover { color: var(--text-muted); } }
|
||||
|
||||
.advice-row-actions { display: flex; gap: 4px; align-items: center; }
|
||||
|
||||
.btn-row-edit {
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-family: var(--font-ui);
|
||||
background: var(--bg-base);
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--fs-md);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
input, select { font-family: inherit; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,3 +76,31 @@
|
||||
margin-bottom: 16px;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
// ── Warning banner (data-sanity sentinel, P0.4) ───────────────────────────
|
||||
|
||||
.warn-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--amber-dim);
|
||||
border: 1px solid var(--amber);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--amber);
|
||||
padding: 10px var(--space-lg);
|
||||
margin-bottom: 16px;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.warn-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--amber);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
line-height: 1;
|
||||
|
||||
&:hover { filter: brightness(1.3); }
|
||||
}
|
||||
|
||||
@@ -153,3 +153,387 @@
|
||||
color: var(--text-dim);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// ── GlossaryPanel ─────────────────────────────────────────────────────────
|
||||
|
||||
.glossary-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 98; // below panel (99) — transparent click catcher
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.glossary-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 340px;
|
||||
background: #0d1117;
|
||||
border-left: 1px solid #1e2a3a;
|
||||
z-index: 99;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: -6px 0 32px #00000088;
|
||||
}
|
||||
|
||||
.glossary-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.glossary-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.glossary-title-q {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--text-dimmer);
|
||||
font-size: 10px;
|
||||
color: var(--text-dimmer);
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.glossary-close {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dimmer);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-xs);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover { color: var(--text-muted); background: var(--bg-card); border-color: var(--border-input); }
|
||||
}
|
||||
|
||||
.glossary-search-wrap {
|
||||
position: relative;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.glossary-search {
|
||||
width: 100%;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
padding: 7px 28px 7px 10px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder { color: var(--text-faint); }
|
||||
&:focus { border-color: var(--blue); box-shadow: 0 0 0 2px #3b82f618; }
|
||||
}
|
||||
|
||||
.glossary-search-clear {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { color: var(--text-muted); }
|
||||
}
|
||||
|
||||
.glossary-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 0 20px;
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #1e3a5f transparent;
|
||||
&::-webkit-scrollbar { width: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: #1e3a5f; border-radius: 2px; }
|
||||
}
|
||||
|
||||
// ── Context banner — fixed between search and body ────────────────────────
|
||||
|
||||
.glossary-ctx-banner {
|
||||
background: #0a2e1a;
|
||||
border-top: 1px solid #1a4a2a;
|
||||
border-bottom: 1px solid #1a4a2a;
|
||||
padding: 9px 14px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #4ade80;
|
||||
letter-spacing: 0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0; // flush with panel edges
|
||||
}
|
||||
|
||||
.glossary-category { margin-bottom: 2px; }
|
||||
|
||||
.glossary-cat-header {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-faint);
|
||||
padding: 12px 14px 4px;
|
||||
}
|
||||
|
||||
// ── Individual item ───────────────────────────────────────────────────────
|
||||
|
||||
.glossary-item {
|
||||
border-left: 3px solid transparent;
|
||||
transition: border-color 0.15s ease, background 0.1s ease;
|
||||
margin: 0 8px 2px;
|
||||
border-radius: 6px;
|
||||
|
||||
// Highlighted = relevant to selected row → green left border, no bg change
|
||||
&.glossary-item-active {
|
||||
border-left-color: #22c55e;
|
||||
}
|
||||
|
||||
// Open/expanded → purple tint + purple left border
|
||||
&.glossary-item-open {
|
||||
border-left-color: #7c3aed;
|
||||
background: #1a1035;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.glossary-item-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 9px 10px 9px 11px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
gap: 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:hover { background: #151f2e; }
|
||||
}
|
||||
|
||||
.glossary-item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.glossary-active-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 4px #22c55e66;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ── Category tags — all same gray pill, label text only ───────────────────
|
||||
|
||||
.glossary-cat-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: capitalize;
|
||||
padding: 2px 9px;
|
||||
border-radius: var(--radius-pill);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
// All same gray — no per-category color variation
|
||||
background: #1e2736;
|
||||
color: #64748b;
|
||||
border: 1px solid #2a3544;
|
||||
}
|
||||
|
||||
// ── Expanded body — card style matching mockup ────────────────────────────
|
||||
|
||||
.glossary-item-body {
|
||||
margin: 0 8px 10px 8px;
|
||||
padding: 12px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: #131c2a;
|
||||
border: 1px solid #1e2d40;
|
||||
border-radius: 8px;
|
||||
overflow: hidden; // prevent pill overflow clipping
|
||||
}
|
||||
|
||||
.glossary-definition {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// ── Gate box (code-styled monospace block) ────────────────────────────────
|
||||
|
||||
.glossary-gate-box {
|
||||
background: #0d1520;
|
||||
border: 1px solid #1e2d40;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
|
||||
code {
|
||||
font-family: 'JetBrains Mono', var(--font-mono, 'SF Mono', 'Fira Code', monospace);
|
||||
font-size: 12px;
|
||||
color: #93c5fd;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Range pills ───────────────────────────────────────────────────────────
|
||||
|
||||
.glossary-range-pills {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.glossary-range-pill {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
white-space: nowrap;
|
||||
line-height: 1.3;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&.grange-good {
|
||||
background: #0d2e1a;
|
||||
color: #4ade80;
|
||||
border: 1px solid #1a4a2a;
|
||||
}
|
||||
|
||||
&.grange-bad {
|
||||
background: #2e0d0d;
|
||||
color: #f87171;
|
||||
border: 1px solid #4a1a1a;
|
||||
}
|
||||
|
||||
&.grange-neutral {
|
||||
background: #1e2736;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #2a3544;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Range labels (below pills) ────────────────────────────────────────────
|
||||
|
||||
.glossary-range-labels {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
|
||||
span {
|
||||
flex: 1 1 0;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
.grlabel-good { color: #4ade80; }
|
||||
.grlabel-neutral { color: #64748b; }
|
||||
.grlabel-bad { color: #f87171; }
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────
|
||||
|
||||
.glossary-empty {
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-dimmer);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// ── Column-header tooltip (300ms CSS delay) ───────────────────────────────
|
||||
// Only active inside <thead> — metric cards in detail panel use dp-mc-help instead
|
||||
|
||||
thead .col-tip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: help;
|
||||
|
||||
&::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 220px;
|
||||
white-space: normal;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease 300ms;
|
||||
z-index: 200;
|
||||
box-shadow: 0 4px 16px #00000055;
|
||||
}
|
||||
|
||||
&:hover::after { opacity: 1; }
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ table {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-faint);
|
||||
color: var(--text-dim);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
background: var(--bg-elevated);
|
||||
@@ -27,11 +27,7 @@ table {
|
||||
tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-card-hover);
|
||||
|
||||
td:first-child { background: var(--bg-card-hover); }
|
||||
}
|
||||
&:hover { background: var(--bg-card-hover); }
|
||||
}
|
||||
|
||||
td {
|
||||
@@ -40,11 +36,12 @@ table {
|
||||
white-space: nowrap;
|
||||
font-size: var(--fs-md);
|
||||
|
||||
// Sticky first column (body)
|
||||
// Sticky first column — inherits row background so hover/select states
|
||||
// paint through correctly across the full row width
|
||||
&:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: var(--bg-surface);
|
||||
background: inherit;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +63,39 @@ table {
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Ticker opens the company modal (profile + chart + news)
|
||||
.ticker-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px dashed transparent;
|
||||
|
||||
&:hover {
|
||||
color: var(--blue);
|
||||
border-bottom-color: var(--blue);
|
||||
}
|
||||
}
|
||||
|
||||
// Turnaround-watch badge: Turnaround style + improving score (candidate flag)
|
||||
.ta-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--green);
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 8px;
|
||||
padding: 1px 5px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.num {
|
||||
|
||||
+52
-36
@@ -1,56 +1,68 @@
|
||||
// ── Design Tokens ────────────────────────────────────────────────────────
|
||||
// SCSS maps generate CSS custom properties. Organised by category so new
|
||||
// tokens are easy to locate and add. All runtime theming uses var(--name).
|
||||
// Single strict surface hierarchy. No decorative colors — semantic only.
|
||||
|
||||
// Background layers — darkest to lightest
|
||||
$bg: (
|
||||
base: #0f1117, // body, nav background
|
||||
surface: #0d1117, // section cards, sidebar
|
||||
elevated: #111827, // section headers, table headers
|
||||
card: #1e293b, // input bg, tags, summary cards, ctx cards
|
||||
card-hover: #131c2b, // table row hover
|
||||
row-alt: #1a2233, // portfolio table row border
|
||||
base: #0a0e14, // body, nav
|
||||
surface: #111820, // section cards, sidebar, table headers
|
||||
elevated: #1a2332, // input bg, metric cards, chips
|
||||
input: #0d1319, // filter inputs, search fields
|
||||
overlay: #000000a6, // modal backdrop
|
||||
row-sel: #0f1f35, // selected/expanded row background
|
||||
);
|
||||
|
||||
// Borders
|
||||
$borders: (
|
||||
border: #1e293b, // primary — cards, sections, inputs
|
||||
border-subtle: #161f2e, // table row separators
|
||||
border-input: #2d3f55, // form input borders
|
||||
border: #1e2d3d, // primary — cards, sections
|
||||
border-subtle: #10182a, // table row separators
|
||||
border-input: #263447, // form input borders, chips
|
||||
);
|
||||
|
||||
// Text — brightest to most muted
|
||||
$text: (
|
||||
text-primary: #f1f5f9, // headings, tickers, card values
|
||||
text-secondary: #e2e8f0, // body text, input values
|
||||
text-muted: #94a3b8, // secondary text, reasons
|
||||
text-dim: #64748b, // labels, table headers, muted values
|
||||
text-dimmer: #475569, // very muted — timestamps, hints
|
||||
text-faint: #334155, // count badge text, column headers
|
||||
text-primary: #e2eaf4, // headings, tickers, values
|
||||
text-secondary: #7a93ad, // body text, labels, column headers
|
||||
text-muted: #3d5166, // very muted — timestamps, category labels
|
||||
);
|
||||
|
||||
// Blue accent ('blue' is a CSS color name — must be quoted)
|
||||
// Blue accent
|
||||
$blues: (
|
||||
'blue': #3b82f6, // focus ring, progress bar, bar-fill
|
||||
'blue-dark': #2563eb, // primary button bg
|
||||
'blue-darker': #1d4ed8, // primary button hover
|
||||
'blue-muted': #60a5fa, // active tab text, edit icon hover
|
||||
'blue-surface': #1e3a5f, // active tab bg, mode tab bg
|
||||
'blue-deep': #0f2240, // analyze button hover bg
|
||||
'blue-badge': #0d1e30, // sidebar header background
|
||||
'blue': #4da6ff, // selection border, active states
|
||||
'blue-dim': #0d2240, // active tab bg
|
||||
'blue-mid': #1a3a5c, // hover states
|
||||
'blue-dark': #1a4a8a, // primary button bg
|
||||
'blue-darker': #2060aa, // primary button hover
|
||||
// legacy aliases — kept so other partials don't break
|
||||
'blue-muted': #4da6ff,
|
||||
'blue-surface': #1a3a5c,
|
||||
'blue-deep': #0d2240,
|
||||
'blue-badge': #0d1319,
|
||||
);
|
||||
|
||||
// Signal / semantic colors (green/yellow/red/orange are CSS color names — must be quoted)
|
||||
// Semantic colors
|
||||
$signals: (
|
||||
'green': #4ade80,
|
||||
'green-bg': #14532d33,
|
||||
'yellow': #facc15,
|
||||
'yellow-bg': #71350033,
|
||||
'red': #f87171,
|
||||
'red-bg': #450a0a33,
|
||||
'red-deep': #450a0a55, // error banner bg
|
||||
'red-border': #7f1d1d, // error banner border
|
||||
'orange': #fb923c,
|
||||
// green
|
||||
'green': #34d17a,
|
||||
'green-dim': #0d2e1a,
|
||||
'green-mid': #1a4a2a,
|
||||
'green-bg': #0d2e1a, // alias for old name
|
||||
// red
|
||||
'red': #f05a5a,
|
||||
'red-dim': #2e0d0d,
|
||||
'red-bg': #2e0d0d,
|
||||
'red-deep': #2e0d0d,
|
||||
'red-border': #4a1a1a,
|
||||
// amber / warning
|
||||
'amber': #f0b429,
|
||||
'amber-dim': #2e2000,
|
||||
// orange — alias kept
|
||||
'orange': #f0b429,
|
||||
// purple — glossary accent
|
||||
'purple': #a78bfa,
|
||||
'purple-dim': #1e1535,
|
||||
// yellow alias
|
||||
'yellow': #f0b429,
|
||||
'yellow-bg': #2e2000,
|
||||
);
|
||||
|
||||
// ── Emit all maps as CSS custom properties ───────────────────────────────
|
||||
@@ -62,6 +74,9 @@ $signals: (
|
||||
@each $name, $val in $signals { --#{$name}: #{$val}; }
|
||||
|
||||
// Typography
|
||||
--font-ui: 'Inter', -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
|
||||
--fs-2xs: 9px;
|
||||
--fs-xs: 10px;
|
||||
--fs-sm: 11px;
|
||||
@@ -87,5 +102,6 @@ $signals: (
|
||||
--space-3xl: 32px;
|
||||
|
||||
// Transitions
|
||||
--transition: 0.15s;
|
||||
--transition: 0.18s ease;
|
||||
--transition-slow: 0.28s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
@use 'sidebar';
|
||||
@use 'calls';
|
||||
@use 'portfolio';
|
||||
@use 'screener';
|
||||
|
||||
Reference in New Issue
Block a user