phase-1: optimize code
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,132 @@
|
||||
<script>
|
||||
import { page, navigating } from '$app/stores';
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
|
||||
// Label shown under the nav progress bar while loading a page
|
||||
const navLabel = $derived(
|
||||
$navigating?.to?.url?.pathname === '/portfolio' ? 'Loading portfolio…' :
|
||||
$navigating?.to?.url?.pathname?.startsWith('/calls') ? 'Loading market calls…' :
|
||||
$navigating?.to?.url?.pathname === '/safe-buys' ? 'Screening safe buys…' :
|
||||
'Loading…'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
<nav>
|
||||
<span class="brand">📊 Market Screener</span>
|
||||
<div class="links">
|
||||
<a href="/" class:active={$page.url.pathname === '/'}>Screener</a>
|
||||
<a href="/portfolio" class:active={$page.url.pathname === '/portfolio'}>Portfolio</a>
|
||||
<a href="/calls" class:active={$page.url.pathname.startsWith('/calls')}>Market Calls</a>
|
||||
<a href="/safe-buys" class:active={$page.url.pathname === '/safe-buys'}>🛡 Safe Buys</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Thin progress bar at top of screen — always visible even on first nav -->
|
||||
{#if $navigating}
|
||||
<div class="nav-progress">
|
||||
<div class="nav-bar"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main>
|
||||
{#if $navigating}
|
||||
<!-- Replace old page content immediately — old page disappears, spinner takes over -->
|
||||
<div class="nav-overlay">
|
||||
<div class="nav-spinner"></div>
|
||||
<span class="nav-label">{navLabel}</span>
|
||||
</div>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shell { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 14px 32px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
background: #0f1117;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.brand { font-size: 15px; font-weight: 700; color: #f1f5f9; }
|
||||
|
||||
.links { display: flex; gap: 4px; margin-left: auto; }
|
||||
|
||||
.links a {
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.links a:hover { color: #e2e8f0; background: #1e293b; }
|
||||
.links a.active { color: #e2e8f0; background: #1e293b; }
|
||||
|
||||
main { flex: 1; padding: 28px 32px; }
|
||||
|
||||
/* ── Navigation progress ─────────────────────────────────────────── */
|
||||
.nav-progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
z-index: 100;
|
||||
background: #1e293b;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
animation: progress 1.5s ease-in-out infinite;
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% { transform: translateX(-100%) scaleX(0.3); }
|
||||
50% { transform: translateX(0%) scaleX(0.7); }
|
||||
100% { transform: translateX(100%) scaleX(0.3); }
|
||||
}
|
||||
|
||||
/* Centered spinner + label in the page body */
|
||||
.nav-overlay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: 100px 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #1e293b;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,858 @@
|
||||
<script>
|
||||
import { screenTickers, fetchCatalysts, analyzeTickers } from '$lib/api.js';
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
|
||||
let input = $state('');
|
||||
let searchOpen = $state(false); // collapsed by default
|
||||
let loading = $state(false);
|
||||
let loadingCats = $state(false);
|
||||
let error = $state(null);
|
||||
let results = $state(null);
|
||||
let activeTab = $state({});
|
||||
let screenedAt = $state(null);
|
||||
|
||||
// Auto-load catalysts once on mount
|
||||
let _booted = false;
|
||||
$effect(() => {
|
||||
if (!_booted) { _booted = true; loadCatalysts(); }
|
||||
});
|
||||
|
||||
// ── Per-tab LLM Analysis sidebar ────────────────────────────────────────────
|
||||
let sidebar = $state({ open: false, loading: false, analysis: null, type: null, error: null });
|
||||
|
||||
async function runTabAnalysis(type) {
|
||||
const tickers = (results?.[type] ?? []).map(r => r.asset.ticker);
|
||||
if (!tickers.length) return;
|
||||
sidebar = { open: true, loading: true, analysis: null, type, error: null };
|
||||
try {
|
||||
const res = await analyzeTickers(tickers);
|
||||
const reason = res.reason === 'no_stories' ? 'No recent news found for these tickers.' : null;
|
||||
sidebar = { open: true, loading: false, analysis: res.analysis, type, error: res.analysis ? null : (reason ?? 'Analysis failed — check server logs for details.') };
|
||||
} catch (e) {
|
||||
sidebar = { open: true, loading: false, analysis: null, type, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function closeSidebar() {
|
||||
sidebar = { ...sidebar, open: false };
|
||||
}
|
||||
|
||||
async function screen() {
|
||||
error = null;
|
||||
loading = true;
|
||||
try {
|
||||
const tickers = input.split(/[\s,]+/).map(t => t.trim().toUpperCase()).filter(Boolean);
|
||||
results = await screenTickers(tickers);
|
||||
screenedAt = new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Load catalysts then immediately screen — no extra click needed.
|
||||
// LLM analysis (if available) is shown alongside the results.
|
||||
async function loadCatalysts() {
|
||||
loadingCats = true;
|
||||
error = null;
|
||||
try {
|
||||
const cat = await fetchCatalysts();
|
||||
const catInput = cat.tickers.join(', ');
|
||||
loading = true;
|
||||
results = await screenTickers(cat.tickers);
|
||||
screenedAt = new Date().toLocaleTimeString();
|
||||
if (!input) input = catInput;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingCats = false;
|
||||
}
|
||||
}
|
||||
|
||||
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
|
||||
const sorted = arr => [...(arr ?? [])].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal));
|
||||
|
||||
const verdictShort = label => {
|
||||
if (!label) return '—';
|
||||
if (label.includes('High Conviction')) return 'Strong';
|
||||
if (label.includes('Speculative')) return 'Speculative';
|
||||
if (label.includes('BUY')) return 'Buy';
|
||||
if (label.includes('Efficient')) return 'Efficient';
|
||||
if (label.includes('Attractive')) return 'Attractive';
|
||||
if (label.includes('Neutral')) return 'Hold';
|
||||
if (label.includes('REJECT')) return 'Reject';
|
||||
if (label.includes('Avoid')) return 'Avoid';
|
||||
return label.replace(/[🟢🟡🔴]/u, '').trim();
|
||||
};
|
||||
|
||||
const vClass = label =>
|
||||
label?.startsWith('🟢') ? 'green' : label?.startsWith('🟡') ? 'yellow' : 'red';
|
||||
|
||||
const getTab = type => activeTab[type] ?? 'inflated';
|
||||
const setTab = (type, tab) => activeTab = { ...activeTab, [type]: tab };
|
||||
|
||||
const ctx = $derived(results?.marketContext ?? null);
|
||||
const allAssets = $derived(results
|
||||
? sorted([...results.STOCK, ...results.ETF, ...results.BOND])
|
||||
: []);
|
||||
|
||||
const fmtPE = v => v != null ? v + 'x' : '—';
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- ── Toolbar ──────────────────────────────────────────────────── -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-top">
|
||||
<button onclick={loadCatalysts} disabled={loading || loadingCats} class="btn-catalyst">
|
||||
{#if loadingCats}<Spinner size="sm" />{:else}📰 Today Market{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => searchOpen = !searchOpen}
|
||||
class="btn-search-toggle"
|
||||
title="Screen custom tickers"
|
||||
>
|
||||
🔍 {searchOpen ? 'Hide search' : 'Search tickers'}
|
||||
</button>
|
||||
{#if screenedAt}
|
||||
<span class="screened-at">Last screened {screenedAt}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if searchOpen}
|
||||
<div class="search-row">
|
||||
<input
|
||||
bind:value={input}
|
||||
placeholder="AAPL, MSFT, VOO …"
|
||||
onkeydown={e => e.key === 'Enter' && screen()}
|
||||
/>
|
||||
<button onclick={screen} disabled={loading || loadingCats} class="btn-screen">
|
||||
{#if loading}<Spinner size="sm" />{:else}Screen{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner">⚠ {error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading || loadingCats}
|
||||
<div class="loading-area">
|
||||
<Spinner size="lg" label={loadingCats ? 'Fetching news catalysts…' : loading ? `Screening tickers…` : ''} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ctx}
|
||||
<!-- ── Market Context Strip ────────────────────────────────────── -->
|
||||
<div class="ctx-strip">
|
||||
<div class="ctx-chip">
|
||||
<span class="ctx-label">10Y</span>
|
||||
<span class="ctx-val">{ctx.riskFreeRate?.toFixed(2)}%</span>
|
||||
</div>
|
||||
<div class="ctx-chip">
|
||||
<span class="ctx-label">VIX</span>
|
||||
<span class="ctx-val">{ctx.vixLevel?.toFixed(1)}</span>
|
||||
</div>
|
||||
<div class="ctx-chip">
|
||||
<span class="ctx-label">S&P</span>
|
||||
<span class="ctx-val">{ctx.sp500Price?.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="ctx-chip">
|
||||
<span class="ctx-label">S&P P/E</span>
|
||||
<span class="ctx-val">{fmtPE(ctx.benchmarks?.marketPE?.toFixed(1))}</span>
|
||||
</div>
|
||||
<div class="ctx-chip">
|
||||
<span class="ctx-label">Tech P/E</span>
|
||||
<span class="ctx-val">{fmtPE(ctx.benchmarks?.techPE?.toFixed(1))}</span>
|
||||
</div>
|
||||
<div class="ctx-chip">
|
||||
<span class="ctx-label">REIT Yld</span>
|
||||
<span class="ctx-val">{ctx.benchmarks?.reitYield?.toFixed(2)}%</span>
|
||||
</div>
|
||||
<div class="ctx-chip">
|
||||
<span class="ctx-label">IG Sprd</span>
|
||||
<span class="ctx-val">{ctx.benchmarks?.igSpread?.toFixed(2)}%</span>
|
||||
</div>
|
||||
<div class="ctx-chip">
|
||||
<span class="ctx-label">Rates</span>
|
||||
<span class="ctx-val ctx-regime" data-regime={ctx.rateRegime}>{ctx.rateRegime}</span>
|
||||
</div>
|
||||
<div class="ctx-chip">
|
||||
<span class="ctx-label">Vol</span>
|
||||
<span class="ctx-val ctx-regime" data-regime={ctx.volatilityRegime}>{ctx.volatilityRegime}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Signal Summary ─────────────────────────────────────────── -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Signal Summary</h2>
|
||||
<span class="count">{allAssets.length} assets</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-ticker">Ticker</th>
|
||||
<th>Type</th>
|
||||
<th>Signal</th>
|
||||
<th>Mkt-Adjusted</th>
|
||||
<th>Fundamental</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each allAssets as r}
|
||||
<tr>
|
||||
<td class="ticker">{r.asset.ticker}</td>
|
||||
<td><span class="tag">{r.asset.type}</span></td>
|
||||
<td><SignalBadge signal={r.signal} /></td>
|
||||
<td>
|
||||
<span class="verdict-pill {vClass(r.inflated.label)}">
|
||||
{verdictShort(r.inflated.label)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="verdict-pill {vClass(r.fundamental.label)}">
|
||||
{verdictShort(r.fundamental.label)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Detail Sections ────────────────────────────────────────── -->
|
||||
{#each ['STOCK', 'ETF', 'BOND'] as type}
|
||||
{#if results[type]?.length}
|
||||
{@const count = results[type].length}
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>{type}S</h2>
|
||||
<span class="count">{count}</span>
|
||||
<div class="mode-tabs">
|
||||
<button
|
||||
class:active={getTab(type) === 'inflated'}
|
||||
onclick={() => setTab(type, 'inflated')}
|
||||
>Mkt-Adjusted</button>
|
||||
<button
|
||||
class:active={getTab(type) === 'fundamental'}
|
||||
onclick={() => setTab(type, 'fundamental')}
|
||||
>Graham</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn-analyze"
|
||||
onclick={() => runTabAnalysis(type)}
|
||||
disabled={sidebar.loading && sidebar.type === type}
|
||||
title="AI analysis of news for these tickers"
|
||||
>
|
||||
{#if sidebar.loading && sidebar.type === type}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
✦ Analyze
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-ticker">Ticker</th>
|
||||
<th>Price</th>
|
||||
<th>Verdict</th>
|
||||
<th>Score</th>
|
||||
{#if type === 'STOCK'}
|
||||
<th>Sector</th>
|
||||
<th>P/E</th><th>PEG</th><th>ROE%</th>
|
||||
<th>OpMgn%</th><th>FCF%</th><th>D/E</th>
|
||||
<th>Flags</th>
|
||||
{:else if type === 'ETF'}
|
||||
<th>Expense</th><th>Yield</th><th>AUM</th><th>5Y Ret</th>
|
||||
{:else}
|
||||
<th>YTM</th><th>Duration</th><th>Rating</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted(results[type]) as r}
|
||||
{@const mode = getTab(type)}
|
||||
{@const m = r.asset.displayMetrics ?? {}}
|
||||
{@const v = r[mode]}
|
||||
<tr class="data-row" data-signal={sigOrd(r.signal)}>
|
||||
<td class="ticker">{r.asset.ticker}</td>
|
||||
<td class="num">{m.Price ?? '—'}</td>
|
||||
<td>
|
||||
<span class="verdict-pill {vClass(v.label)}">
|
||||
{verdictShort(v.label)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="score-cell" title={v.scoreSummary}>{v.scoreSummary}</td>
|
||||
{#if type === 'STOCK'}
|
||||
<td><span class="tag sm">{m.Sector ?? '—'}</span></td>
|
||||
<td class="num">{m['P/E'] ?? '—'}</td>
|
||||
<td class="num">{m['PEG'] ?? '—'}</td>
|
||||
<td class="num">{m['ROE%'] ?? '—'}</td>
|
||||
<td class="num">{m['OpMgn%'] ?? '—'}</td>
|
||||
<td class="num">{m['FCF Yld%'] ?? '—'}</td>
|
||||
<td class="num">{m['D/E'] ?? '—'}</td>
|
||||
<td class="flags">
|
||||
{#each v.audit?.riskFlags ?? [] as flag}
|
||||
<span class="flag">⚠ {flag}</span>
|
||||
{/each}
|
||||
</td>
|
||||
{:else if type === 'ETF'}
|
||||
<td class="num">{m['Exp Ratio%'] ?? '—'}</td>
|
||||
<td class="num">{m['Yield%'] ?? '—'}</td>
|
||||
<td class="num">{m['AUM'] ?? '—'}</td>
|
||||
<td class="num">{m['5Y Return%'] ?? '—'}</td>
|
||||
{:else}
|
||||
<td class="num">{m['YTM%'] ?? '—'}</td>
|
||||
<td class="num">{m['Duration'] ?? '—'}</td>
|
||||
<td class="num">{m['Rating'] ?? '—'}</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if results.ERROR?.length}
|
||||
<section class="section">
|
||||
<h2>Failed <span class="count">{results.ERROR.length}</span></h2>
|
||||
<div class="error-list">
|
||||
{#each results.ERROR as e}
|
||||
<div class="error-item"><span class="ticker">{e.ticker}</span> {e.message}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── LLM Analysis Sidebar ─────────────────────────────────────────────── -->
|
||||
{#if sidebar.open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="sidebar-backdrop" onclick={closeSidebar}></div>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-title">
|
||||
<span>🤖 LLM Analysis</span>
|
||||
{#if sidebar.type}<span class="sidebar-type">{sidebar.type}S</span>{/if}
|
||||
</div>
|
||||
<button class="sidebar-close" onclick={closeSidebar}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-body">
|
||||
{#if sidebar.loading}
|
||||
<div class="sidebar-loading">
|
||||
<Spinner size="lg" label="Analyzing tickers…" />
|
||||
</div>
|
||||
{:else if sidebar.error}
|
||||
<div class="sidebar-error">{sidebar.error}</div>
|
||||
{:else if sidebar.analysis}
|
||||
{@const a = sidebar.analysis}
|
||||
<div class="sb-sentiment-row">
|
||||
<span class="sentiment-pill" data-sentiment={a.sentiment}>{a.sentiment}</span>
|
||||
</div>
|
||||
<p class="sb-summary">{a.summary}</p>
|
||||
|
||||
<h3 class="sb-sub">Affected Industries</h3>
|
||||
<div class="sb-list">
|
||||
{#each a.affectedIndustries ?? [] as ind}
|
||||
<div class="sb-item">
|
||||
<span class="sb-name">{ind.name}</span>
|
||||
<span class="sb-reason">{ind.reason}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h3 class="sb-sub">Related Tickers to Watch</h3>
|
||||
<div class="sb-list">
|
||||
{#each a.relatedTickers ?? [] as rt}
|
||||
<div class="sb-item">
|
||||
<span class="sb-name ticker">{rt.ticker}</span>
|
||||
<span class="sb-reason">{rt.reason}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* ── Page ──────────────────────────────────────────────────────── */
|
||||
.page { max-width: 1400px; padding-bottom: 60px; }
|
||||
|
||||
/* ── Toolbar ────────────────────────────────────────────────────── */
|
||||
.toolbar { margin-bottom: 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.toolbar-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #1e293b;
|
||||
border: 1px solid #2d3f55;
|
||||
border-radius: 8px;
|
||||
color: #e2e8f0;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
letter-spacing: 0.02em;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f620; }
|
||||
|
||||
button {
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
button:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
/* Primary catalyst button */
|
||||
.btn-catalyst {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
.btn-catalyst:hover:not(:disabled) { background: #1d4ed8; }
|
||||
|
||||
/* Secondary search toggle */
|
||||
.btn-search-toggle {
|
||||
background: #1e293b;
|
||||
color: #64748b;
|
||||
border: 1px solid #2d3f55;
|
||||
font-size: 12px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.btn-search-toggle:hover { background: #263347; color: #94a3b8; }
|
||||
|
||||
/* Screen button inside the expanded search row */
|
||||
.btn-screen {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
border: 1px solid #1e3a5f;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 80px;
|
||||
}
|
||||
.btn-screen:hover:not(:disabled) { background: #163356; }
|
||||
|
||||
.screened-at {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.loading-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: #450a0a55;
|
||||
border: 1px solid #7f1d1d;
|
||||
border-radius: 8px;
|
||||
color: #f87171;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Market Context Strip ───────────────────────────────────────── */
|
||||
.ctx-strip {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ctx-chip {
|
||||
flex: 1;
|
||||
min-width: 70px;
|
||||
background: #0f1117;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ctx-label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.ctx-val {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ctx-regime[data-regime="HIGH"] { color: #f87171; }
|
||||
.ctx-regime[data-regime="NORMAL"] { color: #94a3b8; }
|
||||
.ctx-regime[data-regime="LOW"] { color: #4ade80; }
|
||||
|
||||
/* ── Section ────────────────────────────────────────────────────── */
|
||||
.section {
|
||||
background: #0d1117;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 18px 12px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
background: #1e293b;
|
||||
padding: 2px 7px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* ── Mode Tabs ──────────────────────────────────────────────────── */
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mode-tabs button {
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
border: 1px solid #1e293b;
|
||||
font-size: 11px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.mode-tabs button.active {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
border-color: #1e3a5f;
|
||||
}
|
||||
|
||||
/* ── Table ──────────────────────────────────────────────────────── */
|
||||
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
|
||||
table { width: max-content; min-width: 100%; border-collapse: collapse; }
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
padding: 8px 14px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
white-space: nowrap;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
tbody tr { border-bottom: 1px solid #161f2e; }
|
||||
tbody tr:hover { background: #131c2b; }
|
||||
|
||||
tbody td {
|
||||
padding: 10px 14px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Sticky ticker column */
|
||||
.col-ticker,
|
||||
tbody td:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: inherit;
|
||||
z-index: 1;
|
||||
}
|
||||
thead .col-ticker { background: #111827; }
|
||||
tbody td:first-child { background: #0d1117; }
|
||||
tbody tr:hover td:first-child { background: #131c2b; }
|
||||
|
||||
.ticker {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: #f1f5f9;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.num {
|
||||
color: #64748b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Score cell: truncates gate failure text, shown in full via title tooltip */
|
||||
.score-cell {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Verdict Pill ───────────────────────────────────────────────── */
|
||||
.verdict-pill {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.verdict-pill.green { background: #14532d33; color: #4ade80; }
|
||||
.verdict-pill.yellow { background: #71350033; color: #facc15; }
|
||||
.verdict-pill.red { background: #450a0a33; color: #f87171; }
|
||||
|
||||
/* ── Tags ───────────────────────────────────────────────────────── */
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: #1e293b;
|
||||
color: #64748b;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tag.sm { font-size: 10px; padding: 1px 6px; }
|
||||
|
||||
/* ── Risk Flags ─────────────────────────────────────────────────── */
|
||||
.flags { display: flex; flex-direction: column; gap: 2px; }
|
||||
.flag { color: #fb923c; font-size: 11px; }
|
||||
|
||||
/* ── Errors ─────────────────────────────────────────────────────── */
|
||||
.error-list { padding: 12px 18px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.error-item { color: #64748b; font-size: 12px; }
|
||||
.error-item .ticker { color: #f87171; font-weight: 700; margin-right: 8px; }
|
||||
|
||||
/* ── Analyze button ─────────────────────────────────────────────── */
|
||||
.btn-analyze {
|
||||
background: transparent;
|
||||
color: #7c93b0;
|
||||
border: 1px solid #1e293b;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.btn-analyze:hover:not(:disabled) {
|
||||
background: #0f2240;
|
||||
color: #93c5fd;
|
||||
border-color: #1e3a5f;
|
||||
}
|
||||
.btn-analyze:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* ── LLM Sidebar ────────────────────────────────────────────────── */
|
||||
.sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #00000055;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 380px;
|
||||
background: #0d1117;
|
||||
border-left: 1px solid #1e3a5f;
|
||||
z-index: 101;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
background: #0d1e30;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.sidebar-type {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.sidebar-close:hover { color: #94a3b8; background: #1e293b; }
|
||||
|
||||
.sidebar-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sidebar-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.sidebar-error {
|
||||
color: #f87171;
|
||||
background: #450a0a33;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sb-sentiment-row { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.sb-summary {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
border-left: 3px solid #1e3a5f;
|
||||
padding-left: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sb-sub {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #475569;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sb-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.sb-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 10px 12px;
|
||||
background: #111827;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #1e293b;
|
||||
}
|
||||
|
||||
.sb-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.sb-reason {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
|
||||
/* ── Sidebar sentiment pill ─────────────────────────────────────── */
|
||||
.sentiment-pill {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.sentiment-pill[data-sentiment="BULLISH"] { background: #14532d33; color: #4ade80; }
|
||||
.sentiment-pill[data-sentiment="BEARISH"] { background: #450a0a33; color: #f87171; }
|
||||
.sentiment-pill[data-sentiment="NEUTRAL"] { background: #1e293b; color: #94a3b8; }
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
export async function load({ fetch }) {
|
||||
const [callsRes, calRes] = await Promise.all([fetch('/api/calls'), fetch('/api/calls/calendar')]);
|
||||
|
||||
const { calls } = callsRes.ok ? await callsRes.json() : { calls: [] };
|
||||
const { events } = calRes.ok ? await calRes.json() : { events: [] };
|
||||
|
||||
return { calls, events };
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
<script>
|
||||
import { createCall, deleteCall } from '$lib/api.js';
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
// New call form state
|
||||
let showForm = $state(false);
|
||||
let saving = $state(false);
|
||||
let formError = $state(null);
|
||||
let form = $state({
|
||||
title: '',
|
||||
quarter: currentQuarter(),
|
||||
date: today(),
|
||||
thesis: '',
|
||||
tickers: '',
|
||||
});
|
||||
|
||||
function currentQuarter() {
|
||||
const d = new Date();
|
||||
const q = Math.ceil((d.getMonth() + 1) / 3);
|
||||
return `Q${q} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
formError = null;
|
||||
saving = true;
|
||||
try {
|
||||
await createCall({
|
||||
title: form.title.trim(),
|
||||
quarter: form.quarter.trim(),
|
||||
date: form.date,
|
||||
thesis: form.thesis.trim(),
|
||||
tickers: form.tickers.split(/[\s,]+/).map(t => t.trim().toUpperCase()).filter(Boolean),
|
||||
});
|
||||
showForm = false;
|
||||
form = { title: '', quarter: currentQuarter(), date: today(), thesis: '', tickers: '' };
|
||||
await invalidateAll(); // re-run load() to refresh the list
|
||||
} catch (e) {
|
||||
formError = e.message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!confirm('Delete this market call?')) return;
|
||||
await deleteCall(id);
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
const signalColor = s => {
|
||||
if (s?.includes('Strong')) return '#4ade80';
|
||||
if (s?.includes('Momentum')) return '#60a5fa';
|
||||
if (s?.includes('Neutral')) return '#94a3b8';
|
||||
if (s?.includes('Speculation')) return '#fb923c';
|
||||
return '#f87171';
|
||||
};
|
||||
|
||||
const eventIcon = type => ({ earnings: '📊', exdividend: '💰', dividend: '💵' })[type] ?? '📅';
|
||||
const eventColor = type => ({ earnings: '#60a5fa', exdividend: '#facc15', dividend: '#4ade80' })[type] ?? '#94a3b8';
|
||||
|
||||
const upcoming = $derived((data.events ?? []).filter(e => !e.isPast).slice(0, 20));
|
||||
const past = $derived((data.events ?? []).filter(e => e.isPast).slice(0, 10));
|
||||
|
||||
const fmtMoney = n => n == null ? null :
|
||||
n >= 1e9 ? '$' + (n / 1e9).toFixed(1) + 'B' :
|
||||
n >= 1e6 ? '$' + (n / 1e6).toFixed(1) + 'M' : '$' + n.toFixed(2);
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Market Calls</h1>
|
||||
<p class="subtitle">Quarterly investment theses tracked from the day you made the call</p>
|
||||
</div>
|
||||
<button class="btn-primary" onclick={() => showForm = !showForm}>
|
||||
{showForm ? 'Cancel' : '+ New Call'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── New Call Form ──────────────────────────────────────────────── -->
|
||||
{#if showForm}
|
||||
<section class="section form-section">
|
||||
<div class="section-header"><h2>New Market Call</h2></div>
|
||||
<form class="call-form" onsubmit={e => { e.preventDefault(); submit(); }}>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<span>Title</span>
|
||||
<input bind:value={form.title} placeholder="Q3 2025 — Rate pivot & tech rotation" required />
|
||||
</label>
|
||||
<label class="narrow">
|
||||
<span>Quarter</span>
|
||||
<input bind:value={form.quarter} placeholder="Q3 2025" required />
|
||||
</label>
|
||||
<label class="narrow">
|
||||
<span>Date</span>
|
||||
<input type="date" bind:value={form.date} required />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>Thesis</span>
|
||||
<textarea
|
||||
bind:value={form.thesis}
|
||||
rows="4"
|
||||
placeholder="Describe the macro thesis behind this call — why these tickers, why now..."
|
||||
required
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Tickers to track</span>
|
||||
<input
|
||||
bind:value={form.tickers}
|
||||
placeholder="AAPL, MSFT, TLT, GLD …"
|
||||
required
|
||||
/>
|
||||
<span class="hint">Comma or space separated. Current prices will be snapshot automatically.</span>
|
||||
</label>
|
||||
{#if formError}
|
||||
<div class="form-error">⚠ {formError}</div>
|
||||
{/if}
|
||||
<button type="submit" class="btn-primary" disabled={saving}>
|
||||
{#if saving}
|
||||
<Spinner size="sm" />
|
||||
<span>Snapshotting prices…</span>
|
||||
{:else}
|
||||
Save Call
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Calendar ──────────────────────────────────────────────────── -->
|
||||
{#if (data.events ?? []).length > 0}
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>📅 Upcoming Events</h2>
|
||||
<span class="count">{upcoming.length} upcoming</span>
|
||||
{#if past.length > 0}
|
||||
<span class="count" style="margin-left:4px">{past.length} recent</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="cal-grid">
|
||||
{#each upcoming as ev}
|
||||
<div class="cal-event">
|
||||
<div class="cal-date">{ev.date}</div>
|
||||
<div class="cal-content">
|
||||
<span class="cal-ticker">{ev.ticker}</span>
|
||||
<span class="cal-type" style="color:{eventColor(ev.type)}">
|
||||
{eventIcon(ev.type)} {ev.label}
|
||||
{#if ev.detail}<span class="cal-detail">· {ev.detail}</span>{/if}
|
||||
</span>
|
||||
{#if ev.epsEstimate != null}
|
||||
<span class="cal-est">EPS est. ${ev.epsEstimate?.toFixed(2)} · Rev est. {fmtMoney(ev.revEstimate)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if past.length > 0}
|
||||
<div class="cal-divider">— Past —</div>
|
||||
{#each past as ev}
|
||||
<div class="cal-event past">
|
||||
<div class="cal-date">{ev.date}</div>
|
||||
<div class="cal-content">
|
||||
<span class="cal-ticker">{ev.ticker}</span>
|
||||
<span class="cal-type past-type">
|
||||
{eventIcon(ev.type)} {ev.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Calls List ────────────────────────────────────────────────── -->
|
||||
{#if data.error}
|
||||
<div class="error-banner">⚠ {data.error}</div>
|
||||
{:else if data.calls.length === 0}
|
||||
<div class="empty">No market calls yet. Create your first one to start tracking.</div>
|
||||
{:else}
|
||||
{#each data.calls as call}
|
||||
<section class="section call-card">
|
||||
<div class="section-header">
|
||||
<div class="call-meta">
|
||||
<a href="/calls/{call.id}" class="call-title">{call.title}</a>
|
||||
<div class="call-badges">
|
||||
<span class="tag">{call.quarter}</span>
|
||||
<span class="date-badge">{call.date}</span>
|
||||
<span class="count">{call.tickers.length} tickers</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-delete" onclick={() => remove(call.id)}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="call-body">
|
||||
<p class="thesis">{call.thesis}</p>
|
||||
|
||||
{#if Object.keys(call.snapshot ?? {}).length}
|
||||
<div class="snapshot-grid">
|
||||
{#each call.tickers as ticker}
|
||||
{@const snap = call.snapshot[ticker]}
|
||||
{#if snap}
|
||||
<a href="/calls/{call.id}" class="snap-card">
|
||||
<div class="snap-ticker">{ticker}</div>
|
||||
<div class="snap-price">${snap.price?.toFixed(2) ?? '—'}</div>
|
||||
<div class="snap-signal" style="color:{signalColor(snap.signal)}">
|
||||
{snap.signal?.replace(/[✅⚡⚠️🔄❌]/u, '').trim() ?? '—'}
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<a href="/calls/{call.id}" class="view-link">View performance → </a>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { max-width: 1100px; padding-bottom: 60px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h1 { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-bottom: 4px; }
|
||||
.subtitle { font-size: 12px; color: #475569; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────── */
|
||||
button {
|
||||
padding: 9px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary { background: #2563eb; color: #fff; display: inline-flex; align-items: center; gap: 8px; }
|
||||
.btn-primary:hover:not(:disabled) { background: #1d4ed8; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-delete { background: transparent; color: #475569; padding: 4px 8px; font-size: 14px; }
|
||||
.btn-delete:hover { color: #f87171; }
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────────────── */
|
||||
.section {
|
||||
background: #0d1117;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
h2 { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 0; }
|
||||
|
||||
/* ── Form ────────────────────────────────────────────────────────── */
|
||||
.call-form { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.form-row { display: grid; grid-template-columns: 1fr auto auto; gap: 12px; align-items: start; }
|
||||
.form-row .narrow { min-width: 120px; }
|
||||
|
||||
label { display: flex; flex-direction: column; gap: 5px; }
|
||||
label > span { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
|
||||
|
||||
input, textarea {
|
||||
background: #1e293b;
|
||||
border: 1px solid #2d3f55;
|
||||
border-radius: 8px;
|
||||
color: #e2e8f0;
|
||||
padding: 9px 12px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
input:focus, textarea:focus { border-color: #3b82f6; }
|
||||
textarea { resize: vertical; }
|
||||
|
||||
.hint { font-size: 11px; color: #475569; }
|
||||
.form-error { color: #f87171; font-size: 12px; background: #450a0a33; padding: 8px 12px; border-radius: 6px; }
|
||||
|
||||
/* ── Call card ───────────────────────────────────────────────────── */
|
||||
.call-meta { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; }
|
||||
|
||||
.call-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
text-decoration: none;
|
||||
}
|
||||
.call-title:hover { color: #60a5fa; }
|
||||
|
||||
.call-badges { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: #1e293b;
|
||||
color: #64748b;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.date-badge { font-size: 11px; color: #475569; }
|
||||
.count { font-size: 10px; color: #334155; background: #1e293b; padding: 2px 7px; border-radius: 20px; }
|
||||
|
||||
.call-body { padding: 18px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.thesis {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
border-left: 3px solid #1e3a5f;
|
||||
padding-left: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Snapshot grid ───────────────────────────────────────────────── */
|
||||
.snapshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.snap-card {
|
||||
background: #111827;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.snap-card:hover { border-color: #334155; }
|
||||
|
||||
.snap-ticker { font-size: 12px; font-weight: 700; color: #f1f5f9; }
|
||||
.snap-price { font-size: 11px; color: #64748b; font-variant-numeric: tabular-nums; }
|
||||
.snap-signal { font-size: 10px; font-weight: 600; }
|
||||
|
||||
.view-link { font-size: 12px; color: #60a5fa; text-decoration: none; }
|
||||
.view-link:hover { text-decoration: underline; }
|
||||
|
||||
.empty { color: #475569; font-size: 13px; padding: 40px 0; text-align: center; }
|
||||
.error-banner { background: #450a0a55; border: 1px solid #7f1d1d; border-radius: 8px; color: #f87171; padding: 10px 14px; margin-bottom: 16px; font-size: 13px; }
|
||||
|
||||
/* ── Calendar ───────────────────────────────────────────────────── */
|
||||
.cal-grid {
|
||||
padding: 8px 18px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cal-event {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
padding: 8px 6px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.cal-event:hover { background: #111827; }
|
||||
.cal-event.past { opacity: 0.45; }
|
||||
|
||||
.cal-date {
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #475569;
|
||||
padding-top: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cal-content { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.cal-ticker { font-size: 12px; font-weight: 700; color: #f1f5f9; }
|
||||
|
||||
.cal-type { font-size: 11px; font-weight: 600; }
|
||||
.cal-detail { font-weight: 400; color: #64748b; }
|
||||
.past-type { color: #475569 !important; }
|
||||
|
||||
.cal-est { font-size: 10px; color: #475569; }
|
||||
|
||||
.cal-divider {
|
||||
font-size: 10px;
|
||||
color: #334155;
|
||||
text-align: center;
|
||||
padding: 8px 0 4px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
export async function load({ fetch, params }) {
|
||||
const res = await fetch(`/api/calls/${params.id}`);
|
||||
if (!res.ok) return { error: await res.text() };
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<script>
|
||||
let { data } = $props();
|
||||
|
||||
const fmt = v => v != null ? '$' + v.toFixed(2) : '—';
|
||||
|
||||
const pctChange = (then, now) => {
|
||||
if (then == null || now == null || then === 0) return null;
|
||||
return ((now - then) / then) * 100;
|
||||
};
|
||||
|
||||
const pctClass = v => v == null ? '' : v >= 0 ? 'pos' : 'neg';
|
||||
const fmtPct = v => v == null ? '—' : (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
|
||||
|
||||
const verdictColor = label => {
|
||||
if (!label) return '#64748b';
|
||||
if (label.startsWith('🟢')) return '#4ade80';
|
||||
if (label.startsWith('🟡')) return '#facc15';
|
||||
return '#f87171';
|
||||
};
|
||||
|
||||
const daysSince = dateStr => {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
return Math.floor(diff / 86400000);
|
||||
};
|
||||
|
||||
const tickers = $derived(data?.tickers ?? []);
|
||||
const snapshot = $derived(data?.snapshot ?? {});
|
||||
const current = $derived(data?.current ?? {});
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
{#if data?.error}
|
||||
<div class="error-banner">⚠ {data.error}</div>
|
||||
|
||||
{:else if data}
|
||||
<div class="breadcrumb"><a href="/calls">← Market Calls</a></div>
|
||||
|
||||
<div class="call-hero">
|
||||
<div class="hero-meta">
|
||||
<span class="tag">{data.quarter}</span>
|
||||
<span class="date">{data.date}</span>
|
||||
<span class="days">({daysSince(data.date)} days ago)</span>
|
||||
</div>
|
||||
<h1>{data.title}</h1>
|
||||
<p class="thesis">{data.thesis}</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Performance Table ─────────────────────────────────────────── -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Performance since call date</h2>
|
||||
<span class="count">{tickers.length} tickers</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticker</th>
|
||||
<th>Call Price</th>
|
||||
<th>Now</th>
|
||||
<th>Return</th>
|
||||
<th>Call Signal</th>
|
||||
<th>Now Signal</th>
|
||||
<th>Call Verdict</th>
|
||||
<th>Now Verdict</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tickers as ticker}
|
||||
{@const snap = snapshot[ticker]}
|
||||
{@const cur = current[ticker]}
|
||||
{@const ret = pctChange(snap?.price, cur?.price)}
|
||||
<tr class:best={ret != null && ret >= 10} class:worst={ret != null && ret <= -10}>
|
||||
<td class="ticker">{ticker}</td>
|
||||
<td class="num">{fmt(snap?.price)}</td>
|
||||
<td class="num">{fmt(cur?.price)}</td>
|
||||
<td class="num {pctClass(ret)}">{fmtPct(ret)}</td>
|
||||
<td>
|
||||
{#if snap?.signal}
|
||||
<span class="signal-text">{snap.signal}</span>
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if cur?.signal}
|
||||
<span class="signal-text">{cur.signal}</span>
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if snap?.inflatedVerdict}
|
||||
<span class="verdict-pill" style="color:{verdictColor(snap.inflatedVerdict)}">
|
||||
{snap.inflatedVerdict.replace(/[🟢🟡🔴]/u, '').trim()}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if cur?.inflatedVerdict}
|
||||
<span class="verdict-pill" style="color:{verdictColor(cur.inflatedVerdict)}">
|
||||
{cur.inflatedVerdict.replace(/[🟢🟡🔴]/u, '').trim()}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="muted">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { max-width: 1100px; padding-bottom: 60px; }
|
||||
|
||||
.breadcrumb { margin-bottom: 20px; }
|
||||
.breadcrumb a { font-size: 12px; color: #475569; text-decoration: none; }
|
||||
.breadcrumb a:hover { color: #94a3b8; }
|
||||
|
||||
.call-hero { margin-bottom: 24px; }
|
||||
|
||||
.hero-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.tag { background: #1e293b; color: #64748b; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
|
||||
.date { font-size: 12px; color: #475569; }
|
||||
.days { font-size: 12px; color: #334155; }
|
||||
|
||||
h1 { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-bottom: 10px; }
|
||||
|
||||
.thesis {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.7;
|
||||
border-left: 3px solid #1e3a5f;
|
||||
padding-left: 14px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────────────── */
|
||||
.section { background: #0d1117; border: 1px solid #1e293b; border-radius: 10px; overflow: hidden; }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
h2 { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 0; }
|
||||
.count { font-size: 10px; color: #334155; background: #1e293b; padding: 2px 7px; border-radius: 20px; }
|
||||
|
||||
/* ── Table ───────────────────────────────────────────────────────── */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: max-content; min-width: 100%; border-collapse: collapse; }
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
padding: 8px 14px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
white-space: nowrap;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
tbody tr { border-bottom: 1px solid #161f2e; }
|
||||
tbody tr:hover { background: #131c2b; }
|
||||
tbody tr.best td { background: #14532d11; }
|
||||
tbody tr.worst td { background: #450a0a11; }
|
||||
|
||||
tbody td { padding: 10px 14px; vertical-align: middle; white-space: nowrap; font-size: 13px; }
|
||||
|
||||
.ticker { font-weight: 700; color: #f1f5f9; }
|
||||
.num { font-variant-numeric: tabular-nums; color: #64748b; }
|
||||
.pos { color: #4ade80; font-weight: 600; }
|
||||
.neg { color: #f87171; font-weight: 600; }
|
||||
.muted { color: #334155; }
|
||||
|
||||
.signal-text { font-size: 12px; color: #94a3b8; }
|
||||
|
||||
.verdict-pill {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.error-banner { background: #450a0a55; border: 1px solid #7f1d1d; border-radius: 8px; color: #f87171; padding: 10px 14px; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
// Disable SSR — data is fetched client-side in the component so navigation
|
||||
// is instant instead of blocking until all Yahoo Finance calls resolve.
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
<script>
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import MarketContext from '$lib/MarketContext.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import { addHolding, removeHolding } from '$lib/api.js';
|
||||
|
||||
let { data: _data } = $props(); // unused — we load client-side
|
||||
|
||||
let data = $state(null);
|
||||
let loading = $state(true);
|
||||
let refreshing = $state(false); // background refresh — keeps page visible
|
||||
let loadError = $state(null);
|
||||
|
||||
// ── Add holding form (new holdings only) ────────────────────────────────────
|
||||
let formOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let formError = $state(null);
|
||||
let form = $state({ ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' });
|
||||
|
||||
// ── Inline row editing ───────────────────────────────────────────────────────
|
||||
let inlineEdit = $state(null); // { ticker, shares, costBasis, type, source } or null
|
||||
let inlineSaving = $state(false);
|
||||
|
||||
function startInlineEdit(a) {
|
||||
inlineEdit = {
|
||||
ticker: a.ticker,
|
||||
shares: String(a.shares),
|
||||
costBasis: String(a.costBasis ?? 0),
|
||||
type: a.type ?? 'stock',
|
||||
source: a.source ?? 'Robinhood',
|
||||
};
|
||||
}
|
||||
|
||||
async function saveInlineEdit() {
|
||||
if (!inlineEdit) return;
|
||||
inlineSaving = true;
|
||||
try {
|
||||
const updated = {
|
||||
ticker: inlineEdit.ticker,
|
||||
shares: parseFloat(inlineEdit.shares),
|
||||
costBasis: parseFloat(inlineEdit.costBasis) || 0,
|
||||
type: inlineEdit.type,
|
||||
source: inlineEdit.source,
|
||||
};
|
||||
await addHolding(updated);
|
||||
|
||||
// Optimistic update — patch the row immediately, don't wait for Yahoo
|
||||
if (data?.advice) {
|
||||
data = {
|
||||
...data,
|
||||
advice: data.advice.map(a =>
|
||||
a.ticker === updated.ticker
|
||||
? { ...a, shares: updated.shares, costBasis: updated.costBasis, type: updated.type, source: updated.source,
|
||||
marketValue: updated.shares * (parseFloat(a.currentPrice) || 0),
|
||||
gainLossPct: a.currentPrice ? (((parseFloat(a.currentPrice) - updated.costBasis) / updated.costBasis) * 100).toFixed(1) : null }
|
||||
: a
|
||||
),
|
||||
};
|
||||
}
|
||||
inlineEdit = null;
|
||||
fetchPortfolioData(false); // background: update prices + signals
|
||||
} catch (e) {
|
||||
loadError = e.message;
|
||||
} finally {
|
||||
inlineSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
form = { ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' };
|
||||
formOpen = !formOpen;
|
||||
formError = null;
|
||||
inlineEdit = null;
|
||||
}
|
||||
|
||||
async function submitHolding() {
|
||||
formError = null;
|
||||
const ticker = form.ticker.trim().toUpperCase();
|
||||
const shares = parseFloat(form.shares);
|
||||
const costBasis = parseFloat(form.costBasis) || 0;
|
||||
if (!ticker) { formError = 'Ticker is required.'; return; }
|
||||
if (!shares || shares <= 0) { formError = 'Shares must be greater than 0.'; return; }
|
||||
saving = true;
|
||||
try {
|
||||
await addHolding({ ticker, shares, costBasis, type: form.type, source: form.source });
|
||||
|
||||
// Optimistic update — add placeholder row immediately
|
||||
const existing = data?.advice?.find(a => a.ticker === ticker);
|
||||
if (data?.advice && !existing) {
|
||||
data = {
|
||||
...data,
|
||||
advice: [...data.advice, {
|
||||
ticker, shares, costBasis, type: form.type, source: form.source,
|
||||
currentPrice: null, marketValue: null, gainLossPct: null,
|
||||
signal: null, advice: '⏳ Fetching…', reason: 'Screener data loading in background.',
|
||||
}],
|
||||
};
|
||||
}
|
||||
form = { ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' };
|
||||
formOpen = false;
|
||||
fetchPortfolioData(false); // background: get real price + signal
|
||||
} catch (e) {
|
||||
formError = e.message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHolding(ticker) {
|
||||
if (!confirm(`Remove ${ticker} from your portfolio?`)) return;
|
||||
// Optimistic remove — drop the row immediately
|
||||
if (data?.advice) {
|
||||
data = { ...data, advice: data.advice.filter(a => a.ticker !== ticker) };
|
||||
}
|
||||
try {
|
||||
await removeHolding(ticker);
|
||||
fetchPortfolioData(false); // background: recalculate totals
|
||||
} catch (e) {
|
||||
loadError = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchPortfolioData(showFullSpinner = false) {
|
||||
if (showFullSpinner) loading = true;
|
||||
else refreshing = true;
|
||||
loadError = null;
|
||||
fetch('/api/finance/portfolio')
|
||||
.then(res => res.ok ? res.json() : res.text().then(t => { throw new Error(t); }))
|
||||
.then(json => { data = json; })
|
||||
.catch(e => { loadError = e.message; })
|
||||
.finally(() => { loading = false; refreshing = false; });
|
||||
}
|
||||
|
||||
let _booted = false;
|
||||
$effect(() => {
|
||||
if (_booted) return;
|
||||
_booted = true;
|
||||
fetchPortfolioData(true); // initial load — show full spinner
|
||||
});
|
||||
|
||||
// ── Table sorting ────────────────────────────────────────────────────────────
|
||||
let sortCol = $state('ticker');
|
||||
let sortDir = $state(1); // 1 = asc, -1 = desc
|
||||
|
||||
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
|
||||
|
||||
function toggleSort(col) {
|
||||
if (sortCol === col) sortDir = sortDir === 1 ? -1 : 1;
|
||||
else { sortCol = col; sortDir = 1; }
|
||||
}
|
||||
|
||||
const sortedAdvice = $derived.by(() => {
|
||||
if (!data?.advice) return [];
|
||||
return [...data.advice].sort((a, b) => {
|
||||
let av, bv;
|
||||
switch (sortCol) {
|
||||
case 'ticker': av = a.ticker; bv = b.ticker; break;
|
||||
case 'type': av = a.type ?? ''; bv = b.type ?? ''; break;
|
||||
case 'shares': av = a.shares ?? 0; bv = b.shares ?? 0; break;
|
||||
case 'cost': av = a.costBasis ?? 0; bv = b.costBasis ?? 0; break;
|
||||
case 'current': av = parseFloat(a.currentPrice) || 0; bv = parseFloat(b.currentPrice) || 0; break;
|
||||
case 'value': av = parseFloat(a.marketValue) || 0; bv = parseFloat(b.marketValue) || 0; break;
|
||||
case 'gl': av = parseFloat(a.gainLossPct) || 0; bv = parseFloat(b.gainLossPct) || 0; break;
|
||||
case 'signal': av = sigOrd(a.signal); bv = sigOrd(b.signal); break;
|
||||
default: return 0;
|
||||
}
|
||||
if (av < bv) return -sortDir;
|
||||
if (av > bv) return sortDir;
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
const sortIcon = (col) => sortCol !== col ? '⇅' : sortDir === 1 ? '↑' : '↓';
|
||||
|
||||
const fmt = (n) => n != null
|
||||
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n)
|
||||
: '—';
|
||||
|
||||
const fmtShort = (n) => n != null
|
||||
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)
|
||||
: '—';
|
||||
|
||||
const glClass = (pct) => parseFloat(pct) >= 0 ? 'green' : 'red';
|
||||
|
||||
const advClass = (a) => {
|
||||
if (a?.includes('🟢')) return 'green';
|
||||
if (a?.includes('🟡')) return 'yellow';
|
||||
if (a?.includes('🟠')) return 'orange';
|
||||
if (a?.includes('🔴')) return 'red';
|
||||
return 'gray';
|
||||
};
|
||||
|
||||
const totalValue = $derived(data?.advice?.reduce((s, a) => s + (parseFloat(a.marketValue) || 0), 0) ?? 0);
|
||||
const totalCost = $derived(data?.advice?.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0) ?? 0);
|
||||
const totalGL = $derived(totalValue - totalCost);
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
{#if loading}
|
||||
<div class="loading-area">
|
||||
<Spinner size="lg" label="Loading portfolio…" />
|
||||
</div>
|
||||
|
||||
{:else if loadError}
|
||||
<div class="error">{loadError}</div>
|
||||
|
||||
{:else if data?.advice}
|
||||
<!-- ── Toolbar ──────────────────────────────────────────────── -->
|
||||
<div class="toolbar">
|
||||
<button class="btn-add" onclick={openAdd}>
|
||||
{formOpen ? '✕ Cancel' : '+ Add Holding'}
|
||||
</button>
|
||||
{#if refreshing}
|
||||
<span class="refreshing-hint">Updating prices…</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Add Holding Form ─────────────────────────────────────── -->
|
||||
{#if formOpen}
|
||||
<div class="add-form">
|
||||
<div class="form-title">Add Holding</div>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label>Ticker</label>
|
||||
<input bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Shares</label>
|
||||
<input bind:value={form.shares} placeholder="10" type="number" min="0" step="any" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Cost Basis / share</label>
|
||||
<input bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Type</label>
|
||||
<select bind:value={form.type}>
|
||||
<option value="stock">Stock</option>
|
||||
<option value="etf">ETF</option>
|
||||
<option value="bond">Bond</option>
|
||||
<option value="crypto">Crypto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Source</label>
|
||||
<input bind:value={form.source} placeholder="Robinhood" />
|
||||
</div>
|
||||
<button class="btn-save" onclick={submitHolding} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{#if formError}
|
||||
<div class="form-error">⚠ {formError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.marketContext}
|
||||
<MarketContext ctx={data.marketContext} collapsible={true} />
|
||||
{/if}
|
||||
|
||||
<!-- P&L Summary -->
|
||||
<div class="summary-grid">
|
||||
<div class="scard">
|
||||
<div class="slabel-row">
|
||||
<span class="slabel">Total Value</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Current market value of all holdings. Calculated as shares × live price from Yahoo Finance for each position.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="svalue">{fmtShort(totalValue)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel-row">
|
||||
<span class="slabel">Total Cost</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Total amount invested — sum of (cost basis per share × shares) across all positions. Based on the cost basis you entered.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="svalue">{fmtShort(totalCost)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel-row">
|
||||
<span class="slabel">Total G/L</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Total unrealised gain or loss — Total Value minus Total Cost. Green means you're up overall; red means you're down.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="svalue {totalGL >= 0 ? 'green' : 'red'}">{fmtShort(totalGL)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Holdings -->
|
||||
<section class="card-section">
|
||||
<h2>Holdings — Hold / Sell / Add Advice</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick={() => toggleSort('ticker')}>Ticker {sortIcon('ticker')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('type')}>Type {sortIcon('type')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('shares')}>Shares {sortIcon('shares')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('cost')}>Cost {sortIcon('cost')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('current')}>Current {sortIcon('current')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('value')}>Value {sortIcon('value')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('gl')}>G/L {sortIcon('gl')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('signal')}>Signal {sortIcon('signal')}</th>
|
||||
<th>Advice</th><th>Reason</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedAdvice as a}
|
||||
{@const isEditing = inlineEdit?.ticker === a.ticker}
|
||||
<tr class:editing={isEditing}>
|
||||
<td class="ticker">{a.ticker}</td>
|
||||
<td>
|
||||
{#if isEditing}
|
||||
<select class="inline-select" bind:value={inlineEdit.type}>
|
||||
<option value="stock">stock</option>
|
||||
<option value="etf">etf</option>
|
||||
<option value="bond">bond</option>
|
||||
<option value="crypto">crypto</option>
|
||||
</select>
|
||||
{:else}
|
||||
<span class="tag">{a.type}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">
|
||||
{#if isEditing}
|
||||
<input class="inline-input" bind:value={inlineEdit.shares} type="number" min="0" step="any" />
|
||||
{:else}
|
||||
{a.shares}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">
|
||||
{#if isEditing}
|
||||
<input class="inline-input" bind:value={inlineEdit.costBasis} type="number" min="0" step="any" />
|
||||
{:else}
|
||||
{fmt(a.costBasis)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">{fmt(parseFloat(a.currentPrice))}</td>
|
||||
<td class="num">{fmt(parseFloat(a.marketValue))}</td>
|
||||
<td class="num {glClass(a.gainLossPct)}">{a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
|
||||
<td>{#if a.signal}<SignalBadge signal={a.signal} />{:else}<span class="gray">—</span>{/if}</td>
|
||||
<td class={advClass(a.advice)}>{a.advice}</td>
|
||||
<td class="reason">{a.reason}</td>
|
||||
<td class="row-actions">
|
||||
{#if isEditing}
|
||||
<button class="btn-save-inline" onclick={saveInlineEdit} disabled={inlineSaving}>
|
||||
{inlineSaving ? '…' : '✓'}
|
||||
</button>
|
||||
<button class="btn-cancel-inline" onclick={() => inlineEdit = null}>✕</button>
|
||||
{:else}
|
||||
<button class="btn-edit" onclick={() => startInlineEdit(a)} title="Edit">✎</button>
|
||||
<button class="btn-delete" onclick={() => deleteHolding(a.ticker)} title="Remove">✕</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Personal Finance (SimpleFIN) -->
|
||||
{#if data.personalFinance}
|
||||
{@const pf = data.personalFinance}
|
||||
<div class="summary-grid">
|
||||
<div class="scard">
|
||||
<div class="slabel">Net Worth</div>
|
||||
<div class="svalue {pf.netWorth >= 0 ? 'green' : 'red'}">{fmtShort(pf.netWorth)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Total Assets</div>
|
||||
<div class="svalue">{fmtShort(pf.totalAssets)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Liabilities</div>
|
||||
<div class="svalue red">{fmtShort(pf.totalLiabilities)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Cash ({pf.cashPct}%)</div>
|
||||
<div class="svalue">{fmtShort(pf.totalCash)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Investments ({pf.investPct}%)</div>
|
||||
<div class="svalue">{fmtShort(pf.totalInvestments)}</div>
|
||||
</div>
|
||||
{#if pf.savingsRate != null}
|
||||
<div class="scard">
|
||||
<div class="slabel">Savings Rate</div>
|
||||
<div class="svalue {parseFloat(pf.savingsRate) >= 20 ? 'green' : 'yellow'}">{pf.savingsRate}%</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="scard">
|
||||
<div class="slabel">Monthly Income</div>
|
||||
<div class="svalue">{fmtShort(pf.totalIncome)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Monthly Spend</div>
|
||||
<div class="svalue">{fmtShort(pf.totalSpend)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<section class="card-section">
|
||||
<h2>Accounts</h2>
|
||||
<table>
|
||||
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th class="right">Balance</th></tr></thead>
|
||||
<tbody>
|
||||
{#each pf.accounts as a}
|
||||
<tr>
|
||||
<td class="ticker">{a.name}</td>
|
||||
<td><span class="tag">{a.type}</span></td>
|
||||
<td class="gray">{a.org}</td>
|
||||
<td class="num right {a.balance >= 0 ? 'green' : 'red'}">{fmt(a.balance)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card-section">
|
||||
<h2>Spending — Last 30 Days</h2>
|
||||
<table>
|
||||
<thead><tr><th>Category</th><th class="right">Amount</th><th class="right">%</th><th>Share</th></tr></thead>
|
||||
<tbody>
|
||||
{#each pf.categoryBreakdown.slice(0, 10) as c}
|
||||
<tr>
|
||||
<td>{c.category}</td>
|
||||
<td class="num right">{fmt(c.amount)}</td>
|
||||
<td class="num right gray">{c.pct}%</td>
|
||||
<td style="width:100px">
|
||||
<div class="bar-bg">
|
||||
<div class="bar-fill" style="width:{Math.min(c.pct,100)}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { max-width: 1400px; }
|
||||
|
||||
/* ── Toolbar ─────────────────────────────────────────────────────── */
|
||||
.toolbar { margin-bottom: 12px; }
|
||||
|
||||
.btn-add {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 9px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-add:hover { background: #1d4ed8; }
|
||||
|
||||
.refreshing-hint {
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Add holding form ────────────────────────────────────────────── */
|
||||
.add-form {
|
||||
background: #111827;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.field input::placeholder { color: #334155; }
|
||||
|
||||
.field input {
|
||||
background: #1e293b;
|
||||
border: 1px solid #2d3f55;
|
||||
border-radius: 6px;
|
||||
color: #e2e8f0;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
min-width: 100px;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.field input:focus { border-color: #3b82f6; }
|
||||
|
||||
.field select {
|
||||
background: #1e293b url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2364748b' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 10px center;
|
||||
border: 1px solid #2d3f55;
|
||||
border-radius: 6px;
|
||||
color: #e2e8f0;
|
||||
padding: 8px 32px 8px 12px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
min-width: 100px;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.field select:focus { border-color: #3b82f6; }
|
||||
|
||||
.btn-save {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) { background: #1d4ed8; }
|
||||
.btn-save:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.form-error {
|
||||
color: #f87171;
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* ── Delete button ───────────────────────────────────────────────── */
|
||||
.form-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #475569;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field input.readonly {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-cancel-edit {
|
||||
background: transparent;
|
||||
border: 1px solid #2d3f55;
|
||||
color: #64748b;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.btn-cancel-edit:hover { color: #94a3b8; }
|
||||
|
||||
tr.editing { background: #0d1e30; }
|
||||
|
||||
.inline-input {
|
||||
background: #1e293b;
|
||||
border: 1px solid #2d3f55;
|
||||
border-radius: 4px;
|
||||
color: #e2e8f0;
|
||||
padding: 3px 6px;
|
||||
font-size: 12px;
|
||||
width: 80px;
|
||||
outline: none;
|
||||
}
|
||||
.inline-input:focus { border-color: #3b82f6; }
|
||||
|
||||
.inline-select {
|
||||
background: #1e293b;
|
||||
border: 1px solid #2d3f55;
|
||||
border-radius: 4px;
|
||||
color: #e2e8f0;
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn-save-inline {
|
||||
background: #14532d55;
|
||||
border: none;
|
||||
color: #4ade80;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.btn-save-inline:hover:not(:disabled) { background: #14532d99; }
|
||||
.btn-save-inline:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn-cancel-inline {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-cancel-inline:hover { color: #94a3b8; }
|
||||
|
||||
.row-actions { display: flex; gap: 4px; align-items: center; }
|
||||
|
||||
.btn-edit {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-edit:hover { color: #60a5fa; background: #0f2240; }
|
||||
|
||||
.btn-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-delete:hover { color: #f87171; background: #450a0a33; }
|
||||
|
||||
.loading-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 100px 0;
|
||||
}
|
||||
|
||||
.error { color: #f87171; background: #450a0a33; border-radius: 8px; padding: 10px 14px; }
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.scard { background: #1e293b; border-radius: 8px; padding: 12px 14px; }
|
||||
|
||||
.slabel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.slabel { font-size: 10px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.svalue { font-size: 18px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
|
||||
|
||||
/* ── Summary card tooltips ───────────────────────────────────────── */
|
||||
.stip-wrap { position: relative; display: inline-flex; flex-shrink: 0; }
|
||||
|
||||
.stip-anchor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: #0f1117;
|
||||
border: 1px solid #334155;
|
||||
color: #475569;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.stip-box {
|
||||
display: none;
|
||||
position: fixed;
|
||||
width: 220px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
white-space: normal;
|
||||
/* anchor via JS-free trick: use absolute + translate to float above icon */
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.stip-box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: #334155;
|
||||
}
|
||||
|
||||
.stip-wrap:hover .stip-box { display: block; }
|
||||
|
||||
.card-section {
|
||||
background: #111827;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #64748b;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #475569;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody tr { border-bottom: 1px solid #1a2233; }
|
||||
tbody tr:hover { background: #1e293b55; }
|
||||
tbody td { padding: 9px 10px; vertical-align: middle; white-space: nowrap; }
|
||||
|
||||
th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
th.sortable:hover { color: #94a3b8; }
|
||||
|
||||
.ticker { font-weight: 700; font-size: 14px; color: #f1f5f9; }
|
||||
.num { font-variant-numeric: tabular-nums; color: #94a3b8; }
|
||||
.tag { background: #1e293b; color: #94a3b8; padding: 2px 8px; border-radius: 4px; font-size: 11px; }
|
||||
.reason { color: #94a3b8; font-size: 11px; white-space: normal; max-width: 260px; }
|
||||
.right { text-align: right; }
|
||||
|
||||
.green { color: #4ade80; font-weight: 600; }
|
||||
.yellow { color: #facc15; font-weight: 600; }
|
||||
.orange { color: #fb923c; font-weight: 600; }
|
||||
.red { color: #f87171; font-weight: 600; }
|
||||
.gray { color: #64748b; }
|
||||
|
||||
.bar-bg { background: #1e293b; border-radius: 4px; height: 6px; }
|
||||
.bar-fill { background: #3b82f6; border-radius: 4px; height: 6px; }
|
||||
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
// Curated watchlist of well-established, low-cost ETFs and investment-grade bond funds.
|
||||
// Screened for Strong Buy signal under both Market-Adjusted and Fundamental lenses.
|
||||
const SAFE_WATCHLIST = [
|
||||
// ── Broad Market ETFs
|
||||
'VOO', // S&P 500 — Vanguard (0.03%)
|
||||
'IVV', // S&P 500 — iShares (0.03%)
|
||||
'VTI', // Total US Market — Vanguard (0.03%)
|
||||
'SPY', // S&P 500 — SPDR (0.0945%)
|
||||
'QQQ', // Nasdaq-100 — Invesco (0.20%)
|
||||
'VEA', // Developed Markets ex-US — Vanguard
|
||||
'VWO', // Emerging Markets — Vanguard
|
||||
|
||||
// ── Dividend / Quality ETFs
|
||||
'VIG', // Dividend Appreciation — Vanguard
|
||||
'SCHD', // Dividend — Schwab (0.06%)
|
||||
'DGRO', // Dividend Growth — iShares
|
||||
'VYM', // High Dividend Yield — Vanguard
|
||||
|
||||
// ── Sector ETFs (established)
|
||||
'XLK', // Technology
|
||||
'XLV', // Healthcare
|
||||
'XLF', // Financials
|
||||
'XLE', // Energy
|
||||
|
||||
// ── Investment-Grade Bond ETFs
|
||||
'BND', // Total Bond Market — Vanguard
|
||||
'AGG', // US Aggregate Bond — iShares
|
||||
'LQD', // IG Corporate Bond — iShares
|
||||
'VCIT', // Intermediate Corp Bond — Vanguard
|
||||
|
||||
// ── Treasury ETFs
|
||||
'TLT', // 20+ Year Treasury — iShares
|
||||
'IEF', // 7-10 Year Treasury — iShares
|
||||
'SHY', // 1-3 Year Treasury — iShares
|
||||
'GOVT', // US Treasury — iShares
|
||||
'SGOV', // 0-3 Month T-Bill — iShares
|
||||
|
||||
// ── Municipal / TIPS
|
||||
'MUB', // Muni Bond — iShares
|
||||
'TIP', // TIPS — iShares
|
||||
];
|
||||
|
||||
export async function load({ fetch }) {
|
||||
const res = await fetch('/api/screen', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tickers: SAFE_WATCHLIST }),
|
||||
});
|
||||
|
||||
if (!res.ok)
|
||||
return { ETF: [], BOND: [], ERROR: [], marketContext: null, error: await res.text() };
|
||||
|
||||
const data = await res.json();
|
||||
return {
|
||||
ETF: data.ETF ?? [],
|
||||
BOND: data.BOND ?? [],
|
||||
ERROR: data.ERROR ?? [],
|
||||
marketContext: data.marketContext ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
<script>
|
||||
import MarketContext from '$lib/MarketContext.svelte';
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const SIGNAL_STRONG = '✅ Strong Buy';
|
||||
|
||||
// Filter to only Strong Buy in both modes — the safest picks
|
||||
const strongEtfs = $derived((data.ETF ?? []).filter(r => r.signal === SIGNAL_STRONG));
|
||||
const strongBonds = $derived((data.BOND ?? []).filter(r => r.signal === SIGNAL_STRONG));
|
||||
|
||||
// All other non-error results — "watch" tier (pass one mode but not both)
|
||||
const watchEtfs = $derived((data.ETF ?? []).filter(r => r.signal !== SIGNAL_STRONG));
|
||||
const watchBonds = $derived((data.BOND ?? []).filter(r => r.signal !== SIGNAL_STRONG));
|
||||
|
||||
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
|
||||
const sorted = arr => [...arr].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal));
|
||||
|
||||
const vClass = label =>
|
||||
label?.startsWith('🟢') ? 'green' : label?.startsWith('🟡') ? 'yellow' : 'red';
|
||||
|
||||
const verdictShort = label => {
|
||||
if (!label) return '—';
|
||||
if (label.includes('Efficient')) return 'Efficient';
|
||||
if (label.includes('Attractive')) return 'Attractive';
|
||||
if (label.includes('Neutral')) return 'Hold';
|
||||
if (label.includes('REJECT')) return 'Reject';
|
||||
if (label.includes('Avoid')) return 'Avoid';
|
||||
return label.replace(/[🟢🟡🔴]/u, '').trim();
|
||||
};
|
||||
|
||||
const totalScreened = $derived((data.ETF?.length ?? 0) + (data.BOND?.length ?? 0));
|
||||
const totalStrong = $derived(strongEtfs.length + strongBonds.length);
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>🛡 Safe Buys</h1>
|
||||
<p class="subtitle">
|
||||
Low-cost ETFs and investment-grade bonds passing <strong>both</strong> Market-Adjusted and Fundamental gates.
|
||||
{totalStrong} of {totalScreened} screened assets qualify.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if data.error}
|
||||
<div class="error-banner">⚠ {data.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if data.marketContext}
|
||||
<MarketContext ctx={data.marketContext} />
|
||||
{/if}
|
||||
|
||||
<!-- ── Strong Buy ─────────────────────────────────────────────────── -->
|
||||
{#if strongEtfs.length || strongBonds.length}
|
||||
<div class="strong-header">
|
||||
<span class="strong-badge">✅ Strong Buy</span>
|
||||
<span class="strong-sub">Pass both Market-Adjusted and Fundamental gates</span>
|
||||
</div>
|
||||
|
||||
{#if strongEtfs.length}
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>ETFs</h2>
|
||||
<span class="count">{strongEtfs.length}</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-ticker">Ticker</th>
|
||||
<th>Price</th>
|
||||
<th>Mkt-Adj</th>
|
||||
<th>Graham</th>
|
||||
<th>Expense</th>
|
||||
<th>Yield</th>
|
||||
<th>AUM</th>
|
||||
<th>5Y Ret</th>
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted(strongEtfs) as r}
|
||||
{@const m = r.asset.displayMetrics ?? {}}
|
||||
<tr>
|
||||
<td class="ticker">{r.asset.ticker}</td>
|
||||
<td class="num">{m.Price ?? '—'}</td>
|
||||
<td><span class="vpill {vClass(r.inflated.label)}">{verdictShort(r.inflated.label)}</span></td>
|
||||
<td><span class="vpill {vClass(r.fundamental.label)}">{verdictShort(r.fundamental.label)}</span></td>
|
||||
<td class="num">{m['Exp Ratio%'] ?? '—'}</td>
|
||||
<td class="num">{m['Yield%'] ?? '—'}</td>
|
||||
<td class="num">{m['AUM'] ?? '—'}</td>
|
||||
<td class="num">{m['5Y Return%'] ?? '—'}</td>
|
||||
<td class="score">{r.inflated.scoreSummary}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if strongBonds.length}
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Bond ETFs</h2>
|
||||
<span class="count">{strongBonds.length}</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-ticker">Ticker</th>
|
||||
<th>Price</th>
|
||||
<th>Mkt-Adj</th>
|
||||
<th>Graham</th>
|
||||
<th>YTM</th>
|
||||
<th>Duration</th>
|
||||
<th>Rating</th>
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted(strongBonds) as r}
|
||||
{@const m = r.asset.displayMetrics ?? {}}
|
||||
<tr>
|
||||
<td class="ticker">{r.asset.ticker}</td>
|
||||
<td class="num">{m.Price ?? '—'}</td>
|
||||
<td><span class="vpill {vClass(r.inflated.label)}">{verdictShort(r.inflated.label)}</span></td>
|
||||
<td><span class="vpill {vClass(r.fundamental.label)}">{verdictShort(r.fundamental.label)}</span></td>
|
||||
<td class="num">{m['YTM%'] ?? '—'}</td>
|
||||
<td class="num">{m['Duration'] ?? '—'}</td>
|
||||
<td class="num">{m['Rating'] ?? '—'}</td>
|
||||
<td class="score">{r.inflated.scoreSummary}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="empty-strong">
|
||||
No assets currently pass both gates — market conditions may be elevated.
|
||||
Check the Watch List below for assets passing at least one mode.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Watch List ─────────────────────────────────────────────────── -->
|
||||
{#if watchEtfs.length || watchBonds.length}
|
||||
<div class="watch-header">
|
||||
<span class="watch-label">👀 Watch List</span>
|
||||
<span class="watch-sub">Pass one gate — monitor for entry</span>
|
||||
</div>
|
||||
|
||||
{#if watchEtfs.length}
|
||||
<section class="section watch-section">
|
||||
<div class="section-header">
|
||||
<h2>ETFs</h2>
|
||||
<span class="count">{watchEtfs.length}</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-ticker">Ticker</th>
|
||||
<th>Price</th>
|
||||
<th>Signal</th>
|
||||
<th>Mkt-Adj</th>
|
||||
<th>Graham</th>
|
||||
<th>Expense</th>
|
||||
<th>Yield</th>
|
||||
<th>AUM</th>
|
||||
<th>5Y Ret</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted(watchEtfs) as r}
|
||||
{@const m = r.asset.displayMetrics ?? {}}
|
||||
<tr>
|
||||
<td class="ticker">{r.asset.ticker}</td>
|
||||
<td class="num">{m.Price ?? '—'}</td>
|
||||
<td><SignalBadge signal={r.signal} /></td>
|
||||
<td><span class="vpill {vClass(r.inflated.label)}">{verdictShort(r.inflated.label)}</span></td>
|
||||
<td><span class="vpill {vClass(r.fundamental.label)}">{verdictShort(r.fundamental.label)}</span></td>
|
||||
<td class="num">{m['Exp Ratio%'] ?? '—'}</td>
|
||||
<td class="num">{m['Yield%'] ?? '—'}</td>
|
||||
<td class="num">{m['AUM'] ?? '—'}</td>
|
||||
<td class="num">{m['5Y Return%'] ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if watchBonds.length}
|
||||
<section class="section watch-section">
|
||||
<div class="section-header">
|
||||
<h2>Bond ETFs</h2>
|
||||
<span class="count">{watchBonds.length}</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-ticker">Ticker</th>
|
||||
<th>Price</th>
|
||||
<th>Signal</th>
|
||||
<th>Mkt-Adj</th>
|
||||
<th>Graham</th>
|
||||
<th>YTM</th>
|
||||
<th>Duration</th>
|
||||
<th>Rating</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted(watchBonds) as r}
|
||||
{@const m = r.asset.displayMetrics ?? {}}
|
||||
<tr>
|
||||
<td class="ticker">{r.asset.ticker}</td>
|
||||
<td class="num">{m.Price ?? '—'}</td>
|
||||
<td><SignalBadge signal={r.signal} /></td>
|
||||
<td><span class="vpill {vClass(r.inflated.label)}">{verdictShort(r.inflated.label)}</span></td>
|
||||
<td><span class="vpill {vClass(r.fundamental.label)}">{verdictShort(r.fundamental.label)}</span></td>
|
||||
<td class="num">{m['YTM%'] ?? '—'}</td>
|
||||
<td class="num">{m['Duration'] ?? '—'}</td>
|
||||
<td class="num">{m['Rating'] ?? '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { max-width: 1100px; padding-bottom: 60px; }
|
||||
|
||||
.page-header { margin-bottom: 20px; }
|
||||
h1 { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-bottom: 6px; }
|
||||
.subtitle { font-size: 12px; color: #475569; line-height: 1.5; }
|
||||
.subtitle strong { color: #94a3b8; }
|
||||
|
||||
/* ── Strong Buy banner ───────────────────────────────────────────── */
|
||||
.strong-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.strong-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #4ade80;
|
||||
background: #14532d33;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.strong-sub { font-size: 11px; color: #475569; }
|
||||
|
||||
.empty-strong {
|
||||
padding: 32px 20px;
|
||||
background: #111827;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Watch List ──────────────────────────────────────────────────── */
|
||||
.watch-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.watch-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
background: #1e293b;
|
||||
padding: 4px 14px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.watch-sub { font-size: 11px; color: #475569; }
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────────────── */
|
||||
.section {
|
||||
background: #0d1117;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.watch-section { opacity: 0.75; }
|
||||
.watch-section:hover { opacity: 1; transition: opacity 0.2s; }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
h2 { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 0; }
|
||||
.count { font-size: 10px; color: #334155; background: #1e293b; padding: 2px 7px; border-radius: 20px; }
|
||||
|
||||
/* ── Table ───────────────────────────────────────────────────────── */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: max-content; min-width: 100%; border-collapse: collapse; }
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
padding: 7px 14px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
white-space: nowrap;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
tbody tr { border-bottom: 1px solid #161f2e; }
|
||||
tbody tr:hover { background: #131c2b; }
|
||||
tbody td { padding: 10px 14px; vertical-align: middle; white-space: nowrap; font-size: 13px; }
|
||||
|
||||
.col-ticker,
|
||||
tbody td:first-child { position: sticky; left: 0; background: #0d1117; z-index: 1; }
|
||||
thead .col-ticker { background: #111827; }
|
||||
tbody tr:hover td:first-child { background: #131c2b; }
|
||||
|
||||
.ticker { font-weight: 700; color: #f1f5f9; letter-spacing: 0.02em; }
|
||||
.num { color: #64748b; font-variant-numeric: tabular-nums; font-size: 12px; }
|
||||
.score { color: #475569; font-size: 11px; }
|
||||
|
||||
/* ── Verdict pills ───────────────────────────────────────────────── */
|
||||
.vpill {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.vpill.green { background: #14532d33; color: #4ade80; }
|
||||
.vpill.yellow { background: #71350033; color: #facc15; }
|
||||
.vpill.red { background: #450a0a33; color: #f87171; }
|
||||
|
||||
.error-banner { background: #450a0a55; border: 1px solid #7f1d1d; border-radius: 8px; color: #f87171; padding: 10px 14px; margin-bottom: 16px; font-size: 13px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user