phase-10.5: screener enhancements

This commit is contained in:
saikiranvella
2026-06-11 19:18:19 -04:00
parent bac00ab5d5
commit e953822bab
51 changed files with 3745 additions and 36 deletions
+15
View File
@@ -3,6 +3,21 @@
// 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';
+93
View File
@@ -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 }> {
@@ -1,8 +1,9 @@
<script lang="ts">
import { sigOrd, sorted } from '$lib/utils.js';
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';
@@ -22,6 +23,7 @@
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);
@@ -33,21 +35,29 @@
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);
return !!(filterTicker || filterSignal || filterStyle || filterCap || filterPriceMin || filterPriceMax || filterScoreMin || filterFlags || filterTA || filterQD);
}
function clearFilters() {
filterTicker = ''; filterSignal = ''; filterStyle = ''; filterCap = '';
filterPriceMin = ''; filterPriceMax = ''; filterScoreMin = ''; filterFlags = false;
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));
@@ -286,6 +296,20 @@
<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}
@@ -449,9 +473,18 @@
title={watchlistStore.isPinned(r.asset.ticker) ? 'Remove from watchlist' : 'Add to watchlist'}
>{watchlistStore.isPinned(r.asset.ticker) ? '📌' : '🔖'}</button>
</td>
<td class="ticker">{r.asset.ticker}</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 -->
<!-- Signal pill + plain-language advice -->
<td>
<div class="signal-verdict-cell">
<button
@@ -461,6 +494,12 @@
>
{(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 -->
@@ -675,6 +714,23 @@
</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>
@@ -695,3 +751,12 @@
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,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,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 0100%. */
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 screeners 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>
+44 -1
View File
@@ -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';
@@ -31,6 +38,42 @@ class ScreenerStore {
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;
+140
View File
@@ -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 todays market prices but does not pass the strict value gates. ' +
'Fine while the market is kind; expect sharper falls when it isnt.',
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 dont 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;
}
+1
View File
@@ -1,3 +1,4 @@
export * from './sorting.js';
export * from './verdicts.js';
export * from './formatting.js';
export * from './advice.js';
+7
View File
@@ -5,6 +5,8 @@
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;
@@ -19,11 +21,16 @@
if (_booted) return;
_booted = true;
s.reloadCatalysts();
s.loadSectorPulse();
});
</script>
<div class="screener-page">
<!-- ── Market pulse — page-level header band (sectors today) ──────── -->
<SectorPulse />
<SectorPanel />
<!-- ── Toolbar ────────────────────────────────────────────────────── -->
<div class="toolbar">
<div class="toolbar-top">
@@ -71,16 +71,17 @@
<style>
.login-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
/* 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: 100%;
max-width: 380px;
width: min(380px, 100%);
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
@@ -91,12 +92,14 @@
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 {
+9 -6
View File
@@ -77,16 +77,17 @@
<style>
/* Auth page layout only — input styles come from global _forms.scss */
.login-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
/* 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: 100%;
max-width: 380px;
width: min(380px, 100%);
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
@@ -97,12 +98,14 @@
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 {
+9 -6
View File
@@ -96,16 +96,17 @@
<style>
/* Auth page layout only — input/select styles come from global _forms.scss */
.login-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
/* 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: 100%;
max-width: 380px;
width: min(380px, 100%);
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
@@ -116,12 +117,14 @@
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 {
@@ -100,16 +100,17 @@
<style>
.login-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
/* 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: 100%;
max-width: 380px;
width: min(380px, 100%);
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
@@ -120,12 +121,14 @@
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 {
+48
View File
@@ -180,6 +180,54 @@
&:hover { color: var(--red); }
}
// ── Plain-language advice line (personal-use layer) ──────────────────────
.advice-line {
font-size: 10.5px;
line-height: 1.3;
margin-top: 3px;
cursor: help;
max-width: 200px;
}
.advice-buy { color: var(--green); }
.advice-mindful { color: var(--amber); }
.advice-caution { color: var(--orange); }
.advice-wait { color: var(--blue); }
.advice-skip { color: var(--red); }
.advice-unknown { color: var(--text-muted); font-style: italic; }
// Turnaround-watch filter toggle (STOCK section header)
.ta-filter-btn {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
background: transparent;
border: 1px solid var(--border);
border-radius: 12px;
padding: 3px 10px;
margin-left: 8px;
cursor: pointer;
&:hover { color: var(--green); border-color: var(--green); }
&.active {
color: var(--green);
border-color: var(--green);
background: rgba(74, 222, 128, 0.08);
}
&.qd:hover, &.qd.active { color: var(--blue); border-color: var(--blue); background: rgba(96, 165, 250, 0.08); }
}
.empty-row td {
text-align: center;
font-size: 12px;
color: var(--text-muted);
font-style: italic;
padding: 18px 24px;
}
// ── Column headers ────────────────────────────────────────────────────────
.sort-th {
+33
View File
@@ -63,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 {