phase-10.5: screener enhancements
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './sorting.js';
|
||||
export * from './verdicts.js';
|
||||
export * from './formatting.js';
|
||||
export * from './advice.js';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user