phase-1: optimize code

This commit is contained in:
saikiranvella
2026-06-04 01:36:28 -04:00
committed by GitHub
parent 4ebf3e618d
commit d87f0b8427
89 changed files with 11189 additions and 845 deletions
+170
View File
@@ -0,0 +1,170 @@
# CLAUDE.md
Guidance for working in this repository.
## Overview
`market-screener-ui` is a SvelteKit 5 single-page application (CSR, no SSR) that serves as the interactive dashboard for the `market_screener` Fastify API.
- All data comes from the API at `http://localhost:3000` (proxied through Vite in dev)
- No SSR — `+layout.js` exports `ssr = false`
- Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`)
---
## Commands
```bash
npm install # install dependencies (SvelteKit, Vite, Svelte 5)
npm run dev # dev server on port 5173
npm run build # production build → .svelte-kit/output
npm run preview # preview production build
```
To run the full stack, use `npm run dev` from the **API repo** (`market_screener/`) instead — it starts both servers together using `concurrently`.
---
## Architecture
### No SSR
`src/routes/+layout.js` exports `ssr = false`. All data fetching happens in the browser. This avoids Svelte 5 SSR compatibility issues and makes sense for a live-data dashboard.
### API Proxy
`vite.config.js` proxies `/api/*``http://localhost:3000` in dev. In production, configure your reverse proxy (nginx/Caddy) to do the same.
### Data Loading
- **Screener page** (`/`): data loaded client-side on button click via `$lib/api.js`
- **Portfolio page** (`/portfolio`): data loaded via SvelteKit `+page.js` `load()` function — this fires on navigation and is the correct SvelteKit pattern for CSR page data
**Do not use `onMount` for initial data fetching** — use `load()` in `+page.js` instead. `onMount` does not reliably fire in SvelteKit CSR for page-level data.
---
## Project Structure
```
src/
app.html ← HTML shell
app.css ← Global reset + body styles (no :global() in .svelte files)
routes/
+layout.js ← exports ssr = false
+layout.svelte ← nav bar (Screener / Portfolio links)
+page.svelte ← Screener page
portfolio/
+page.js ← load() function — fetches /api/finance/portfolio
+page.svelte ← Portfolio + SimpleFIN page
lib/
api.js ← All fetch calls to the Fastify API
SignalBadge.svelte ← Signal pill component (Strong Buy / Avoid / etc.)
MarketContext.svelte ← Benchmark strip component
.claude/
launch.json ← Preview server config for Claude Code
vite.config.js ← Vite config with /api proxy
svelte.config.js ← SvelteKit config (adapter-auto)
```
---
## Key Files
### `src/lib/api.js`
All API calls in one place. If the API base URL changes, change it here only.
```js
screenTickers(tickers) // POST /api/screen
fetchCatalysts() // GET /api/screen/catalysts
fetchPortfolio() // GET /api/finance/portfolio
fetchMarketContext() // GET /api/finance/market-context
```
### `src/routes/+page.svelte` (Screener)
- Ticker input pre-filled with a default watchlist
- `screen()` calls API and stores results in `$state`
- `loadCatalysts()` fetches news tickers then **immediately calls `screen()`** — one click, full results
- `results` is `null` until first screen — nothing renders below the toolbar
- `verdictShort()` abbreviates long verdict strings (`"🟢 BUY (High Conviction)"``"Strong"`)
### `src/routes/portfolio/+page.svelte`
- Receives `data` from `+page.js` load function via `let { data } = $props()`
- Shows `data.error` if load failed, `data.advice` for holdings, `data.personalFinance` for SimpleFIN section
---
## Svelte 5 Patterns Used
```svelte
<!-- Reactive state -->
let loading = $state(false);
<!-- Derived values -->
const totalGL = $derived(totalValue - totalCost);
<!-- Derived with block -->
const cards = $derived.by(() => { ... return [...] });
<!-- Props -->
let { ctx } = $props();
let { data } = $props();
<!-- Event handlers (no on:click, use onclick) -->
<button onclick={screen}>Screen</button>
<!-- Conditionals in template -->
{@const mode = getTab(type)}
```
---
## API Response Shape
The Fastify API serializes asset class instances before sending — `asset.getDisplayMetrics()` is called server-side and included as `asset.displayMetrics`. In the browser, use `r.asset.displayMetrics` directly (not `r.asset.getDisplayMetrics()` which doesn't exist on plain JSON objects).
```js
// Screener response shape
{
STOCK: [{ asset: { ticker, type, currentPrice, metrics, displayMetrics }, fundamental, inflated, signal }],
ETF: [...],
BOND: [...],
ERROR: [...],
marketContext: { sp500Price, riskFreeRate, vixLevel, rateRegime, volatilityRegime, benchmarks }
}
```
---
## Styling Conventions
- Dark theme throughout: page background `#0f1117`, card sections `#0d1117`/`#111827`
- All colors are CSS custom values inline (no CSS variables yet — keep consistent with existing palette)
- Tables: `width: max-content; min-width: 100%` inside a `.table-wrap { overflow-x: auto }` container
- First column sticky: `position: sticky; left: 0; background: inherit`
- Verdict pills: `.verdict-pill.green/yellow/red` — colored background tint + text
- Monospace font for the ticker input field
- `white-space: nowrap` on `tbody td` — tables scroll horizontally, not wrap
**Color palette:**
```
page bg: #0f1117
card bg: #0d1117 / #111827 (header rows)
border: #1e293b
muted: #64748b / #475569
text: #e2e8f0 / #f1f5f9
green: #4ade80 (bg tint: #14532d33)
yellow: #facc15 (bg tint: #71350033)
red: #f87171 (bg tint: #450a0a33)
blue accent: #2563eb / #3b82f6
```
---
## Conventions
- Do not use `:global()` in `<style>` blocks — put global styles in `src/app.css`
- Use `load()` in `+page.js` for page-level data, not `onMount`
- `$derived` for computed values — do not recalculate in templates
- Keep `api.js` as the single place for fetch calls
- If adding a new page: create `+page.js` with a `load()` that fetches the needed API endpoint, receive via `$props()` in the component
+114
View File
@@ -0,0 +1,114 @@
# Market Screener UI
SvelteKit 5 dashboard for the [Market Screener](../market_screener) API. Provides an interactive interface for screening stocks, ETFs, and bonds — and tracking your portfolio with live hold/sell/add advice.
---
## Quick Start
This UI requires the Market Screener API running on port 3000.
```bash
# Recommended: start both from the API repo
cd ../market_screener
npm run dev # starts API (:3000) + this UI (:5173) together
# Or start the UI independently
npm install
npm run dev # http://localhost:5173
```
---
## Pages
### Screener (`/`)
- Enter any tickers (comma or space separated) and click **Screen**
- Click **📰 Catalysts** to load today's news-driven tickers and screen them automatically (one click)
- Market context strip shows live benchmarks: 10Y yield, VIX, S&P 500, P/E ratios, rate regime
- Signal Summary table ranks all assets by signal strength
- Drill-down tables for Stocks, ETFs, and Bonds with **Mkt-Adjusted** / **Graham** tab toggle
- Ticker column stays pinned while scrolling wide tables
### Portfolio (`/portfolio`)
- Reads `portfolio.json` from the API server
- Shows total value, cost basis, and unrealised G/L
- Per-holding advice: ✅ Hold & Add, 🟡 Reduce, 🔴 Sell
- If SimpleFIN is configured: net worth, account balances, 30-day spending breakdown
---
## Signals Explained
| Signal | Meaning |
|---|---|
| ✅ Strong Buy | Passes both Market-Adjusted AND Fundamental gates |
| ⚡ Momentum | Passes market-adjusted, holds fundamentally |
| ⚠️ Speculation | Passes market-adjusted, fails fundamental — priced for perfection |
| 🔄 Neutral | Hold territory in one or both lenses |
| ❌ Avoid | Fails both gates |
The **Mkt-Adjusted** tab uses gates derived from live market data (e.g. S&P P/E × 1.5 for the P/E gate). The **Graham** tab uses strict historical value-investing gates (P/E < 15×, PEG < 1.0).
---
## Tech Stack
| Layer | Choice |
|---|---|
| Framework | SvelteKit 2 + Svelte 5 |
| Build tool | Vite 6 |
| Adapter | `@sveltejs/adapter-auto` |
| Rendering | Client-side only (SSR disabled) |
| API | Proxied via Vite dev server → Fastify on :3000 |
---
## Project Structure
```
src/
app.html HTML shell
app.css Global reset + dark theme base
routes/
+layout.js ssr = false
+layout.svelte Nav bar
+page.svelte Screener page
portfolio/
+page.js load() → fetches /api/finance/portfolio
+page.svelte Portfolio + SimpleFIN page
lib/
api.js All API fetch functions
SignalBadge.svelte Signal pill component
MarketContext.svelte Benchmark strip component
vite.config.js /api proxy → localhost:3000
svelte.config.js SvelteKit config
```
---
## Configuration
### API URL
In `vite.config.js`, the Vite dev server proxies `/api/*` to `http://localhost:3000`. To point at a different API host, update the `proxy` target there.
For production, configure your reverse proxy (nginx, Caddy, etc.) to route `/api/*` to the Fastify server.
### CORS
The Fastify API allows `http://localhost:5173` by default. If you deploy the UI to a different origin, set `CLIENT_ORIGIN` in the API's `.env`:
```
CLIENT_ORIGIN=https://your-deployed-ui.example.com
```
---
## Development Notes
- Uses Svelte 5 runes: `$state`, `$derived`, `$derived.by`, `$props`
- `onclick={handler}` not `on:click` — Svelte 5 syntax
- Page data loaded via `+page.js` `load()` function, not `onMount`
- Global CSS lives in `src/app.css` — no `:global()` in component `<style>` blocks
- `asset.displayMetrics` (plain object from API) — never call `getDisplayMetrics()` in the browser
+1456
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "market-screener-ui",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0",
"vite": "^6.0.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f1117;
color: #e2e8f0;
font-size: 13px;
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+191
View File
@@ -0,0 +1,191 @@
<script>
let { ctx, collapsible = false } = $props();
let expanded = $state(!collapsible); // collapsed by default when collapsible=true
const cards = $derived.by(() => {
const b = ctx?.benchmarks ?? {};
return [
{
label: '10Y Yield',
value: ctx?.riskFreeRate != null ? ctx.riskFreeRate.toFixed(2) + '%' : '—',
tip: 'US 10-year Treasury yield — the risk-free rate benchmark. Higher = tighter conditions for stocks and bonds.',
},
{
label: 'VIX',
value: ctx?.vixLevel?.toFixed(1) ?? '—',
tip: 'CBOE Volatility Index — measures expected market volatility. Above 20 = elevated fear; above 30 = high stress.',
},
{
label: 'S&P 500',
value: ctx?.sp500Price?.toLocaleString() ?? '—',
tip: 'Live S&P 500 index price — broad US large-cap benchmark.',
},
{
label: 'S&P P/E',
value: b.marketPE != null ? b.marketPE.toFixed(1) + 'x' : '—',
tip: 'Trailing P/E ratio of SPY. Used to set the INFLATED mode P/E gate (S&P P/E × 1.5 in normal rates).',
},
{
label: 'Tech P/E',
value: b.techPE != null ? b.techPE.toFixed(1) + 'x' : '—',
tip: 'Trailing P/E of XLK (tech sector ETF). Sets the tech-sector gate in INFLATED mode (XLK P/E × 1.3).',
},
{
label: 'REIT Yield',
value: b.reitYield != null ? b.reitYield.toFixed(2) + '%' : '—',
tip: 'Dividend yield of XLRE (real estate ETF). Used as the REIT minimum yield gate in INFLATED mode.',
},
{
label: 'IG Spread',
value: b.igSpread != null ? b.igSpread.toFixed(2) + '%' : '—',
tip: 'Investment-grade bond spread (LQD yield 10Y yield). Sets the bond minimum spread gate in INFLATED mode.',
},
{
label: 'Rate Regime',
value: ctx?.rateRegime ?? '—',
tip: 'HIGH (>4.5%) compresses P/E gates and tightens bond/REIT requirements. NORMAL uses looser INFLATED gates.',
},
{
label: 'Volatility',
value: ctx?.volatilityRegime ?? '—',
tip: 'Derived from VIX level — LOW (<15), NORMAL (1525), HIGH (>25). Informational; not currently gating scores.',
},
];
});
</script>
<div class="ctx-wrap">
{#if collapsible}
<button class="ctx-toggle" onclick={() => expanded = !expanded}>
<span class="ctx-toggle-label">Market Context</span>
<span class="ctx-toggle-chevron">{expanded ? '▲' : '▼'}</span>
</button>
{/if}
{#if expanded}
<div class="grid">
{#each cards as c}
<div class="card">
<div class="label-row">
<span class="label">{c.label}</span>
<span class="tip-wrap">
<span class="tip-anchor">?</span>
<span class="tip-box">{c.tip}</span>
</span>
</div>
<div class="value">{c.value}</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.ctx-wrap { margin-bottom: 20px; }
/* ── Collapsible toggle ─────────────────────────────────────────── */
.ctx-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: 1px solid #1e293b;
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
margin-bottom: 10px;
}
.ctx-toggle-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #475569;
}
.ctx-toggle-chevron {
font-size: 9px;
color: #334155;
}
/* ── Cards grid ────────────────────────────────────────────────── */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 10px;
margin-bottom: 8px;
}
.card { background: #1e293b; border-radius: 8px; padding: 12px 14px; }
.label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.label {
font-size: 10px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Tooltip ──────────────────────────────────────────────────── */
.tip-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.tip-anchor {
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
border-radius: 50%;
background: #1e293b;
border: 1px solid #334155;
color: #475569;
font-size: 9px;
font-weight: 700;
cursor: help;
}
.tip-box {
display: none;
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
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: 50;
pointer-events: none;
white-space: normal;
}
.tip-box::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #334155;
}
.tip-wrap:hover .tip-box { display: block; }
.value { font-size: 17px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
</style>
+29
View File
@@ -0,0 +1,29 @@
<script>
let { signal } = $props();
const cls = () => {
if (signal?.includes('Strong')) return 'strong';
if (signal?.includes('Momentum')) return 'momentum';
if (signal?.includes('Speculation')) return 'spec';
if (signal?.includes('Neutral')) return 'neutral';
return 'avoid';
};
</script>
<span class="badge {cls()}">{signal ?? '—'}</span>
<style>
.badge {
display: inline-block;
font-size: 11px;
font-weight: 700;
padding: 3px 10px;
border-radius: 20px;
white-space: nowrap;
}
.strong { background: #14532d33; color: #4ade80; }
.momentum { background: #1e3a5f33; color: #60a5fa; }
.spec { background: #7c2d1233; color: #fb923c; }
.neutral { background: #1e293b; color: #94a3b8; }
.avoid { background: #450a0a33; color: #f87171; }
</style>
+139
View File
@@ -0,0 +1,139 @@
<script>
// size: 'sm' | 'md' | 'lg'
// label: optional text shown below (lg only)
let { size = 'md', label = null } = $props();
</script>
{#if size === 'sm'}
<!-- Compact dot-pulse for buttons -->
<span class="dot-pulse">
<span></span><span></span><span></span>
</span>
{:else}
<!-- Market chart line animation for md / lg -->
<div class="chart-wrap" data-size={size}>
<svg
viewBox="0 0 160 60"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="chart-svg"
aria-hidden="true"
>
<!-- Faint grid lines -->
<line x1="0" y1="15" x2="160" y2="15" stroke="#1e293b" stroke-width="1" />
<line x1="0" y1="30" x2="160" y2="30" stroke="#1e293b" stroke-width="1" />
<line x1="0" y1="45" x2="160" y2="45" stroke="#1e293b" stroke-width="1" />
<!-- The market line — rises, dips, spikes, recovers -->
<polyline
class="chart-line"
points="
0,45
12,38
22,42
32,28
42,32
52,18
62,24
72,14
82,20
92,10
104,22
114,16
124,28
134,20
148,8
160,12
"
/>
<!-- Glowing dot at the leading edge -->
<circle class="chart-dot" cx="160" cy="12" r="3" />
</svg>
{#if label}
<span class="chart-label">{label}</span>
{/if}
</div>
{/if}
<style>
/* ── Dot pulse (sm) ─────────────────────────────────────────────── */
.dot-pulse {
display: inline-flex;
align-items: center;
gap: 3px;
}
.dot-pulse span {
display: block;
width: 4px;
height: 4px;
border-radius: 50%;
background: #60a5fa;
animation: dot-bounce 0.9s ease-in-out infinite;
}
.dot-pulse span:nth-child(2) { animation-delay: 0.15s; }
.dot-pulse span:nth-child(3) { animation-delay: 0.30s; }
@keyframes dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* ── Chart wrap (md / lg) ───────────────────────────────────────── */
.chart-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.chart-wrap[data-size="md"] .chart-svg { width: 120px; height: 45px; }
.chart-wrap[data-size="lg"] .chart-svg { width: 200px; height: 75px; }
.chart-svg { overflow: visible; }
/* The animated line */
.chart-line {
stroke: #3b82f6;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
/* total path length ≈ 220 — animate draw-in then loop */
stroke-dasharray: 220;
stroke-dashoffset: 220;
animation: draw-line 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes draw-line {
0% { stroke-dashoffset: 220; opacity: 1; }
70% { stroke-dashoffset: 0; opacity: 1; }
85% { stroke-dashoffset: 0; opacity: 0; }
100% { stroke-dashoffset: 220; opacity: 0; }
}
/* Glowing dot that appears when the line finishes drawing */
.chart-dot {
fill: #3b82f6;
filter: drop-shadow(0 0 4px #3b82f6);
opacity: 0;
animation: dot-appear 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes dot-appear {
0% { opacity: 0; }
60% { opacity: 0; }
70% { opacity: 1; }
85% { opacity: 1; }
100% { opacity: 0; }
}
.chart-label {
font-size: 12px;
color: #475569;
letter-spacing: 0.02em;
}
</style>
+96
View File
@@ -0,0 +1,96 @@
const BASE = '/api';
export async function screenTickers(tickers) {
const res = await fetch(`${BASE}/screen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tickers }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchCatalysts() {
const res = await fetch(`${BASE}/screen/catalysts`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function analyzeTickers(tickers) {
const res = await fetch(`${BASE}/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tickers }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchPortfolio() {
const res = await fetch(`${BASE}/finance/portfolio`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function addHolding(holding) {
const res = await fetch(`${BASE}/finance/holdings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(holding),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function removeHolding(ticker) {
const res = await fetch(`${BASE}/finance/holdings/${ticker}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchMarketContext() {
const res = await fetch(`${BASE}/finance/market-context`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// ── Market Calls ──────────────────────────────────────────────────────────────
export async function fetchCalls() {
const res = await fetch(`${BASE}/calls`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchCall(id) {
const res = await fetch(`${BASE}/calls/${id}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function createCall(payload) {
const res = await fetch(`${BASE}/calls`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deleteCall(id) {
const res = await fetch(`${BASE}/calls/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchCallsCalendar(tickers = null) {
const url = tickers?.length
? `${BASE}/calls/calendar?tickers=${tickers.join(',')}`
: `${BASE}/calls/calendar`;
const res = await fetch(url);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
+1
View File
@@ -0,0 +1 @@
export const ssr = false;
+132
View File
@@ -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>
+858
View File
@@ -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>
+8
View File
@@ -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 };
}
+420
View File
@@ -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>
+5
View File
@@ -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();
}
+202
View File
@@ -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>
+8
View File
@@ -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 {};
}
+795
View File
@@ -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>
+60
View File
@@ -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,
};
}
+368
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
import adapter from '@sveltejs/adapter-auto';
export default {
kit: { adapter: adapter() },
};
+11
View File
@@ -0,0 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
'/api': 'http://127.0.0.1:3000',
},
},
});