UI enhancemnts
This commit is contained in:
+49
@@ -0,0 +1,49 @@
|
|||||||
|
# ── Stage 1: Build the SvelteKit UI ──────────────────────────────────────────
|
||||||
|
FROM node:22-alpine AS ui-builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY ui/package*.json ./ui/
|
||||||
|
RUN cd ui && npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
# UI source + shared server types (needed for $types alias)
|
||||||
|
COPY ui/ ./ui/
|
||||||
|
COPY server/ ./server/
|
||||||
|
|
||||||
|
WORKDIR /app/ui
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 2: Runtime (API + compiled UI) ─────────────────────────────────────
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# API dependencies (tsx needed at runtime for ESM TypeScript)
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# API source
|
||||||
|
COPY bin/ ./bin/
|
||||||
|
COPY server/ ./server/
|
||||||
|
COPY tsconfig*.json ./
|
||||||
|
|
||||||
|
# Pre-built UI from stage 1
|
||||||
|
COPY --from=ui-builder /app/ui/build ./ui/build
|
||||||
|
COPY --from=ui-builder /app/ui/package*.json ./ui/
|
||||||
|
RUN cd ui && npm ci --omit=dev --legacy-peer-deps
|
||||||
|
|
||||||
|
# SQLite volume mount point
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV DB_PATH=/app/data/market-screener.db
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV UI_PORT=3001
|
||||||
|
|
||||||
|
EXPOSE 3000 3001
|
||||||
|
|
||||||
|
# Run both processes; if either dies the container exits
|
||||||
|
CMD ["npx", "concurrently", \
|
||||||
|
"--kill-others", \
|
||||||
|
"--names", "api,ui", \
|
||||||
|
"tsx bin/server.ts", \
|
||||||
|
"node ui/build/index.js"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install all deps (tsx is needed at runtime for ESM + TypeScript)
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY bin/ ./bin/
|
||||||
|
COPY server/ ./server/
|
||||||
|
COPY tsconfig*.json ./
|
||||||
|
|
||||||
|
# SQLite database lives here — mount a volume at /app/data in compose
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
ENV DB_PATH=/app/data/market-screener.db
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npx", "tsx", "bin/server.ts"]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy UI package files and install
|
||||||
|
COPY ui/package*.json ./ui/
|
||||||
|
RUN cd ui && npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
# Copy UI source + shared server types (needed for $types alias resolution)
|
||||||
|
COPY ui/ ./ui/
|
||||||
|
COPY server/ ./server/
|
||||||
|
|
||||||
|
WORKDIR /app/ui
|
||||||
|
|
||||||
|
# adapter-auto picks adapter-node when NODE_ENV=production in a container
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Runtime stage ---
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/ui/build ./build
|
||||||
|
COPY --from=builder /app/ui/package*.json ./
|
||||||
|
RUN npm ci --omit=dev --legacy-peer-deps
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
ENV PORT=3001
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
|
||||||
|
CMD ["node", "build"]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000"
|
||||||
|
- "127.0.0.1:3001:3001"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DB_PATH: /app/data/market-screener.db
|
||||||
|
API_KEY: ${API_KEY:-}
|
||||||
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||||
|
SIMPLEFIN_ACCESS_URL: ${SIMPLEFIN_ACCESS_URL:-}
|
||||||
|
SIMPLEFIN_SETUP_TOKEN: ${SIMPLEFIN_SETUP_TOKEN:-}
|
||||||
|
CLIENT_ORIGIN: ${CLIENT_ORIGIN:-http://localhost}
|
||||||
|
volumes:
|
||||||
|
- db_data:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,631 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>LLM Analysis — Redesign Prototype</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
button{font-family:inherit;cursor:pointer;}
|
||||||
|
|
||||||
|
:root{
|
||||||
|
--bg-base: #0a0e14;
|
||||||
|
--bg-surface: #111820;
|
||||||
|
--bg-elevated: #1a2332;
|
||||||
|
--bg-card: #141e2b;
|
||||||
|
--border: #1e2d3d;
|
||||||
|
--border-lt: #263447;
|
||||||
|
|
||||||
|
--text-1: #e2eaf4;
|
||||||
|
--text-2: #7a93ad;
|
||||||
|
--text-3: #3d5166;
|
||||||
|
|
||||||
|
--green: #34d17a;
|
||||||
|
--green-dim: #0d2e1a;
|
||||||
|
--green-mid: #1a4a2a;
|
||||||
|
--red: #f05a5a;
|
||||||
|
--red-dim: #2e0d0d;
|
||||||
|
--red-mid: #4a1a1a;
|
||||||
|
--amber: #f0b429;
|
||||||
|
--amber-dim: #2e2000;
|
||||||
|
--blue: #4da6ff;
|
||||||
|
--blue-dim: #0d2240;
|
||||||
|
--purple: #a78bfa;
|
||||||
|
--purple-dim: #1e1535;
|
||||||
|
--teal: #2dd4bf;
|
||||||
|
--teal-dim: #0d2e2a;
|
||||||
|
|
||||||
|
--font-ui: 'Inter', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
--t: 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body{
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
background: #060a10;
|
||||||
|
color: var(--text-1);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px 16px;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* side-by-side comparison */
|
||||||
|
.compare-label{
|
||||||
|
font-size: 11px; font-weight: 600; letter-spacing: .08em;
|
||||||
|
text-transform: uppercase; color: var(--text-3);
|
||||||
|
text-align: center; margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PANEL SHELL ── */
|
||||||
|
.panel{
|
||||||
|
width: 380px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 92vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── HEADER ── */
|
||||||
|
.panel-header{
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
.panel-icon{ font-size: 18px; }
|
||||||
|
.panel-title{ font-size: 14px; font-weight: 700; flex: 1; }
|
||||||
|
.scope-chip{
|
||||||
|
padding: 3px 10px; border-radius: 20px;
|
||||||
|
font-size: 11px; font-weight: 600; letter-spacing: .04em;
|
||||||
|
background: var(--blue-dim); color: var(--blue);
|
||||||
|
border: 1px solid #1a3a5c;
|
||||||
|
}
|
||||||
|
.close-btn{
|
||||||
|
width: 26px; height: 26px; border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: none; color: var(--text-2);
|
||||||
|
font-size: 16px; display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: all var(--t);
|
||||||
|
}
|
||||||
|
.close-btn:hover{ background: var(--bg-elevated); color: var(--text-1); }
|
||||||
|
|
||||||
|
/* ── SCROLLABLE BODY ── */
|
||||||
|
.panel-body{
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.panel-body::-webkit-scrollbar{ width: 3px; }
|
||||||
|
.panel-body::-webkit-scrollbar-thumb{ background: var(--border); border-radius: 2px; }
|
||||||
|
|
||||||
|
/* ── SENTIMENT HERO ── */
|
||||||
|
.sentiment-hero{
|
||||||
|
padding: 20px 16px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.sent-top{
|
||||||
|
display: flex; align-items: center;
|
||||||
|
justify-content: space-between; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.sent-badge{
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 16px; border-radius: 24px;
|
||||||
|
font-size: 13px; font-weight: 700; letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.sent-bullish { background: var(--green-dim); color: var(--green); border: 1px solid var(--green-mid); }
|
||||||
|
.sent-neutral { background: var(--blue-dim); color: var(--blue); border: 1px solid #1a3a5c; }
|
||||||
|
.sent-bearish { background: var(--red-dim); color: var(--red); border: 1px solid var(--red-mid); }
|
||||||
|
.sent-mixed { background: var(--amber-dim); color: var(--amber); border: 1px solid #4a3000; }
|
||||||
|
|
||||||
|
.sent-meta{
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.sent-time{
|
||||||
|
font-size: 10px; font-family: var(--font-mono);
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.sent-model{
|
||||||
|
font-size: 10px; padding: 2px 7px; border-radius: 4px;
|
||||||
|
background: var(--bg-elevated); color: var(--text-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* confidence bar */
|
||||||
|
.conf-row{
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.conf-label{
|
||||||
|
font-size: 10px; font-weight: 600; letter-spacing: .06em;
|
||||||
|
text-transform: uppercase; color: var(--text-3); width: 72px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.conf-track{
|
||||||
|
flex: 1; height: 5px; background: var(--border);
|
||||||
|
border-radius: 3px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.conf-fill{
|
||||||
|
height: 100%; border-radius: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--blue) 0%, var(--teal) 100%);
|
||||||
|
transition: width .6s ease;
|
||||||
|
}
|
||||||
|
.conf-pct{
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
font-family: var(--font-mono); color: var(--blue); width: 36px; text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* summary */
|
||||||
|
.summary-text{
|
||||||
|
font-size: 13px; line-height: 1.7;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.summary-text strong{ color: var(--text-1); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── SECTION ── */
|
||||||
|
.section{ padding: 16px 16px 0; }
|
||||||
|
.section:last-child{ padding-bottom: 16px; }
|
||||||
|
|
||||||
|
.section-header{
|
||||||
|
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.section-title{
|
||||||
|
font-size: 10px; font-weight: 700; letter-spacing: .1em;
|
||||||
|
text-transform: uppercase; color: var(--text-3);
|
||||||
|
}
|
||||||
|
.section-count{
|
||||||
|
font-size: 10px; font-family: var(--font-mono);
|
||||||
|
padding: 1px 6px; border-radius: 3px;
|
||||||
|
background: var(--bg-elevated); color: var(--text-3);
|
||||||
|
}
|
||||||
|
.section-divider{
|
||||||
|
flex: 1; height: 1px; background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── INDUSTRY CARDS ── */
|
||||||
|
.industry-list{ display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.ind-card{
|
||||||
|
border-radius: 8px; padding: 11px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
transition: border-color var(--t);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.ind-card:hover{ border-color: var(--border-lt); }
|
||||||
|
|
||||||
|
.ind-card-top{
|
||||||
|
display: flex; align-items: flex-start;
|
||||||
|
justify-content: space-between; gap: 8px; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.ind-name{
|
||||||
|
font-size: 12px; font-weight: 600; color: var(--text-1);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.impact-chip{
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
padding: 2px 8px; border-radius: 4px;
|
||||||
|
font-size: 10px; font-weight: 700; letter-spacing: .05em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.imp-bear{ background: var(--red-dim); color: var(--red); border: 1px solid var(--red-mid); }
|
||||||
|
.imp-bull{ background: var(--green-dim); color: var(--green); border: 1px solid var(--green-mid); }
|
||||||
|
.imp-neut{ background: var(--bg-elevated); color: var(--text-2); border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.ind-body{
|
||||||
|
font-size: 12px; line-height: 1.6; color: var(--text-2);
|
||||||
|
}
|
||||||
|
.ind-body strong{ color: var(--text-1); font-weight: 600; }
|
||||||
|
|
||||||
|
/* accent left border by impact */
|
||||||
|
.ind-card.bear{ border-left: 2px solid var(--red); }
|
||||||
|
.ind-card.bull{ border-left: 2px solid var(--green); }
|
||||||
|
.ind-card.neut{ border-left: 2px solid var(--border-lt); }
|
||||||
|
|
||||||
|
/* ── TICKER CARDS ── */
|
||||||
|
.ticker-list{ display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.tick-card{
|
||||||
|
border-radius: 8px; padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
transition: border-color var(--t), background var(--t);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tick-card:hover{ border-color: var(--border-lt); background: var(--bg-elevated); }
|
||||||
|
|
||||||
|
.tick-top{
|
||||||
|
display: flex; align-items: center; gap: 8px; margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
.tick-sym{
|
||||||
|
font-size: 15px; font-weight: 700;
|
||||||
|
font-family: var(--font-mono); letter-spacing: .03em;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
.tick-name{
|
||||||
|
font-size: 11px; color: var(--text-2); flex: 1;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.signal-chip{
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
padding: 3px 9px; border-radius: 20px;
|
||||||
|
font-size: 10px; font-weight: 700; letter-spacing: .05em;
|
||||||
|
}
|
||||||
|
.sig-bear{ background: var(--red-dim); color: var(--red); border: 1px solid var(--red-mid); }
|
||||||
|
.sig-bull{ background: var(--green-dim); color: var(--green); border: 1px solid var(--green-mid); }
|
||||||
|
.sig-neut{ background: var(--bg-elevated); color: var(--text-2); border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.tick-meta{
|
||||||
|
display: flex; align-items: center; gap: 6px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.conf-chip{
|
||||||
|
font-size: 10px; font-weight: 600; font-family: var(--font-mono);
|
||||||
|
padding: 2px 8px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.conf-high { background: var(--green-dim); color: var(--green); }
|
||||||
|
.conf-med { background: var(--amber-dim); color: var(--amber); }
|
||||||
|
.conf-low { background: var(--bg-elevated); color: var(--text-3); }
|
||||||
|
|
||||||
|
.score-tier{
|
||||||
|
font-size: 10px; font-weight: 600; font-family: var(--font-mono);
|
||||||
|
color: var(--text-3); padding: 2px 7px; border-radius: 4px;
|
||||||
|
background: var(--bg-elevated); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.score-tip{
|
||||||
|
font-size: 10px; color: var(--text-3); cursor: help;
|
||||||
|
text-decoration: underline; text-decoration-style: dotted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick-thesis{
|
||||||
|
font-size: 12px; line-height: 1.6; color: var(--text-2);
|
||||||
|
padding-top: 8px; border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.tick-thesis strong{ color: var(--text-1); font-weight: 600; }
|
||||||
|
|
||||||
|
/* catalyst tag */
|
||||||
|
.catalyst-tag{
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-size: 10px; font-weight: 500;
|
||||||
|
color: var(--purple); background: var(--purple-dim);
|
||||||
|
padding: 2px 8px; border-radius: 4px;
|
||||||
|
border: 1px solid #2d2050; margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── SCREENER PROMPT ── */
|
||||||
|
.screener-prompt{
|
||||||
|
margin: 0 16px 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--blue-dim);
|
||||||
|
border: 1px solid #1a3a5c;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||||
|
}
|
||||||
|
.sp-text{
|
||||||
|
font-size: 12px; color: var(--blue); line-height: 1.5;
|
||||||
|
}
|
||||||
|
.sp-text strong{ font-weight: 600; }
|
||||||
|
.sp-btn{
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 14px; border-radius: 6px;
|
||||||
|
background: var(--blue); color: #000;
|
||||||
|
border: none; font-size: 11px; font-weight: 700;
|
||||||
|
letter-spacing: .04em; cursor: pointer;
|
||||||
|
transition: background var(--t);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sp-btn:hover{ background: #7ec0ff; }
|
||||||
|
|
||||||
|
/* ── OLD PANEL STYLES (for comparison) ── */
|
||||||
|
.old-panel{
|
||||||
|
width: 380px;
|
||||||
|
background: #1a2030;
|
||||||
|
border: 1px solid #2a3a50;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 92vh;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.old-header{
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid #2a3a50;
|
||||||
|
font-size: 14px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.old-body{ flex: 1; overflow-y: auto; padding: 16px; }
|
||||||
|
.old-sentiment{
|
||||||
|
font-size: 11px; font-weight: 700; letter-spacing: .1em;
|
||||||
|
text-transform: uppercase; color: #5a7a9a; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.old-quote{
|
||||||
|
border-left: 3px solid #3a5a7a;
|
||||||
|
padding: 4px 0 4px 14px; margin-bottom: 20px;
|
||||||
|
font-size: 14px; color: #8aaac0; line-height: 1.7;
|
||||||
|
}
|
||||||
|
.old-section{
|
||||||
|
font-size: 11px; font-weight: 700; letter-spacing: .1em;
|
||||||
|
text-transform: uppercase; color: #c8d8e8; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.old-ind-card{
|
||||||
|
background: #1e2a3a; border: 1px solid #2a3a50;
|
||||||
|
border-radius: 8px; padding: 12px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.old-ind-title{ font-size: 13px; font-weight: 600; color: #6a9ac0; margin-bottom: 6px; }
|
||||||
|
.old-ind-body { font-size: 13px; color: #9ab0c0; line-height: 1.6; }
|
||||||
|
.old-ticker-card{
|
||||||
|
background: #1e2a3a; border: 1px solid #2a3a50;
|
||||||
|
border-radius: 8px; padding: 12px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.old-tick-top{
|
||||||
|
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.old-tick-sym{ font-size: 16px; font-weight: 700; color: #e8f0f8; }
|
||||||
|
.old-bear{ background: #c0392b; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
|
||||||
|
.old-med { background: #1a3a5c; color: #4da6ff; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
|
||||||
|
.old-s { background: #2a3a4a; color: #9ab0c0; padding: 2px 8px; border-radius: 4px; font-size: 11px; }
|
||||||
|
.old-tick-body{ font-size: 13px; color: #9ab0c0; line-height: 1.6; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ── BEFORE (current) ── -->
|
||||||
|
<div>
|
||||||
|
<div class="compare-label">❌ Before — Current Design</div>
|
||||||
|
<div class="old-panel">
|
||||||
|
<div class="old-header">
|
||||||
|
🤖 LLM Analysis
|
||||||
|
<span style="background:#1a3a5c;color:#4da6ff;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:600">STOCKS</span>
|
||||||
|
<span style="margin-left:auto;color:#5a7a9a;font-size:18px;cursor:pointer">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="old-body">
|
||||||
|
<div class="old-sentiment">NEUTRAL</div>
|
||||||
|
<div class="old-quote">
|
||||||
|
Tech sector faces a consolidation phase as Apple's underwhelming AI announcements weigh on mega-cap sentiment, while financial stocks and fintech platforms show relative strength; the market braces for inflation data and Fed decisions with elevated volatility across semiconductors and growth equities.
|
||||||
|
</div>
|
||||||
|
<div class="old-section">AFFECTED INDUSTRIES</div>
|
||||||
|
<div class="old-ind-card">
|
||||||
|
<div class="old-ind-title">Semiconductor Equipment & Materials</div>
|
||||||
|
<div class="old-ind-body">AI disappointment from AAPL reduces near-term demand signals for chip manufacturers; capex guidance revisions possible as OEMs delay purchasing cycles.</div>
|
||||||
|
</div>
|
||||||
|
<div class="old-ind-card">
|
||||||
|
<div class="old-ind-title">Enterprise Software & Cloud Infrastructure</div>
|
||||||
|
<div class="old-ind-body">Inflation data and Fed rate expectations influence SaaS margin profiles and customer IT budget allocation; higher rates pressure growth-at-all-costs valuations.</div>
|
||||||
|
</div>
|
||||||
|
<div class="old-ind-card">
|
||||||
|
<div class="old-ind-title">Consumer Discretionary & Travel/Hospitality</div>
|
||||||
|
<div class="old-ind-body">Earnings misses at MTN signal consumer spending weakness; tariff concerns (Trump pivot) threaten cost structures for imported goods and leisure operators.</div>
|
||||||
|
</div>
|
||||||
|
<br/>
|
||||||
|
<div class="old-section">RELATED TICKERS TO WATCH</div>
|
||||||
|
<div class="old-ticker-card">
|
||||||
|
<div class="old-tick-top">
|
||||||
|
<span class="old-tick-sym">LRCX</span>
|
||||||
|
<span class="old-bear">BEAR</span>
|
||||||
|
<span class="old-med">MEDIUM</span>
|
||||||
|
<span class="old-s">S4</span>
|
||||||
|
</div>
|
||||||
|
<div class="old-tick-body">Semiconductor equipment supplier directly exposed to AI capex cycles; Apple AI letdown signals delayed fab tool orders and potential guidance misses.</div>
|
||||||
|
</div>
|
||||||
|
<div class="old-ticker-card">
|
||||||
|
<div class="old-tick-top">
|
||||||
|
<span class="old-tick-sym">ASML</span>
|
||||||
|
<span class="old-bear">BEAR</span>
|
||||||
|
<span class="old-med">MEDIUM</span>
|
||||||
|
<span class="old-s">S3</span>
|
||||||
|
</div>
|
||||||
|
<div class="old-tick-body">Upstream equipment vendor to chip makers; weakening AI demand narrative pressures customer capex visibility and order book confidence.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── AFTER (redesigned) ── -->
|
||||||
|
<div>
|
||||||
|
<div class="compare-label">✅ After — Redesigned</div>
|
||||||
|
<div class="panel">
|
||||||
|
|
||||||
|
<!-- header -->
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-icon">🤖</span>
|
||||||
|
<span class="panel-title">LLM Analysis</span>
|
||||||
|
<span class="scope-chip">STOCKS</span>
|
||||||
|
<button class="close-btn">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
|
|
||||||
|
<!-- ── SENTIMENT HERO ── -->
|
||||||
|
<div class="sentiment-hero">
|
||||||
|
<div class="sent-top">
|
||||||
|
<span class="sent-badge sent-neutral">
|
||||||
|
⊙ Neutral
|
||||||
|
</span>
|
||||||
|
<div class="sent-meta">
|
||||||
|
<span class="sent-time">2 min ago</span>
|
||||||
|
<span class="sent-model">claude-sonnet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- confidence bar -->
|
||||||
|
<div class="conf-row">
|
||||||
|
<span class="conf-label">Confidence</span>
|
||||||
|
<div class="conf-track">
|
||||||
|
<div class="conf-fill" style="width:72%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="conf-pct">72%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-text">
|
||||||
|
Tech sector faces a <strong>consolidation phase</strong> as Apple's underwhelming AI announcements weigh on mega-cap sentiment, while <strong>financial stocks and fintech</strong> show relative strength. Market braces for inflation data and Fed decisions — elevated volatility expected across semiconductors and growth equities.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── AFFECTED INDUSTRIES ── -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Affected Industries</span>
|
||||||
|
<span class="section-count">4</span>
|
||||||
|
<div class="section-divider"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="industry-list">
|
||||||
|
|
||||||
|
<div class="ind-card bear">
|
||||||
|
<div class="ind-card-top">
|
||||||
|
<span class="ind-name">Semiconductor Equipment & Materials</span>
|
||||||
|
<span class="impact-chip imp-bear">▼ BEAR</span>
|
||||||
|
</div>
|
||||||
|
<div class="ind-body">
|
||||||
|
<strong>AAPL AI letdown</strong> reduces near-term demand signals for chip manufacturers. Capex guidance revisions possible as OEMs delay purchasing cycles.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ind-card bear">
|
||||||
|
<div class="ind-card-top">
|
||||||
|
<span class="ind-name">Enterprise Software & Cloud Infrastructure</span>
|
||||||
|
<span class="impact-chip imp-bear">▼ BEAR</span>
|
||||||
|
</div>
|
||||||
|
<div class="ind-body">
|
||||||
|
<strong>Higher rates</strong> pressure SaaS margin profiles and customer IT budget allocation. Growth-at-all-costs valuations face multiple compression.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ind-card bear">
|
||||||
|
<div class="ind-card-top">
|
||||||
|
<span class="ind-name">Consumer Discretionary & Travel</span>
|
||||||
|
<span class="impact-chip imp-bear">▼ BEAR</span>
|
||||||
|
</div>
|
||||||
|
<div class="ind-body">
|
||||||
|
<strong>MTN earnings miss</strong> signals consumer spending weakness. Tariff concerns threaten cost structures for imported goods and leisure operators.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ind-card bull">
|
||||||
|
<div class="ind-card-top">
|
||||||
|
<span class="ind-name">Private Credit & Non-Bank Lending</span>
|
||||||
|
<span class="impact-chip imp-bull">▲ BULL</span>
|
||||||
|
</div>
|
||||||
|
<div class="ind-body">
|
||||||
|
Rising yields reflect well on BDC net interest margins. <strong>Fintech lenders like SOFI</strong> benefit from institutional inflows, though spread compression is a risk.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── RELATED TICKERS ── -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Tickers to Watch</span>
|
||||||
|
<span class="section-count">5</span>
|
||||||
|
<div class="section-divider"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ticker-list">
|
||||||
|
|
||||||
|
<div class="tick-card">
|
||||||
|
<div class="tick-top">
|
||||||
|
<span class="tick-sym">LRCX</span>
|
||||||
|
<span class="tick-name">Lam Research Corp</span>
|
||||||
|
<span class="signal-chip sig-bear">▼ BEARISH</span>
|
||||||
|
</div>
|
||||||
|
<div class="tick-meta">
|
||||||
|
<span class="conf-chip conf-med">MED confidence</span>
|
||||||
|
<span class="score-tier" title="Screener score tier: S4 = score 4/20">Screener S4</span>
|
||||||
|
</div>
|
||||||
|
<div class="tick-thesis">
|
||||||
|
Semiconductor equipment supplier <strong>directly exposed to AI capex cycles</strong>. Apple AI letdown signals delayed fab tool orders and potential guidance misses.
|
||||||
|
</div>
|
||||||
|
<div class="catalyst-tag">⚡ Catalyst: AAPL AI capex cut</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tick-card">
|
||||||
|
<div class="tick-top">
|
||||||
|
<span class="tick-sym">ASML</span>
|
||||||
|
<span class="tick-name">ASML Holding NV</span>
|
||||||
|
<span class="signal-chip sig-bear">▼ BEARISH</span>
|
||||||
|
</div>
|
||||||
|
<div class="tick-meta">
|
||||||
|
<span class="conf-chip conf-med">MED confidence</span>
|
||||||
|
<span class="score-tier" title="Screener score tier: S3 = score 3/20">Screener S3</span>
|
||||||
|
</div>
|
||||||
|
<div class="tick-thesis">
|
||||||
|
Upstream equipment vendor. <strong>Weakening AI demand narrative</strong> pressures customer capex visibility and order book confidence near-term.
|
||||||
|
</div>
|
||||||
|
<div class="catalyst-tag">⚡ Catalyst: AI capex slowdown</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tick-card">
|
||||||
|
<div class="tick-top">
|
||||||
|
<span class="tick-sym">SOFI</span>
|
||||||
|
<span class="tick-name">SoFi Technologies</span>
|
||||||
|
<span class="signal-chip sig-bull">▲ BULLISH</span>
|
||||||
|
</div>
|
||||||
|
<div class="tick-meta">
|
||||||
|
<span class="conf-chip conf-med">MED confidence</span>
|
||||||
|
<span class="score-tier" title="Screener score tier: S6 = score 6/20">Screener S6</span>
|
||||||
|
</div>
|
||||||
|
<div class="tick-thesis">
|
||||||
|
Fintech lender benefiting from <strong>institutional inflows</strong> as yields rise. Watch for spread compression risk if credit conditions tighten further.
|
||||||
|
</div>
|
||||||
|
<div class="catalyst-tag">⚡ Catalyst: Rate environment tailwind</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tick-card">
|
||||||
|
<div class="tick-top">
|
||||||
|
<span class="tick-sym">MTN</span>
|
||||||
|
<span class="tick-name">Vail Resorts Inc</span>
|
||||||
|
<span class="signal-chip sig-bear">▼ BEARISH</span>
|
||||||
|
</div>
|
||||||
|
<div class="tick-meta">
|
||||||
|
<span class="conf-chip conf-high">HIGH confidence</span>
|
||||||
|
<span class="score-tier">Screener S2</span>
|
||||||
|
</div>
|
||||||
|
<div class="tick-thesis">
|
||||||
|
Recent <strong>earnings miss</strong> directly signals consumer discretionary softness. Tariff pressure compounds cost-side risks. Monitor forward guidance closely.
|
||||||
|
</div>
|
||||||
|
<div class="catalyst-tag">⚡ Catalyst: Earnings miss + tariff risk</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tick-card">
|
||||||
|
<div class="tick-top">
|
||||||
|
<span class="tick-sym">NVDA</span>
|
||||||
|
<span class="tick-name">NVIDIA Corp</span>
|
||||||
|
<span class="signal-chip sig-neut">⊙ WATCH</span>
|
||||||
|
</div>
|
||||||
|
<div class="tick-meta">
|
||||||
|
<span class="conf-chip conf-low">LOW confidence</span>
|
||||||
|
<span class="score-tier">Screener S13</span>
|
||||||
|
</div>
|
||||||
|
<div class="tick-thesis">
|
||||||
|
<strong>Dual exposure</strong>: benefits from AI capex but indirectly exposed if Apple's AI pullback signals broader industry caution. Monitor hyperscaler guidance.
|
||||||
|
</div>
|
||||||
|
<div class="catalyst-tag">⚡ Catalyst: Hyperscaler capex announcements</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── SCREENER BRIDGE ── -->
|
||||||
|
<div class="screener-prompt">
|
||||||
|
<div class="sp-text">
|
||||||
|
<strong>Screen these tickers</strong> to see current signals, scores, and gate results.
|
||||||
|
</div>
|
||||||
|
<button class="sp-btn">Screen All →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /panel-body -->
|
||||||
|
</div><!-- /panel -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+4
-1
@@ -16,9 +16,12 @@
|
|||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,js}": [
|
"{server,bin,tests}/**/*.{ts,js}": [
|
||||||
"eslint --fix",
|
"eslint --fix",
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"ui/src/**/*.ts": [
|
||||||
|
"prettier --write"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+7
-1
@@ -10,6 +10,7 @@ import { PortfolioAdvisor } from './domains/portfolio';
|
|||||||
import { CallsController, CalendarService } from './domains/calls';
|
import { CallsController, CalendarService } from './domains/calls';
|
||||||
import { AuthController, AuthService, UserStore, verifyJwt } from './domains/auth';
|
import { AuthController, AuthService, UserStore, verifyJwt } from './domains/auth';
|
||||||
import type { TokenPayload } from './domains/auth';
|
import type { TokenPayload } from './domains/auth';
|
||||||
|
import { WatchlistController, WatchlistRepository } from './domains/watchlist';
|
||||||
|
|
||||||
// Shared infrastructure
|
// Shared infrastructure
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
LLMAnalyst,
|
LLMAnalyst,
|
||||||
MarketCallRepository,
|
MarketCallRepository,
|
||||||
PortfolioRepository,
|
PortfolioRepository,
|
||||||
|
SignalSnapshotRepository,
|
||||||
createDb,
|
createDb,
|
||||||
DatabaseConnection,
|
DatabaseConnection,
|
||||||
QueryAudit,
|
QueryAudit,
|
||||||
@@ -124,12 +126,14 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
|
|||||||
const innerWidth = Math.max(line1.length, line2.length) + 2;
|
const innerWidth = Math.max(line1.length, line2.length) + 2;
|
||||||
const hr = '─'.repeat(innerWidth);
|
const hr = '─'.repeat(innerWidth);
|
||||||
const pad = (s: string) => `│ ${s}${' '.repeat(innerWidth - 1 - s.length)}│`;
|
const pad = (s: string) => `│ ${s}${' '.repeat(innerWidth - 1 - s.length)}│`;
|
||||||
|
/* eslint-disable no-console -- boot-time invite code must reach the operator's terminal */
|
||||||
console.log(`\n┌${hr}┐`);
|
console.log(`\n┌${hr}┐`);
|
||||||
console.log(pad(''));
|
console.log(pad(''));
|
||||||
console.log(pad(line1));
|
console.log(pad(line1));
|
||||||
console.log(pad(line2));
|
console.log(pad(line2));
|
||||||
console.log(pad(''));
|
console.log(pad(''));
|
||||||
console.log(`└${hr}┘\n`);
|
console.log(`└${hr}┘\n`);
|
||||||
|
/* eslint-enable no-console */
|
||||||
|
|
||||||
const userStore = new UserStore(db);
|
const userStore = new UserStore(db);
|
||||||
const authService = new AuthService(userStore, JWT_SECRET);
|
const authService = new AuthService(userStore, JWT_SECRET);
|
||||||
@@ -137,7 +141,7 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
|
|||||||
|
|
||||||
// Register controllers
|
// Register controllers
|
||||||
// Public routes (GET) remain open; write routes require JWT + trader role
|
// Public routes (GET) remain open; write routes require JWT + trader role
|
||||||
new ScreenerController(engine, catalystCache).register(app);
|
new ScreenerController(engine, catalystCache, new SignalSnapshotRepository(db)).register(app);
|
||||||
new FinanceController(engine, new PortfolioRepository(db), advisor, {
|
new FinanceController(engine, new PortfolioRepository(db), advisor, {
|
||||||
authGuard,
|
authGuard,
|
||||||
traderGuard,
|
traderGuard,
|
||||||
@@ -148,6 +152,8 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
|
|||||||
}).register(app);
|
}).register(app);
|
||||||
new AnalyzeController(catalystCache, llm).register(app);
|
new AnalyzeController(catalystCache, llm).register(app);
|
||||||
|
|
||||||
|
new WatchlistController(new WatchlistRepository(db), { authGuard }).register(app);
|
||||||
|
|
||||||
app.get('/health', async () => ({ status: 'ok' }));
|
app.get('/health', async () => ({ status: 'ok' }));
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -119,9 +119,11 @@ export class AuthService {
|
|||||||
this.#store.createResetToken(user.id, token, expiresAt);
|
this.#store.createResetToken(user.id, token, expiresAt);
|
||||||
|
|
||||||
const link = `${appOrigin}/auth/reset-password?token=${token}`;
|
const link = `${appOrigin}/auth/reset-password?token=${token}`;
|
||||||
|
/* eslint-disable no-console -- no mailer yet: reset link must reach the operator's terminal */
|
||||||
console.log('\n🔐 Password reset requested for:', email);
|
console.log('\n🔐 Password reset requested for:', email);
|
||||||
console.log(' Link (expires in 1 hour):');
|
console.log(' Link (expires in 1 hour):');
|
||||||
console.log(` ${link}\n`);
|
console.log(` ${link}\n`);
|
||||||
|
/* eslint-enable no-console */
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export class ScreenerEngine {
|
|||||||
asset,
|
asset,
|
||||||
fundamental,
|
fundamental,
|
||||||
inflated,
|
inflated,
|
||||||
signal: this.signal(fundamental.label, inflated.label),
|
signal: this.signal(fundamental, inflated),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
results.ERROR.push({
|
results.ERROR.push({
|
||||||
@@ -184,13 +184,13 @@ export class ScreenerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private signal(fundamentalLabel: string, inflatedLabel: string): Signal {
|
// Signal derives from the structured verdict tier — never from label strings.
|
||||||
const green = (l: string) => l.startsWith('🟢');
|
// Rewording a display label can no longer silently corrupt signals.
|
||||||
const yellow = (l: string) => l.startsWith('🟡');
|
private signal(fundamental: ScoreResult, inflated: ScoreResult): Signal {
|
||||||
if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY;
|
if (fundamental.tier === 'PASS') return SIGNAL.STRONG_BUY;
|
||||||
if (green(inflatedLabel) && yellow(fundamentalLabel)) return SIGNAL.MOMENTUM;
|
if (inflated.tier === 'PASS' && fundamental.tier === 'HOLD') return SIGNAL.MOMENTUM;
|
||||||
if (green(inflatedLabel) && !green(fundamentalLabel)) return SIGNAL.SPECULATION;
|
if (inflated.tier === 'PASS') return SIGNAL.SPECULATION;
|
||||||
if (yellow(fundamentalLabel) || yellow(inflatedLabel)) return SIGNAL.NEUTRAL;
|
if (fundamental.tier === 'HOLD' || inflated.tier === 'HOLD') return SIGNAL.NEUTRAL;
|
||||||
return SIGNAL.AVOID;
|
return SIGNAL.AVOID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export class BondScorer {
|
|||||||
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
||||||
return {
|
return {
|
||||||
label: '🔴 REJECT',
|
label: '🔴 REJECT',
|
||||||
|
tier: 'REJECT',
|
||||||
|
score: null,
|
||||||
scoreSummary: `Credit rating gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
|
scoreSummary: `Credit rating gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
|
||||||
audit: {
|
audit: {
|
||||||
passedGates: false,
|
passedGates: false,
|
||||||
@@ -42,6 +44,8 @@ export class BondScorer {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid',
|
label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid',
|
||||||
|
tier: score >= 4 ? 'PASS' : score >= 1 ? 'HOLD' : 'REJECT',
|
||||||
|
score,
|
||||||
scoreSummary: `Score: ${score}`,
|
scoreSummary: `Score: ${score}`,
|
||||||
audit: { passedGates: true, breakdown },
|
audit: { passedGates: true, breakdown },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { EtfMetrics, ScoreResult } from '../../../domains/shared';
|
import type { EtfMetrics, ScoreResult } from '../../../domains/shared';
|
||||||
|
|
||||||
export class EtfScorer {
|
export class EtfScorer {
|
||||||
|
/** Parse to a finite number, preserving null for missing data. */
|
||||||
|
private static n(v: unknown): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
const f = parseFloat(String(v));
|
||||||
|
return Number.isFinite(f) ? f : null;
|
||||||
|
}
|
||||||
|
|
||||||
static score(
|
static score(
|
||||||
m: EtfMetrics,
|
m: EtfMetrics,
|
||||||
rules: {
|
rules: {
|
||||||
@@ -11,51 +18,77 @@ export class EtfScorer {
|
|||||||
): ScoreResult {
|
): ScoreResult {
|
||||||
const { gates, weights, thresholds } = rules;
|
const { gates, weights, thresholds } = rules;
|
||||||
const metrics = {
|
const metrics = {
|
||||||
expenseRatio: parseFloat(String(m.expenseRatio)) || 0,
|
expenseRatio: EtfScorer.n(m.expenseRatio),
|
||||||
yield: parseFloat(String(m.yield)) || 0,
|
yield: EtfScorer.n(m.yield),
|
||||||
volume: parseFloat(String(m.volume)) || 0,
|
volume: EtfScorer.n(m.volume),
|
||||||
fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0,
|
fiveYearReturn: EtfScorer.n(m.fiveYearReturn),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Gates are only checked when the underlying data exists — missing data
|
||||||
|
// skips the gate (same convention as StockScorer) instead of auto-failing.
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
if (metrics.expenseRatio != null && metrics.expenseRatio > gates.maxExpenseRatio) {
|
||||||
failures.push(`Expense ratio: ${metrics.expenseRatio} > ${gates.maxExpenseRatio}`);
|
failures.push(`Expense ratio: ${metrics.expenseRatio} > ${gates.maxExpenseRatio}`);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
metrics.fiveYearReturn != null &&
|
||||||
thresholds.minFiveYearReturn != null &&
|
thresholds.minFiveYearReturn != null &&
|
||||||
metrics.fiveYearReturn < thresholds.minFiveYearReturn
|
metrics.fiveYearReturn < thresholds.minFiveYearReturn
|
||||||
) {
|
) {
|
||||||
failures.push(`5-year return: ${metrics.fiveYearReturn}% < ${thresholds.minFiveYearReturn}%`);
|
failures.push(`5-year return: ${metrics.fiveYearReturn}% < ${thresholds.minFiveYearReturn}%`);
|
||||||
}
|
}
|
||||||
if (thresholds.minVolume != null && metrics.volume < thresholds.minVolume) {
|
if (
|
||||||
|
metrics.volume != null &&
|
||||||
|
thresholds.minVolume != null &&
|
||||||
|
metrics.volume < thresholds.minVolume
|
||||||
|
) {
|
||||||
failures.push(`Volume: ${metrics.volume} < ${thresholds.minVolume}`);
|
failures.push(`Volume: ${metrics.volume} < ${thresholds.minVolume}`);
|
||||||
}
|
}
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
return {
|
return {
|
||||||
label: '🔴 REJECT',
|
label: '🔴 REJECT',
|
||||||
|
tier: 'REJECT',
|
||||||
|
score: null,
|
||||||
scoreSummary: `Gate failed: ${failures.map((f) => f.split(':')[0]).join(', ')}`,
|
scoreSummary: `Gate failed: ${failures.map((f) => f.split(':')[0]).join(', ')}`,
|
||||||
audit: { passedGates: false, failures },
|
audit: { passedGates: false, failures },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const breakdown: Record<string, number> = {
|
// Factors only fire when the underlying data exists.
|
||||||
cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
|
const breakdown: Record<string, number> = {};
|
||||||
yield: metrics.yield >= thresholds.minYield ? weights.yield : -1,
|
if (metrics.expenseRatio != null) {
|
||||||
vol: metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2,
|
breakdown.cost = metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3;
|
||||||
fiveYearReturn:
|
}
|
||||||
thresholds.minFiveYearReturn != null
|
if (metrics.yield != null) {
|
||||||
? metrics.fiveYearReturn >= thresholds.minFiveYearReturn
|
breakdown.yield = metrics.yield >= thresholds.minYield ? weights.yield : -1;
|
||||||
? (weights.fiveYearReturn ?? 1)
|
}
|
||||||
: -1
|
if (metrics.volume != null) {
|
||||||
: 0,
|
breakdown.vol = metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2;
|
||||||
};
|
}
|
||||||
|
if (metrics.fiveYearReturn != null && thresholds.minFiveYearReturn != null) {
|
||||||
|
breakdown.fiveYearReturn =
|
||||||
|
metrics.fiveYearReturn >= thresholds.minFiveYearReturn ? (weights.fiveYearReturn ?? 1) : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeFactors = Object.keys(breakdown).length;
|
||||||
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
if (activeFactors === 0) {
|
||||||
|
return {
|
||||||
|
label: '🟡 Neutral (No Data)',
|
||||||
|
tier: 'HOLD',
|
||||||
|
score: 0,
|
||||||
|
scoreSummary: 'Score: 0 (no metrics available)',
|
||||||
|
audit: { passedGates: true, breakdown, coverage: { active: 0, total: 4 } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: score >= 3 ? '🟢 Efficient' : score >= 0 ? '🟡 Neutral' : '🔴 Expensive/Low Yield',
|
label: score >= 3 ? '🟢 Efficient' : score >= 0 ? '🟡 Neutral' : '🔴 Expensive/Low Yield',
|
||||||
|
tier: score >= 3 ? 'PASS' : score >= 0 ? 'HOLD' : 'REJECT',
|
||||||
|
score,
|
||||||
scoreSummary: `Score: ${score}`,
|
scoreSummary: `Score: ${score}`,
|
||||||
audit: { passedGates: true, breakdown },
|
audit: { passedGates: true, breakdown, coverage: { active: activeFactors, total: 4 } },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import type { NumVal, SanitizedMetrics, ScoreResult, StockMetrics } from '../../../domains/shared';
|
import type { NumVal, SanitizedMetrics, ScoreResult, StockMetrics } from '../../../domains/shared';
|
||||||
|
|
||||||
export class StockScorer {
|
export class StockScorer {
|
||||||
|
/**
|
||||||
|
* Parse to a finite number, preserving 0 — zero is a real value for metrics
|
||||||
|
* like revenueGrowth (stagnant), debtToEquity (debt-free), or
|
||||||
|
* dcfMarginOfSafety (exactly fair value).
|
||||||
|
*/
|
||||||
private static n(v: unknown): NumVal {
|
private static n(v: unknown): NumVal {
|
||||||
|
if (v == null) return null;
|
||||||
const f = parseFloat(String(v));
|
const f = parseFloat(String(v));
|
||||||
return !isNaN(f) && f !== 0 ? f : null;
|
return Number.isFinite(f) ? f : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse to a strictly positive number. Used for ratios where 0 is
|
||||||
|
* impossible and indicates junk/missing data (P/E, PEG, P/B, P/FFO).
|
||||||
|
*/
|
||||||
|
private static pos(v: unknown): NumVal {
|
||||||
|
const f = StockScorer.n(v);
|
||||||
|
return f != null && f > 0 ? f : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static scoreValue(val: number, high: number, med: number, weight: number): number {
|
private static scoreValue(val: number, high: number, med: number, weight: number): number {
|
||||||
@@ -46,6 +61,8 @@ export class StockScorer {
|
|||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
return {
|
return {
|
||||||
label: '🔴 REJECT',
|
label: '🔴 REJECT',
|
||||||
|
tier: 'REJECT',
|
||||||
|
score: null,
|
||||||
scoreSummary: `Gate failed: ${failures.join(' | ')}`,
|
scoreSummary: `Gate failed: ${failures.join(' | ')}`,
|
||||||
audit: { passedGates: false, failures },
|
audit: { passedGates: false, failures },
|
||||||
};
|
};
|
||||||
@@ -172,6 +189,8 @@ export class StockScorer {
|
|||||||
breakdown[f.key] = f.fn() as number;
|
breakdown[f.key] = f.fn() as number;
|
||||||
return sum + breakdown[f.key];
|
return sum + breakdown[f.key];
|
||||||
}, 0);
|
}, 0);
|
||||||
|
const activeFactors = Object.keys(breakdown).length;
|
||||||
|
const coverage = { active: activeFactors, total: factors.length };
|
||||||
|
|
||||||
const riskFlags = [
|
const riskFlags = [
|
||||||
m.beta != null && m.beta > 1.5 && `High volatility (β ${m.beta.toFixed(2)})`,
|
m.beta != null && m.beta > 1.5 && `High volatility (β ${m.beta.toFixed(2)})`,
|
||||||
@@ -207,10 +226,34 @@ export class StockScorer {
|
|||||||
`DCF: stock trading ${Math.abs(m.dcfMarginOfSafety).toFixed(0)}% above intrinsic value`,
|
`DCF: stock trading ${Math.abs(m.dcfMarginOfSafety).toFixed(0)}% above intrinsic value`,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
// No factor had data — distinguish "insufficient data" from a genuine
|
||||||
|
// neutral score so the UI doesn't present an unknown as a Hold verdict.
|
||||||
|
if (activeFactors === 0) {
|
||||||
|
return {
|
||||||
|
label: '🟡 HOLD (No Data)',
|
||||||
|
tier: 'HOLD',
|
||||||
|
score: 0,
|
||||||
|
scoreSummary: 'Score: 0 (no scoring factors had data)',
|
||||||
|
audit: {
|
||||||
|
passedGates: true,
|
||||||
|
breakdown,
|
||||||
|
riskFlags: riskFlags.length ? riskFlags : null,
|
||||||
|
coverage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: StockScorer.label(totalScore),
|
label: StockScorer.label(totalScore),
|
||||||
|
tier: StockScorer.tier(totalScore),
|
||||||
|
score: totalScore,
|
||||||
scoreSummary: `Score: ${totalScore}`,
|
scoreSummary: `Score: ${totalScore}`,
|
||||||
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
|
audit: {
|
||||||
|
passedGates: true,
|
||||||
|
breakdown,
|
||||||
|
riskFlags: riskFlags.length ? riskFlags : null,
|
||||||
|
coverage,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +264,12 @@ export class StockScorer {
|
|||||||
return '🔴 REJECT';
|
return '🔴 REJECT';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static tier(score: number): 'PASS' | 'HOLD' | 'REJECT' {
|
||||||
|
if (score >= 4) return 'PASS';
|
||||||
|
if (score >= 0) return 'HOLD';
|
||||||
|
return 'REJECT';
|
||||||
|
}
|
||||||
|
|
||||||
private static sanitize(m: StockMetrics): SanitizedMetrics {
|
private static sanitize(m: StockMetrics): SanitizedMetrics {
|
||||||
const w52 =
|
const w52 =
|
||||||
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
||||||
@@ -229,16 +278,16 @@ export class StockScorer {
|
|||||||
return {
|
return {
|
||||||
debtToEquity: StockScorer.n(m.debtToEquity),
|
debtToEquity: StockScorer.n(m.debtToEquity),
|
||||||
quickRatio: StockScorer.n(m.quickRatio),
|
quickRatio: StockScorer.n(m.quickRatio),
|
||||||
peRatio: StockScorer.n(m.peRatio),
|
peRatio: StockScorer.pos(m.peRatio),
|
||||||
pegRatio: StockScorer.n(m.pegRatio),
|
pegRatio: StockScorer.pos(m.pegRatio),
|
||||||
priceToBook: StockScorer.n(m.priceToBook),
|
priceToBook: StockScorer.pos(m.priceToBook),
|
||||||
netProfitMargin: StockScorer.n(m.netProfitMargin),
|
netProfitMargin: StockScorer.n(m.netProfitMargin),
|
||||||
operatingMargin: StockScorer.n(m.operatingMargin),
|
operatingMargin: StockScorer.n(m.operatingMargin),
|
||||||
returnOnEquity: StockScorer.n(m.returnOnEquity),
|
returnOnEquity: StockScorer.n(m.returnOnEquity),
|
||||||
revenueGrowth: StockScorer.n(m.revenueGrowth),
|
revenueGrowth: StockScorer.n(m.revenueGrowth),
|
||||||
fcfYield: StockScorer.n(m.fcfYield),
|
fcfYield: StockScorer.n(m.fcfYield),
|
||||||
dividendYield: StockScorer.n(m.dividendYield),
|
dividendYield: StockScorer.n(m.dividendYield),
|
||||||
pFFO: StockScorer.n(m.pFFO),
|
pFFO: StockScorer.pos(m.pFFO),
|
||||||
beta: StockScorer.n(m.beta),
|
beta: StockScorer.n(m.beta),
|
||||||
week52Position: w52,
|
week52Position: w52,
|
||||||
week52Change: StockScorer.n(m.week52Change),
|
week52Change: StockScorer.n(m.week52Change),
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
import { ScreenerEngine } from './ScreenerEngine';
|
import { ScreenerEngine } from './ScreenerEngine';
|
||||||
import { CatalystCache } from '../../domains/shared';
|
import { CatalystCache, SignalSnapshotRepository } from '../../domains/shared';
|
||||||
import type { LiveAssetResult } from '../../domains/shared';
|
import type { LiveAssetResult, ScreenerResult } from '../../domains/shared';
|
||||||
import { screenSchema } from '../../domains/shared/types/schemas';
|
import { screenSchema } from '../../domains/shared/types/schemas';
|
||||||
|
|
||||||
export class ScreenerController {
|
export class ScreenerController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly engine: ScreenerEngine,
|
private readonly engine: ScreenerEngine,
|
||||||
private readonly catalystCache: CatalystCache,
|
private readonly catalystCache: CatalystCache,
|
||||||
|
// Optional so tests and minimal setups work without a database.
|
||||||
|
private readonly snapshots?: SignalSnapshotRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
register(app: FastifyInstance): void {
|
register(app: FastifyInstance): void {
|
||||||
@@ -21,6 +23,29 @@ export class ScreenerController {
|
|||||||
{ config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
{ config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||||||
this.catalysts.bind(this),
|
this.catalysts.bind(this),
|
||||||
);
|
);
|
||||||
|
app.get('/api/screen/history/:ticker', this.history.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signal snapshot history for one ticker (P0.1 ledger read side). */
|
||||||
|
private async history(req: FastifyRequest) {
|
||||||
|
if (!this.snapshots) return { ticker: null, snapshots: [] };
|
||||||
|
const { ticker } = req.params as { ticker: string };
|
||||||
|
return {
|
||||||
|
ticker: ticker.toUpperCase(),
|
||||||
|
snapshots: this.snapshots.history(ticker).map((row) => ({
|
||||||
|
date: row.snapshot_date,
|
||||||
|
signal: row.signal,
|
||||||
|
price: row.price,
|
||||||
|
fundamental: { tier: row.fundamental_tier, score: row.fundamental_score },
|
||||||
|
inflated: { tier: row.inflated_tier, score: row.inflated_score },
|
||||||
|
coverage:
|
||||||
|
row.coverage_active != null
|
||||||
|
? { active: row.coverage_active, total: row.coverage_total }
|
||||||
|
: null,
|
||||||
|
riskFlags: row.risk_flags ? JSON.parse(row.risk_flags) : [],
|
||||||
|
rateRegime: row.rate_regime,
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static serializeAssets(arr: LiveAssetResult[]) {
|
private static serializeAssets(arr: LiveAssetResult[]) {
|
||||||
@@ -39,6 +64,7 @@ export class ScreenerController {
|
|||||||
private async screen(req: FastifyRequest) {
|
private async screen(req: FastifyRequest) {
|
||||||
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
|
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
|
||||||
const results = await this.engine.screenTickers(tickers);
|
const results = await this.engine.screenTickers(tickers);
|
||||||
|
this.recordSnapshots(results, req);
|
||||||
return {
|
return {
|
||||||
...results,
|
...results,
|
||||||
STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]),
|
STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]),
|
||||||
@@ -47,6 +73,29 @@ export class ScreenerController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P0.1 signal track record — persist one snapshot per asset per day.
|
||||||
|
* Best-effort: a snapshot failure must never fail the screen response.
|
||||||
|
*/
|
||||||
|
private recordSnapshots(results: ScreenerResult, req: FastifyRequest): void {
|
||||||
|
if (!this.snapshots) return;
|
||||||
|
try {
|
||||||
|
const rateRegime = results.marketContext?.rateRegime ?? null;
|
||||||
|
const inputs = [...results.STOCK, ...results.ETF, ...results.BOND].map((r) => ({
|
||||||
|
ticker: r.asset.ticker,
|
||||||
|
assetType: r.asset.type,
|
||||||
|
price: r.asset.currentPrice ?? null,
|
||||||
|
signal: r.signal,
|
||||||
|
fundamental: r.fundamental,
|
||||||
|
inflated: r.inflated,
|
||||||
|
rateRegime,
|
||||||
|
}));
|
||||||
|
this.snapshots.recordBatch(inputs);
|
||||||
|
} catch (err) {
|
||||||
|
req.log?.warn?.({ err }, 'signal snapshot recording failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async catalysts() {
|
private async catalysts() {
|
||||||
const { tickers, stories } = await this.catalystCache.get();
|
const { tickers, stories } = await this.catalystCache.get();
|
||||||
return { tickers, stories };
|
return { tickers, stories };
|
||||||
|
|||||||
@@ -7,14 +7,20 @@ export class DataMapper {
|
|||||||
// ── Public entry point ────────────────────────────────────────────────────
|
// ── Public entry point ────────────────────────────────────────────────────
|
||||||
static mapToStandardFormat(ticker: string, summary: YahooSummary): MappedData {
|
static mapToStandardFormat(ticker: string, summary: YahooSummary): MappedData {
|
||||||
const quoteType = summary.price?.quoteType as string | undefined;
|
const quoteType = summary.price?.quoteType as string | undefined;
|
||||||
const category = ((summary.assetProfile?.category as string) || '').toLowerCase();
|
// Prefer fundProfile.categoryName (Morningstar category, e.g. "Intermediate
|
||||||
const yieldVal = (summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0;
|
// Core Bond") — assetProfile.category is rarely populated for ETFs. A
|
||||||
|
// dividend-yield heuristic is deliberately NOT used: high-yield equity ETFs
|
||||||
|
// (SCHD, VYM) are not bonds.
|
||||||
|
const category = (
|
||||||
|
(summary.fundProfile?.categoryName as string) ||
|
||||||
|
(summary.assetProfile?.category as string) ||
|
||||||
|
''
|
||||||
|
).toLowerCase();
|
||||||
|
|
||||||
const isBond =
|
const isBond =
|
||||||
category.includes('bond') ||
|
category.includes('bond') ||
|
||||||
category.includes('fixed income') ||
|
category.includes('fixed income') ||
|
||||||
category.includes('treasury') ||
|
category.includes('treasury');
|
||||||
(quoteType === 'ETF' && yieldVal > 0.02 && category === '');
|
|
||||||
|
|
||||||
if (quoteType === 'ETF') {
|
if (quoteType === 'ETF') {
|
||||||
return isBond
|
return isBond
|
||||||
@@ -143,17 +149,23 @@ export class DataMapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── ETF ───────────────────────────────────────────────────────────────────
|
// ── ETF ───────────────────────────────────────────────────────────────────
|
||||||
|
// Missing fields are preserved as null (not coerced to 0) so EtfScorer can
|
||||||
|
// skip the corresponding gate instead of auto-failing on absent Yahoo data.
|
||||||
private static mapEtfData(summary: YahooSummary) {
|
private static mapEtfData(summary: YahooSummary) {
|
||||||
|
const num = (v: unknown): number | null =>
|
||||||
|
typeof v === 'number' && Number.isFinite(v) ? v : null;
|
||||||
|
|
||||||
|
const expenseRatio = num(summary.summaryDetail?.expenseRatio);
|
||||||
|
const dividendYield = num(summary.summaryDetail?.trailingAnnualDividendYield);
|
||||||
|
const fiveYearReturn = num(summary.defaultKeyStatistics?.fiveYearAverageReturn);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
expenseRatio: ((summary.summaryDetail?.expenseRatio as number) ?? 0) * 100,
|
expenseRatio: expenseRatio != null ? expenseRatio * 100 : null,
|
||||||
totalAssets: (summary.summaryDetail?.totalAssets as number) ?? 0,
|
totalAssets: num(summary.summaryDetail?.totalAssets),
|
||||||
yield: ((summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0) * 100,
|
yield: dividendYield != null ? dividendYield * 100 : null,
|
||||||
fiveYearReturn: ((summary.defaultKeyStatistics?.fiveYearAverageReturn as number) ?? 0) * 100,
|
fiveYearReturn: fiveYearReturn != null ? fiveYearReturn * 100 : null,
|
||||||
volume:
|
volume: num(summary.summaryDetail?.averageVolume) ?? num(summary.price?.averageVolume),
|
||||||
(summary.summaryDetail?.averageVolume as number) ??
|
currentPrice: num(summary.price?.regularMarketPrice) ?? 0,
|
||||||
(summary.price?.averageVolume as number) ??
|
|
||||||
0,
|
|
||||||
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const YAHOO_MODULES: string[] = [
|
|||||||
'defaultKeyStatistics',
|
'defaultKeyStatistics',
|
||||||
'price',
|
'price',
|
||||||
'summaryDetail',
|
'summaryDetail',
|
||||||
|
'fundProfile', // categoryName drives ETF vs bond-fund classification in DataMapper
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SIGNAL_ORDER: Record<string, number> = {
|
export const SIGNAL_ORDER: Record<string, number> = {
|
||||||
|
|||||||
@@ -139,6 +139,15 @@ export class DatabaseConnection {
|
|||||||
return txn();
|
return txn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a raw SQL SELECT and return all rows.
|
||||||
|
* Use only when QueryBuilder is not practical (e.g. static named queries).
|
||||||
|
*/
|
||||||
|
rawAll<T = Record<string, unknown>>(sql: string, params: unknown[] = []): T[] {
|
||||||
|
const stmt = this.getOrCacheStatement(sql);
|
||||||
|
return stmt.all(...params) as T[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a raw SQL SELECT and return the first row.
|
* Execute a raw SQL SELECT and return the first row.
|
||||||
* Use only when QueryBuilder is not practical (e.g. auth domain with static queries).
|
* Use only when QueryBuilder is not practical (e.g. auth domain with static queries).
|
||||||
|
|||||||
@@ -130,6 +130,82 @@ export const RESET_TOKEN_QUERIES = {
|
|||||||
|
|
||||||
// ── Schema Definition (DDL) ──────────────────────────────────────────────────
|
// ── Schema Definition (DDL) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Watchlist Queries ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const WATCHLIST_QUERIES = {
|
||||||
|
SELECT_ALL: `
|
||||||
|
SELECT ticker, pinned_at
|
||||||
|
FROM watchlist
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY pinned_at DESC
|
||||||
|
`,
|
||||||
|
INSERT: `
|
||||||
|
INSERT OR IGNORE INTO watchlist (ticker, user_id, pinned_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`,
|
||||||
|
DELETE: `
|
||||||
|
DELETE FROM watchlist WHERE ticker = ? AND user_id = ?
|
||||||
|
`,
|
||||||
|
EXISTS: `
|
||||||
|
SELECT 1 FROM watchlist WHERE ticker = ? AND user_id = ?
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Signal Snapshot Queries (P0.1 — signal track record) ────────────────────
|
||||||
|
|
||||||
|
export const SIGNAL_SNAPSHOT_QUERIES = {
|
||||||
|
// One row per ticker per day — repeated screens the same day keep the latest
|
||||||
|
UPSERT: `
|
||||||
|
INSERT INTO signal_snapshots (
|
||||||
|
ticker, snapshot_date, asset_type, price, signal,
|
||||||
|
fundamental_tier, fundamental_score, fundamental_label,
|
||||||
|
inflated_tier, inflated_score, inflated_label,
|
||||||
|
coverage_active, coverage_total, risk_flags, rate_regime, created_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(ticker, snapshot_date) DO UPDATE SET
|
||||||
|
asset_type = excluded.asset_type,
|
||||||
|
price = excluded.price,
|
||||||
|
signal = excluded.signal,
|
||||||
|
fundamental_tier = excluded.fundamental_tier,
|
||||||
|
fundamental_score = excluded.fundamental_score,
|
||||||
|
fundamental_label = excluded.fundamental_label,
|
||||||
|
inflated_tier = excluded.inflated_tier,
|
||||||
|
inflated_score = excluded.inflated_score,
|
||||||
|
inflated_label = excluded.inflated_label,
|
||||||
|
coverage_active = excluded.coverage_active,
|
||||||
|
coverage_total = excluded.coverage_total,
|
||||||
|
risk_flags = excluded.risk_flags,
|
||||||
|
rate_regime = excluded.rate_regime,
|
||||||
|
created_at = excluded.created_at
|
||||||
|
`,
|
||||||
|
|
||||||
|
// Full history for one ticker, oldest first (for trend/backtest views)
|
||||||
|
SELECT_BY_TICKER: `
|
||||||
|
SELECT * FROM signal_snapshots
|
||||||
|
WHERE ticker = ?
|
||||||
|
ORDER BY snapshot_date ASC
|
||||||
|
`,
|
||||||
|
|
||||||
|
// All snapshots for one day (for daily diff jobs)
|
||||||
|
SELECT_BY_DATE: `
|
||||||
|
SELECT * FROM signal_snapshots
|
||||||
|
WHERE snapshot_date = ?
|
||||||
|
ORDER BY ticker ASC
|
||||||
|
`,
|
||||||
|
|
||||||
|
// Latest snapshot per ticker on or before a given date (for change detection)
|
||||||
|
SELECT_LATEST_BEFORE: `
|
||||||
|
SELECT s.* FROM signal_snapshots s
|
||||||
|
JOIN (
|
||||||
|
SELECT ticker, MAX(snapshot_date) AS d
|
||||||
|
FROM signal_snapshots
|
||||||
|
WHERE snapshot_date < ?
|
||||||
|
GROUP BY ticker
|
||||||
|
) latest ON latest.ticker = s.ticker AND latest.d = s.snapshot_date
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
export const DDL = `
|
export const DDL = `
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -167,6 +243,36 @@ export const DDL = `
|
|||||||
snapshot TEXT NOT NULL, -- JSON object
|
snapshot TEXT NOT NULL, -- JSON object
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS watchlist (
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
pinned_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (ticker, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS signal_snapshots (
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
snapshot_date TEXT NOT NULL, -- YYYY-MM-DD
|
||||||
|
asset_type TEXT NOT NULL, -- STOCK / ETF / BOND
|
||||||
|
price REAL,
|
||||||
|
signal TEXT NOT NULL, -- ✅ Strong Buy etc.
|
||||||
|
fundamental_tier TEXT NOT NULL, -- PASS / HOLD / REJECT
|
||||||
|
fundamental_score REAL,
|
||||||
|
fundamental_label TEXT,
|
||||||
|
inflated_tier TEXT NOT NULL,
|
||||||
|
inflated_score REAL,
|
||||||
|
inflated_label TEXT,
|
||||||
|
coverage_active INTEGER,
|
||||||
|
coverage_total INTEGER,
|
||||||
|
risk_flags TEXT, -- JSON array
|
||||||
|
rate_regime TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (ticker, snapshot_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON signal_snapshots(snapshot_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_snapshots_signal ON signal_snapshots(signal, snapshot_date);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ── Runtime migrations (ALTER TABLE for existing DBs) ────────────────────────
|
// ── Runtime migrations (ALTER TABLE for existing DBs) ────────────────────────
|
||||||
|
|||||||
@@ -6,24 +6,34 @@ export class Etf extends Asset {
|
|||||||
|
|
||||||
constructor(data: EtfData) {
|
constructor(data: EtfData) {
|
||||||
super(data);
|
super(data);
|
||||||
|
// Preserve null for missing fields — coercing to 0 would auto-fail gates
|
||||||
|
// in EtfScorer for data Yahoo simply didn't return.
|
||||||
|
const num = (v: unknown): number | null => {
|
||||||
|
if (v == null) return null;
|
||||||
|
const f = parseFloat(String(v));
|
||||||
|
return Number.isFinite(f) ? f : null;
|
||||||
|
};
|
||||||
this.metrics = {
|
this.metrics = {
|
||||||
expenseRatio: parseFloat(String(data.expenseRatio)) || 0,
|
expenseRatio: num(data.expenseRatio),
|
||||||
totalAssets: parseFloat(String(data.totalAssets)) || 0,
|
totalAssets: num(data.totalAssets),
|
||||||
yield: parseFloat(String(data.yield)) || 0,
|
yield: num(data.yield),
|
||||||
volume: parseFloat(String(data.volume)) || 0,
|
volume: num(data.volume),
|
||||||
fiveYearReturn: parseFloat(String(data.fiveYearReturn)) || 0,
|
fiveYearReturn: num(data.fiveYearReturn),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getDisplayMetrics(): Record<string, string> {
|
getDisplayMetrics(): Record<string, string> {
|
||||||
|
const m = this.metrics;
|
||||||
|
const fmt = (v: number | null, dec: number, suffix = '') =>
|
||||||
|
v != null ? `${v.toFixed(dec)}${suffix}` : '—';
|
||||||
return {
|
return {
|
||||||
Ticker: this.ticker,
|
Ticker: this.ticker,
|
||||||
Type: 'ETF',
|
Type: 'ETF',
|
||||||
Price: this.formatCurrency(this.currentPrice),
|
Price: this.formatCurrency(this.currentPrice),
|
||||||
'Exp Ratio%': `${this.metrics.expenseRatio.toFixed(2)}%`,
|
'Exp Ratio%': fmt(m.expenseRatio, 2, '%'),
|
||||||
'Yield%': `${this.metrics.yield.toFixed(2)}%`,
|
'Yield%': fmt(m.yield, 2, '%'),
|
||||||
AUM: this.formatLargeNumber(this.metrics.totalAssets),
|
AUM: m.totalAssets != null ? this.formatLargeNumber(m.totalAssets) : '—',
|
||||||
'5Y Return%': `${this.metrics.fiveYearReturn.toFixed(1)}%`,
|
'5Y Return%': fmt(m.fiveYearReturn, 1, '%'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export { MarketRegime } from './scoring/MarketRegime';
|
|||||||
// Persistence (repositories)
|
// Persistence (repositories)
|
||||||
export { MarketCallRepository } from './persistence/MarketCallRepository';
|
export { MarketCallRepository } from './persistence/MarketCallRepository';
|
||||||
export { PortfolioRepository } from './persistence/PortfolioRepository';
|
export { PortfolioRepository } from './persistence/PortfolioRepository';
|
||||||
|
export { SignalSnapshotRepository } from './persistence/SignalSnapshotRepository';
|
||||||
|
export type { SnapshotInput } from './persistence/SignalSnapshotRepository';
|
||||||
export { DatabaseConnection, QueryAudit, createDb } from './db/index';
|
export { DatabaseConnection, QueryAudit, createDb } from './db/index';
|
||||||
|
|
||||||
// Config & Constants
|
// Config & Constants
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { DatabaseConnection } from '../db/index';
|
||||||
|
import { QueryBuilder } from '../utils/QueryBuilder';
|
||||||
|
import type { ScoreResult, SignalSnapshotRow } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signal snapshot ledger (PRODUCT.md P0.1).
|
||||||
|
*
|
||||||
|
* Persists one row per ticker per day on every /api/screen call so the
|
||||||
|
* product builds a verifiable signal track record. This data cannot be
|
||||||
|
* backfilled — the backtest dashboard (Phase 10.5e), thesis review (10.6d),
|
||||||
|
* and calibration features all depend on it accumulating from day one.
|
||||||
|
*
|
||||||
|
* Recording is best-effort: failures are logged by the caller and must never
|
||||||
|
* fail the screen request itself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SnapshotInput {
|
||||||
|
ticker: string;
|
||||||
|
assetType: string;
|
||||||
|
price: number | null;
|
||||||
|
signal: string;
|
||||||
|
fundamental: ScoreResult;
|
||||||
|
inflated: ScoreResult;
|
||||||
|
rateRegime?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SignalSnapshotRepository {
|
||||||
|
constructor(private readonly db: DatabaseConnection) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert today's snapshot for a batch of screened assets.
|
||||||
|
* Repeated screens on the same day keep the latest result.
|
||||||
|
*/
|
||||||
|
recordBatch(inputs: SnapshotInput[], date = SignalSnapshotRepository.today()): number {
|
||||||
|
let written = 0;
|
||||||
|
for (const input of inputs) {
|
||||||
|
this.record(input, date);
|
||||||
|
written++;
|
||||||
|
}
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
|
record(input: SnapshotInput, date = SignalSnapshotRepository.today()): void {
|
||||||
|
const { ticker, assetType, price, signal, fundamental, inflated, rateRegime } = input;
|
||||||
|
const coverage = fundamental.audit?.coverage ?? inflated.audit?.coverage ?? null;
|
||||||
|
const riskFlags = fundamental.audit?.riskFlags ?? inflated.audit?.riskFlags ?? null;
|
||||||
|
|
||||||
|
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.UPSERT', [
|
||||||
|
ticker.toUpperCase(),
|
||||||
|
date,
|
||||||
|
assetType,
|
||||||
|
price,
|
||||||
|
signal,
|
||||||
|
fundamental.tier,
|
||||||
|
fundamental.score,
|
||||||
|
fundamental.label,
|
||||||
|
inflated.tier,
|
||||||
|
inflated.score,
|
||||||
|
inflated.label,
|
||||||
|
coverage?.active ?? null,
|
||||||
|
coverage?.total ?? null,
|
||||||
|
riskFlags ? JSON.stringify(riskFlags) : null,
|
||||||
|
rateRegime ?? null,
|
||||||
|
new Date().toISOString(),
|
||||||
|
]);
|
||||||
|
this.db.run(qb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full history for one ticker, oldest first. */
|
||||||
|
history(ticker: string): SignalSnapshotRow[] {
|
||||||
|
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.SELECT_BY_TICKER', [ticker.toUpperCase()]);
|
||||||
|
return this.db.all<SignalSnapshotRow>(qb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All snapshots for a given day (YYYY-MM-DD). */
|
||||||
|
byDate(date: string): SignalSnapshotRow[] {
|
||||||
|
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.SELECT_BY_DATE', [date]);
|
||||||
|
return this.db.all<SignalSnapshotRow>(qb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest snapshot per ticker strictly before a date — for daily diffing. */
|
||||||
|
latestBefore(date: string): SignalSnapshotRow[] {
|
||||||
|
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.SELECT_LATEST_BEFORE', [date]);
|
||||||
|
return this.db.all<SignalSnapshotRow>(qb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static today(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,19 +12,52 @@ export class BenchmarkProvider {
|
|||||||
private static readonly TTL_MS = 60 * 60 * 1000;
|
private static readonly TTL_MS = 60 * 60 * 1000;
|
||||||
private static readonly CACHE_PATH = '.benchmark-cache.json';
|
private static readonly CACHE_PATH = '.benchmark-cache.json';
|
||||||
|
|
||||||
|
// NOTE: regimes must stay consistent with rateRegime()/volRegime() below —
|
||||||
|
// 4.5% ⇒ NORMAL (2–5%), VIX 20 ⇒ NORMAL (15–25).
|
||||||
private static readonly DEFAULTS: MarketContext = {
|
private static readonly DEFAULTS: MarketContext = {
|
||||||
sp500Price: 5000,
|
sp500Price: 5000,
|
||||||
riskFreeRate: 4.5,
|
riskFreeRate: 4.5,
|
||||||
vixLevel: 20,
|
vixLevel: 20,
|
||||||
rateRegime: 'HIGH',
|
rateRegime: 'NORMAL',
|
||||||
volatilityRegime: 'NORMAL',
|
volatilityRegime: 'NORMAL',
|
||||||
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
|
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Hysteresis band: the 10Y must cross a regime boundary by this much to flip. */
|
||||||
|
private static readonly REGIME_HYSTERESIS = 0.25;
|
||||||
|
|
||||||
private static rateRegime(rate: number): MarketContext['rateRegime'] {
|
private static rateRegime(rate: number): MarketContext['rateRegime'] {
|
||||||
return rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH;
|
return rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate regime with hysteresis (PRODUCT.md P0.5).
|
||||||
|
*
|
||||||
|
* The raw thresholds (2% / 5%) flip the INFLATED scoring gates between
|
||||||
|
* back-to-back requests when the 10Y hovers near a boundary. With a known
|
||||||
|
* previous regime, the rate must cross the boundary by ±0.25% before the
|
||||||
|
* regime switches. A two-step jump (LOW→HIGH) applies immediately.
|
||||||
|
* Public static for direct unit testing.
|
||||||
|
*/
|
||||||
|
static resolveRateRegime(
|
||||||
|
rate: number,
|
||||||
|
previous: MarketContext['rateRegime'] | null,
|
||||||
|
): MarketContext['rateRegime'] {
|
||||||
|
const raw = BenchmarkProvider.rateRegime(rate);
|
||||||
|
if (!previous || raw === previous) return raw;
|
||||||
|
|
||||||
|
const h = BenchmarkProvider.REGIME_HYSTERESIS;
|
||||||
|
if (previous === REGIME.NORMAL && raw === REGIME.HIGH)
|
||||||
|
return rate > 5 + h ? REGIME.HIGH : REGIME.NORMAL;
|
||||||
|
if (previous === REGIME.HIGH && raw === REGIME.NORMAL)
|
||||||
|
return rate < 5 - h ? REGIME.NORMAL : REGIME.HIGH;
|
||||||
|
if (previous === REGIME.NORMAL && raw === REGIME.LOW)
|
||||||
|
return rate < 2 - h ? REGIME.LOW : REGIME.NORMAL;
|
||||||
|
if (previous === REGIME.LOW && raw === REGIME.NORMAL)
|
||||||
|
return rate > 2 + h ? REGIME.NORMAL : REGIME.LOW;
|
||||||
|
return raw; // LOW↔HIGH double jump — no damping
|
||||||
|
}
|
||||||
|
|
||||||
private static volRegime(vix: number): MarketContext['volatilityRegime'] {
|
private static volRegime(vix: number): MarketContext['volatilityRegime'] {
|
||||||
return vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH;
|
return vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH;
|
||||||
}
|
}
|
||||||
@@ -34,6 +67,8 @@ export class BenchmarkProvider {
|
|||||||
}
|
}
|
||||||
private cache: { data: MarketContext | null; expiresAt: number };
|
private cache: { data: MarketContext | null; expiresAt: number };
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
/** Last known rate regime — survives cache expiry so hysteresis has memory. */
|
||||||
|
private lastRegime: MarketContext['rateRegime'] | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly client: YahooFinanceClient,
|
private readonly client: YahooFinanceClient,
|
||||||
@@ -47,6 +82,8 @@ export class BenchmarkProvider {
|
|||||||
try {
|
try {
|
||||||
if (!existsSync(BenchmarkProvider.CACHE_PATH)) return { data: null, expiresAt: 0 };
|
if (!existsSync(BenchmarkProvider.CACHE_PATH)) return { data: null, expiresAt: 0 };
|
||||||
const file = JSON.parse(readFileSync(BenchmarkProvider.CACHE_PATH, 'utf8')) as CacheFile;
|
const file = JSON.parse(readFileSync(BenchmarkProvider.CACHE_PATH, 'utf8')) as CacheFile;
|
||||||
|
// Even an expired cache remembers the previous regime for hysteresis
|
||||||
|
this.lastRegime = file.data?.rateRegime ?? null;
|
||||||
if (Date.now() < file.expiresAt) return { data: file.data, expiresAt: file.expiresAt };
|
if (Date.now() < file.expiresAt) return { data: file.data, expiresAt: file.expiresAt };
|
||||||
} catch {
|
} catch {
|
||||||
// corrupt or missing — ignore
|
// corrupt or missing — ignore
|
||||||
@@ -95,7 +132,7 @@ export class BenchmarkProvider {
|
|||||||
sp500Price,
|
sp500Price,
|
||||||
riskFreeRate,
|
riskFreeRate,
|
||||||
vixLevel,
|
vixLevel,
|
||||||
rateRegime: BenchmarkProvider.rateRegime(riskFreeRate),
|
rateRegime: BenchmarkProvider.resolveRateRegime(riskFreeRate, this.lastRegime),
|
||||||
volatilityRegime: BenchmarkProvider.volRegime(vixLevel),
|
volatilityRegime: BenchmarkProvider.volRegime(vixLevel),
|
||||||
benchmarks: {
|
benchmarks: {
|
||||||
marketPE: BenchmarkProvider.pe(spy) ?? 22,
|
marketPE: BenchmarkProvider.pe(spy) ?? 22,
|
||||||
@@ -107,6 +144,7 @@ export class BenchmarkProvider {
|
|||||||
|
|
||||||
const expiresAt = Date.now() + BenchmarkProvider.TTL_MS;
|
const expiresAt = Date.now() + BenchmarkProvider.TTL_MS;
|
||||||
this.cache = { data: context, expiresAt };
|
this.cache = { data: context, expiresAt };
|
||||||
|
this.lastRegime = context.rateRegime;
|
||||||
this.saveDiskCache(context, expiresAt);
|
this.saveDiskCache(context, expiresAt);
|
||||||
return context;
|
return context;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -45,12 +45,25 @@ export interface ScoreAudit {
|
|||||||
breakdown?: Record<string, number>;
|
breakdown?: Record<string, number>;
|
||||||
riskFlags?: string[] | null;
|
riskFlags?: string[] | null;
|
||||||
failures?: string[];
|
failures?: string[];
|
||||||
|
/** Data coverage: how many scoring factors had data vs. were defined. */
|
||||||
|
coverage?: { active: number; total: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured verdict tier — the machine-readable counterpart of `label`.
|
||||||
|
* Signal derivation and persistence MUST use this, never the label string.
|
||||||
|
* PASS = green (buy-quality), HOLD = yellow (neutral), REJECT = red (gate fail / negative).
|
||||||
|
*/
|
||||||
|
export type VerdictTier = 'PASS' | 'HOLD' | 'REJECT';
|
||||||
|
|
||||||
export interface ScoreResult {
|
export interface ScoreResult {
|
||||||
label: string;
|
label: string;
|
||||||
scoreSummary: string;
|
scoreSummary: string;
|
||||||
audit: ScoreAudit;
|
audit: ScoreAudit;
|
||||||
|
/** Machine-readable verdict tier. Use this for signal logic, not the label. */
|
||||||
|
tier: VerdictTier;
|
||||||
|
/** Numeric factor score. Null when gates failed (no score computed). */
|
||||||
|
score: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AssetResult with runtime methods still attached — used at the HTTP boundary
|
// AssetResult with runtime methods still attached — used at the HTTP boundary
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ export type {
|
|||||||
BondData,
|
BondData,
|
||||||
BondMetrics,
|
BondMetrics,
|
||||||
} from './models.model';
|
} from './models.model';
|
||||||
export type { StoreData, PortfolioData, MarketCallRow, HoldingRow } from './repositories.model';
|
export type {
|
||||||
|
StoreData,
|
||||||
|
PortfolioData,
|
||||||
|
MarketCallRow,
|
||||||
|
HoldingRow,
|
||||||
|
SignalSnapshotRow,
|
||||||
|
} from './repositories.model';
|
||||||
export type { NumVal, SanitizedMetrics, SanitizedBondMetrics } from './scorers.model';
|
export type { NumVal, SanitizedMetrics, SanitizedBondMetrics } from './scorers.model';
|
||||||
export type {
|
export type {
|
||||||
BenchmarkProviderOptions,
|
BenchmarkProviderOptions,
|
||||||
|
|||||||
@@ -86,20 +86,22 @@ export interface StockMetrics {
|
|||||||
export interface EtfData {
|
export interface EtfData {
|
||||||
ticker?: string;
|
ticker?: string;
|
||||||
currentPrice?: number;
|
currentPrice?: number;
|
||||||
expenseRatio?: string | number;
|
expenseRatio?: string | number | null;
|
||||||
totalAssets?: string | number;
|
totalAssets?: string | number | null;
|
||||||
yield?: string | number;
|
yield?: string | number | null;
|
||||||
volume?: string | number;
|
volume?: string | number | null;
|
||||||
fiveYearReturn?: string | number;
|
fiveYearReturn?: string | number | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Missing Yahoo data is preserved as null so EtfScorer skips the
|
||||||
|
// corresponding gate instead of auto-failing on a coerced 0.
|
||||||
export interface EtfMetrics {
|
export interface EtfMetrics {
|
||||||
expenseRatio: number;
|
expenseRatio: number | null;
|
||||||
totalAssets: number;
|
totalAssets: number | null;
|
||||||
yield: number;
|
yield: number | null;
|
||||||
volume: number;
|
volume: number | null;
|
||||||
fiveYearReturn: number;
|
fiveYearReturn: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bond ───────────────────────────────────────────────────────────────────
|
// ── Bond ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -37,6 +37,28 @@ export interface HoldingRow {
|
|||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw database row from signal_snapshots table (P0.1 signal track record).
|
||||||
|
*/
|
||||||
|
export interface SignalSnapshotRow {
|
||||||
|
ticker: string;
|
||||||
|
snapshot_date: string;
|
||||||
|
asset_type: string;
|
||||||
|
price: number | null;
|
||||||
|
signal: string;
|
||||||
|
fundamental_tier: string;
|
||||||
|
fundamental_score: number | null;
|
||||||
|
fundamental_label: string | null;
|
||||||
|
inflated_tier: string;
|
||||||
|
inflated_score: number | null;
|
||||||
|
inflated_label: string | null;
|
||||||
|
coverage_active: number | null;
|
||||||
|
coverage_total: number | null;
|
||||||
|
risk_flags: string | null; // JSON array stringified
|
||||||
|
rate_regime: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Persistence Shapes (returned by repositories) ───────────────────────────
|
// ── Persistence Shapes (returned by repositories) ───────────────────────────
|
||||||
|
|
||||||
export interface StoreData {
|
export interface StoreData {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { DatabaseConnection } from '../shared/db/index.js';
|
||||||
|
import { WATCHLIST_QUERIES } from '../shared/db/queries.constant.js';
|
||||||
|
|
||||||
|
export interface WatchlistEntry {
|
||||||
|
ticker: string;
|
||||||
|
pinnedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WatchlistRepository {
|
||||||
|
constructor(private readonly db: DatabaseConnection) {}
|
||||||
|
|
||||||
|
list(userId: string): WatchlistEntry[] {
|
||||||
|
const rows = this.db.rawAll<{ ticker: string; pinned_at: string }>(
|
||||||
|
WATCHLIST_QUERIES.SELECT_ALL,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => ({ ticker: r.ticker, pinnedAt: r.pinned_at }));
|
||||||
|
}
|
||||||
|
|
||||||
|
add(ticker: string, userId: string): void {
|
||||||
|
this.db.rawRun(WATCHLIST_QUERIES.INSERT, [
|
||||||
|
ticker.toUpperCase(),
|
||||||
|
userId,
|
||||||
|
new Date().toISOString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(ticker: string, userId: string): void {
|
||||||
|
this.db.rawRun(WATCHLIST_QUERIES.DELETE, [ticker.toUpperCase(), userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
has(ticker: string, userId: string): boolean {
|
||||||
|
return !!this.db.rawGet(WATCHLIST_QUERIES.EXISTS, [ticker.toUpperCase(), userId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { WatchlistController } from './watchlist.controller.js';
|
||||||
|
export { WatchlistRepository } from './WatchlistRepository.js';
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify';
|
||||||
|
import type { TokenPayload } from '../auth/index.js';
|
||||||
|
import { WatchlistRepository } from './WatchlistRepository.js';
|
||||||
|
|
||||||
|
type AuthedRequest = FastifyRequest & { user: TokenPayload };
|
||||||
|
|
||||||
|
interface WatchlistControllerOptions {
|
||||||
|
authGuard: preHandlerHookHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WatchlistController {
|
||||||
|
readonly #guards: preHandlerHookHandler[];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly repo: WatchlistRepository,
|
||||||
|
options: WatchlistControllerOptions,
|
||||||
|
) {
|
||||||
|
this.#guards = [options.authGuard];
|
||||||
|
}
|
||||||
|
|
||||||
|
register(app: FastifyInstance): void {
|
||||||
|
const g = { preHandler: this.#guards };
|
||||||
|
app.get('/api/watchlist', g, this.list.bind(this));
|
||||||
|
app.post('/api/watchlist/:ticker', g, this.add.bind(this));
|
||||||
|
app.delete('/api/watchlist/:ticker', g, this.remove.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private list(req: FastifyRequest): {
|
||||||
|
tickers: string[];
|
||||||
|
entries: { ticker: string; pinnedAt: string }[];
|
||||||
|
} {
|
||||||
|
const userId = (req as AuthedRequest).user.sub;
|
||||||
|
const entries = this.repo.list(userId);
|
||||||
|
return { tickers: entries.map((e) => e.ticker), entries };
|
||||||
|
}
|
||||||
|
|
||||||
|
private add(req: FastifyRequest, reply: FastifyReply): { ok: boolean } | FastifyReply {
|
||||||
|
const userId = (req as AuthedRequest).user.sub;
|
||||||
|
const ticker = (req.params as { ticker: string }).ticker?.toUpperCase();
|
||||||
|
if (!ticker || !/^[A-Z0-9.-]{1,12}$/.test(ticker)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid ticker' });
|
||||||
|
}
|
||||||
|
this.repo.add(ticker, userId);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private remove(req: FastifyRequest): { ok: boolean } {
|
||||||
|
const userId = (req as AuthedRequest).user.sub;
|
||||||
|
const ticker = (req.params as { ticker: string }).ticker?.toUpperCase();
|
||||||
|
this.repo.remove(ticker, userId);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { BenchmarkProvider } from '../server/domains/shared/services/BenchmarkProvider.js';
|
||||||
|
|
||||||
|
// P0.5 — rate-regime hysteresis: the 10Y must cross a boundary by ±0.25%
|
||||||
|
// before the regime flips, so a rate hovering at the threshold can't toggle
|
||||||
|
// INFLATED gates between back-to-back requests.
|
||||||
|
test('BenchmarkProvider.resolveRateRegime', async (t) => {
|
||||||
|
await t.test('no previous regime → raw thresholds apply', () => {
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(1.5, null), 'LOW');
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(4.5, null), 'NORMAL');
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(5.1, null), 'HIGH');
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('NORMAL holds until 10Y clears 5.25%', () => {
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(5.1, 'NORMAL'), 'NORMAL'); // damped
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(5.25, 'NORMAL'), 'NORMAL'); // boundary
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(5.3, 'NORMAL'), 'HIGH'); // crossed
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('HIGH holds until 10Y drops below 4.75%', () => {
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(4.9, 'HIGH'), 'HIGH'); // damped
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(4.75, 'HIGH'), 'HIGH'); // boundary
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(4.7, 'HIGH'), 'NORMAL'); // crossed
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('LOW/NORMAL boundary at 2% gets the same damping', () => {
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(1.9, 'NORMAL'), 'NORMAL'); // damped
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(1.7, 'NORMAL'), 'LOW'); // crossed
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(2.1, 'LOW'), 'LOW'); // damped
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(2.3, 'LOW'), 'NORMAL'); // crossed
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('no change when raw regime equals previous', () => {
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(4.5, 'NORMAL'), 'NORMAL');
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(6.0, 'HIGH'), 'HIGH');
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('double jump (LOW→HIGH) is not damped', () => {
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(5.4, 'LOW'), 'HIGH');
|
||||||
|
assert.equal(BenchmarkProvider.resolveRateRegime(1.2, 'HIGH'), 'LOW');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -255,6 +255,49 @@ test('EtfScorer', async (t) => {
|
|||||||
assert.equal(result.label, '🔴 REJECT');
|
assert.equal(result.label, '🔴 REJECT');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await t.test('does not reject ETF when Yahoo data is missing (null)', () => {
|
||||||
|
const metrics: EtfMetrics = {
|
||||||
|
expenseRatio: 0.05,
|
||||||
|
yield: 1.8,
|
||||||
|
volume: null, // Yahoo did not return averageVolume
|
||||||
|
fiveYearReturn: null, // Yahoo did not return fiveYearAverageReturn
|
||||||
|
totalAssets: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
||||||
|
// Missing data skips gates — must NOT auto-fail as 0 < gate
|
||||||
|
assert.notEqual(result.label, '🔴 REJECT');
|
||||||
|
assert.ok(result.audit?.passedGates);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('still enforces expense gate when other data is missing', () => {
|
||||||
|
const metrics: EtfMetrics = {
|
||||||
|
expenseRatio: 0.8, // above 0.2 gate
|
||||||
|
yield: null,
|
||||||
|
volume: null,
|
||||||
|
fiveYearReturn: null,
|
||||||
|
totalAssets: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
||||||
|
assert.equal(result.label, '🔴 REJECT');
|
||||||
|
assert.ok(result.scoreSummary.includes('Expense ratio'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('labels all-null metrics as No Data instead of Neutral', () => {
|
||||||
|
const metrics: EtfMetrics = {
|
||||||
|
expenseRatio: null,
|
||||||
|
yield: null,
|
||||||
|
volume: null,
|
||||||
|
fiveYearReturn: null,
|
||||||
|
totalAssets: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
||||||
|
assert.equal(result.label, '🟡 Neutral (No Data)');
|
||||||
|
assert.equal(result.audit?.coverage?.active, 0);
|
||||||
|
});
|
||||||
|
|
||||||
await t.test('handles negative 5-year return', () => {
|
await t.test('handles negative 5-year return', () => {
|
||||||
const metrics: EtfMetrics = {
|
const metrics: EtfMetrics = {
|
||||||
expenseRatio: 0.1,
|
expenseRatio: 0.1,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { SignalSnapshotRepository } from '../server/domains/shared/persistence/SignalSnapshotRepository.js';
|
||||||
|
import { MockDatabaseConnection } from './helpers/mockDb.js';
|
||||||
|
import type { DatabaseConnection } from '../server/domains/shared/db/index.js';
|
||||||
|
import type { ScoreResult } from '../server/domains/shared/types/index.js';
|
||||||
|
|
||||||
|
const passResult: ScoreResult = {
|
||||||
|
label: '🟢 BUY (High Conviction)',
|
||||||
|
tier: 'PASS',
|
||||||
|
score: 9,
|
||||||
|
scoreSummary: 'Score: 9',
|
||||||
|
audit: { passedGates: true, breakdown: { roe: 3 }, coverage: { active: 6, total: 11 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectResult: ScoreResult = {
|
||||||
|
label: '🔴 REJECT',
|
||||||
|
tier: 'REJECT',
|
||||||
|
score: null,
|
||||||
|
scoreSummary: 'Gate failed: P/E 40 > 15',
|
||||||
|
audit: { passedGates: false, failures: ['P/E 40 > 15'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
function repo(): SignalSnapshotRepository {
|
||||||
|
return new SignalSnapshotRepository(
|
||||||
|
new MockDatabaseConnection() as unknown as DatabaseConnection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('SignalSnapshotRepository', async (t) => {
|
||||||
|
await t.test('record() builds a valid UPSERT (16 params, no throw)', () => {
|
||||||
|
// QueryBuilder validates placeholder count — a param mismatch throws here.
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
repo().record({
|
||||||
|
ticker: 'aapl',
|
||||||
|
assetType: 'STOCK',
|
||||||
|
price: 189.5,
|
||||||
|
signal: '✅ Strong Buy',
|
||||||
|
fundamental: passResult,
|
||||||
|
inflated: passResult,
|
||||||
|
rateRegime: 'NORMAL',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('record() tolerates gate-failed results (null score)', () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
repo().record({
|
||||||
|
ticker: 'XYZ',
|
||||||
|
assetType: 'STOCK',
|
||||||
|
price: null,
|
||||||
|
signal: '❌ Avoid',
|
||||||
|
fundamental: rejectResult,
|
||||||
|
inflated: rejectResult,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('recordBatch() returns count written', () => {
|
||||||
|
const n = repo().recordBatch([
|
||||||
|
{
|
||||||
|
ticker: 'AAPL',
|
||||||
|
assetType: 'STOCK',
|
||||||
|
price: 189.5,
|
||||||
|
signal: '✅ Strong Buy',
|
||||||
|
fundamental: passResult,
|
||||||
|
inflated: passResult,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ticker: 'MSFT',
|
||||||
|
assetType: 'STOCK',
|
||||||
|
price: 425.3,
|
||||||
|
signal: '🔄 Neutral',
|
||||||
|
fundamental: passResult,
|
||||||
|
inflated: passResult,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(n, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('read methods build valid queries', () => {
|
||||||
|
const r = repo();
|
||||||
|
assert.deepEqual(r.history('aapl'), []);
|
||||||
|
assert.deepEqual(r.byDate('2026-06-09'), []);
|
||||||
|
assert.deepEqual(r.latestBefore('2026-06-09'), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -238,8 +238,97 @@ test('StockScorer', async (t) => {
|
|||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
||||||
// Should handle gracefully (zero is falsy, treated as null)
|
// Zero quick ratio is a real value and fails the liquidity gate;
|
||||||
|
// zero P/E, PEG, P/B are impossible values and are treated as missing.
|
||||||
assert.ok(result);
|
assert.ok(result);
|
||||||
|
assert.equal(result.label, '🔴 REJECT');
|
||||||
|
assert.ok(result.scoreSummary.includes('Quick'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('treats zero revenue growth as a real (stagnant) value', () => {
|
||||||
|
const metrics: StockMetrics = {
|
||||||
|
peRatio: 12,
|
||||||
|
pegRatio: 0.8,
|
||||||
|
debtToEquity: 0.5,
|
||||||
|
quickRatio: 1.2,
|
||||||
|
returnOnEquity: 20,
|
||||||
|
operatingMargin: 15,
|
||||||
|
netProfitMargin: 10,
|
||||||
|
revenueGrowth: 0, // stagnant — must be scored, not skipped
|
||||||
|
fcfYield: 5,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
||||||
|
assert.ok(result.audit?.passedGates);
|
||||||
|
// 0% growth is below revMed (5) → scores -1, same as slightly negative growth
|
||||||
|
assert.equal(result.audit?.breakdown?.revenue, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('treats zero debt-to-equity as debt-free, not missing', () => {
|
||||||
|
const metrics: StockMetrics = {
|
||||||
|
peRatio: 12,
|
||||||
|
pegRatio: 0.8,
|
||||||
|
debtToEquity: 0, // debt-free — should pass the gate, not be skipped
|
||||||
|
quickRatio: 1.2,
|
||||||
|
returnOnEquity: 20,
|
||||||
|
operatingMargin: 15,
|
||||||
|
netProfitMargin: 10,
|
||||||
|
revenueGrowth: 8,
|
||||||
|
fcfYield: 5,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
||||||
|
assert.ok(result.audit?.passedGates);
|
||||||
|
assert.notEqual(result.label, '🔴 REJECT');
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('flags insufficient data instead of plain HOLD', () => {
|
||||||
|
const metrics: StockMetrics = { currentPrice: 50 } as any;
|
||||||
|
|
||||||
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
||||||
|
assert.equal(result.label, '🟡 HOLD (No Data)');
|
||||||
|
assert.equal(result.audit?.coverage?.active, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('returns structured tier and numeric score (P0.3)', () => {
|
||||||
|
const strong: StockMetrics = {
|
||||||
|
peRatio: 12,
|
||||||
|
pegRatio: 0.7,
|
||||||
|
debtToEquity: 0.3,
|
||||||
|
quickRatio: 1.5,
|
||||||
|
returnOnEquity: 30,
|
||||||
|
operatingMargin: 25,
|
||||||
|
netProfitMargin: 18,
|
||||||
|
revenueGrowth: 12,
|
||||||
|
fcfYield: 6,
|
||||||
|
} as any;
|
||||||
|
const pass = StockScorer.score(strong, DEFAULT_RULES);
|
||||||
|
assert.equal(pass.tier, 'PASS');
|
||||||
|
assert.ok(typeof pass.score === 'number' && pass.score >= 4);
|
||||||
|
|
||||||
|
const gated: StockMetrics = { ...strong, peRatio: 40 } as any;
|
||||||
|
const reject = StockScorer.score(gated, DEFAULT_RULES);
|
||||||
|
assert.equal(reject.tier, 'REJECT');
|
||||||
|
assert.equal(reject.score, null);
|
||||||
|
|
||||||
|
const noData = StockScorer.score({ currentPrice: 50 } as any, DEFAULT_RULES);
|
||||||
|
assert.equal(noData.tier, 'HOLD');
|
||||||
|
assert.equal(noData.score, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('reports factor coverage in audit', () => {
|
||||||
|
const metrics: StockMetrics = {
|
||||||
|
peRatio: 12,
|
||||||
|
pegRatio: 0.8,
|
||||||
|
quickRatio: 1.2,
|
||||||
|
returnOnEquity: 20,
|
||||||
|
currentPrice: 50,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
||||||
|
assert.ok(result.audit?.coverage);
|
||||||
|
assert.ok(result.audit.coverage.active >= 1);
|
||||||
|
assert.ok(result.audit.coverage.active <= result.audit.coverage.total);
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('scores based on configured thresholds', () => {
|
await t.test('scores based on configured thresholds', () => {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export { screenTickers, fetchCatalysts, analyzeTickers } from './screener.js';
|
|||||||
export { fetchPortfolio, addHolding, removeHolding, fetchMarketContext } from './finance.js';
|
export { fetchPortfolio, addHolding, removeHolding, fetchMarketContext } from './finance.js';
|
||||||
export { fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar } from './calls.js';
|
export { fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar } from './calls.js';
|
||||||
export { login, register, authFetch } from './auth.js';
|
export { login, register, authFetch } from './auth.js';
|
||||||
|
export { fetchWatchlist, pinTicker, unpinTicker } from './watchlist.js';
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { authFetch } from './auth.js';
|
||||||
|
|
||||||
|
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
export async function fetchWatchlist(): Promise<{ tickers: string[] }> {
|
||||||
|
const res = await authFetch(`${BASE}/api/watchlist`);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pinTicker(ticker: string): Promise<void> {
|
||||||
|
const res = await authFetch(`${BASE}/api/watchlist/${encodeURIComponent(ticker)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unpinTicker(ticker: string): Promise<void> {
|
||||||
|
const res = await authFetch(`${BASE}/api/watchlist/${encodeURIComponent(ticker)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
}
|
||||||
@@ -2,7 +2,100 @@
|
|||||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||||
import type { SidebarState } from '$lib/types.js';
|
import type { SidebarState } from '$lib/types.js';
|
||||||
|
|
||||||
let { sidebar, onClose }: { sidebar: SidebarState; onClose: () => void } = $props();
|
let { sidebar, onClose, onScreenTickers }: {
|
||||||
|
sidebar: SidebarState;
|
||||||
|
onClose: () => void;
|
||||||
|
onScreenTickers?: (tickers: string[]) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function sentimentClass(s: string) {
|
||||||
|
if (s === 'BULLISH') return 'sent-bullish';
|
||||||
|
if (s === 'BEARISH') return 'sent-bearish';
|
||||||
|
return 'sent-neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentimentEmoji(s: string) {
|
||||||
|
if (s === 'BULLISH') return '▲';
|
||||||
|
if (s === 'BEARISH') return '▼';
|
||||||
|
return '⊙';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentimentLabel(s: string) {
|
||||||
|
if (s === 'BULLISH') return 'Bullish';
|
||||||
|
if (s === 'BEARISH') return 'Bearish';
|
||||||
|
return 'Neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive industry impact from reason text heuristically
|
||||||
|
function industryImpact(reason: string): 'bear' | 'bull' | 'neut' {
|
||||||
|
const r = reason.toLowerCase();
|
||||||
|
const bearWords = ['weigh', 'pressure', 'risk', 'decline', 'weaken', 'concern', 'miss', 'delay', 'slowdown', 'threat', 'compress', 'reduce', 'cut', 'loss'];
|
||||||
|
const bullWords = ['benefit', 'strength', 'tailwind', 'inflow', 'growth', 'gain', 'boost', 'rise', 'improve', 'outperform'];
|
||||||
|
const bearScore = bearWords.filter(w => r.includes(w)).length;
|
||||||
|
const bullScore = bullWords.filter(w => r.includes(w)).length;
|
||||||
|
if (bearScore > bullScore) return 'bear';
|
||||||
|
if (bullScore > bearScore) return 'bull';
|
||||||
|
return 'neut';
|
||||||
|
}
|
||||||
|
|
||||||
|
function biasClass(bias: string) {
|
||||||
|
if (bias === 'BULL') return 'sig-bull';
|
||||||
|
if (bias === 'BEAR') return 'sig-bear';
|
||||||
|
return 'sig-neut';
|
||||||
|
}
|
||||||
|
|
||||||
|
function biasLabel(bias: string) {
|
||||||
|
if (bias === 'BULL') return '▲ BULLISH';
|
||||||
|
if (bias === 'BEAR') return '▼ BEARISH';
|
||||||
|
return '⊙ WATCH';
|
||||||
|
}
|
||||||
|
|
||||||
|
// sensitivity 1–5 → confidence label + class
|
||||||
|
function confLabel(s: number): string {
|
||||||
|
if (s >= 4) return 'HIGH confidence';
|
||||||
|
if (s >= 2) return 'MED confidence';
|
||||||
|
return 'LOW confidence';
|
||||||
|
}
|
||||||
|
|
||||||
|
function confClass(s: number): string {
|
||||||
|
if (s >= 4) return 'conf-high';
|
||||||
|
if (s >= 2) return 'conf-med';
|
||||||
|
return 'conf-low';
|
||||||
|
}
|
||||||
|
|
||||||
|
// sensitivity → confidence bar %
|
||||||
|
function confPct(s: number): number {
|
||||||
|
return Math.round((s / 5) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// horizon → human label for catalyst tag
|
||||||
|
function horizonLabel(h: string): string {
|
||||||
|
if (h === 'SHORT') return 'Near-term';
|
||||||
|
if (h === 'LONG') return 'Long-term';
|
||||||
|
return 'Medium-term';
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenAll() {
|
||||||
|
if (!sidebar.analysis) return;
|
||||||
|
const tickers = sidebar.analysis.relatedTickers.map(rt => rt.ticker);
|
||||||
|
onScreenTickers?.(tickers);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bold key phrases — wrap words > 6 chars that are all-caps or capitalised nouns
|
||||||
|
// (simple heuristic: bold ticker-like tokens and numbers with %)
|
||||||
|
function boldKeyTerms(text: string): string {
|
||||||
|
// Bold anything that looks like a ticker (2–5 uppercase letters)
|
||||||
|
return text.replace(/\b([A-Z]{2,5})\b/g, '<strong>$1</strong>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overall confidence from analysis: average sensitivity
|
||||||
|
function overallConf(tickers: { sensitivity: number }[]): number {
|
||||||
|
if (!tickers.length) return 50;
|
||||||
|
return Math.round(tickers.reduce((s, t) => s + t.sensitivity, 0) / tickers.length / 5 * 100);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if sidebar.open}
|
{#if sidebar.open}
|
||||||
@@ -17,16 +110,19 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Panel -->
|
<!-- Panel -->
|
||||||
<aside class="sidebar">
|
<aside class="sidebar as-panel">
|
||||||
<div class="sidebar-header">
|
<!-- Header -->
|
||||||
<div class="sidebar-title">
|
<div class="sidebar-header as-header">
|
||||||
<span>🤖 LLM Analysis</span>
|
<span class="as-icon">🤖</span>
|
||||||
{#if sidebar.type}<span class="sidebar-type">{sidebar.type}S</span>{/if}
|
<span class="sidebar-title as-title">LLM Analysis</span>
|
||||||
</div>
|
{#if sidebar.type}
|
||||||
|
<span class="sidebar-type as-scope">{sidebar.type}S</span>
|
||||||
|
{/if}
|
||||||
<button class="sidebar-close" onclick={onClose}>✕</button>
|
<button class="sidebar-close" onclick={onClose}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-body">
|
<div class="sidebar-body as-body">
|
||||||
|
|
||||||
{#if sidebar.loading}
|
{#if sidebar.loading}
|
||||||
<div class="sidebar-loading">
|
<div class="sidebar-loading">
|
||||||
<Spinner size="lg" label="Analyzing tickers…" />
|
<Spinner size="lg" label="Analyzing tickers…" />
|
||||||
@@ -37,46 +133,464 @@
|
|||||||
|
|
||||||
{:else if sidebar.analysis}
|
{:else if sidebar.analysis}
|
||||||
{@const a = sidebar.analysis}
|
{@const a = sidebar.analysis}
|
||||||
<div class="sb-sentiment-row">
|
{@const conf = overallConf(a.relatedTickers ?? [])}
|
||||||
<span class="sentiment-pill" data-sentiment={a.sentiment}>{a.sentiment}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="sb-summary">{a.summary}</p>
|
<!-- ── SENTIMENT HERO ── -->
|
||||||
|
<div class="as-sentiment-hero">
|
||||||
<h3 class="sb-sub">Affected Industries</h3>
|
<div class="as-sent-top">
|
||||||
<div class="sb-list">
|
<span class="as-sent-badge {sentimentClass(a.sentiment)}">
|
||||||
{#each a.affectedIndustries ?? [] as ind}
|
{sentimentEmoji(a.sentiment)} {sentimentLabel(a.sentiment)}
|
||||||
<div class="sb-item">
|
</span>
|
||||||
<span class="sb-name">{ind.name}</span>
|
<div class="as-sent-meta">
|
||||||
<span class="sb-reason">{ind.reason}</span>
|
<span class="as-sent-model">claude-sonnet</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</div>
|
||||||
|
|
||||||
|
<!-- confidence bar -->
|
||||||
|
<div class="as-conf-row">
|
||||||
|
<span class="as-conf-label">Confidence</span>
|
||||||
|
<div class="as-conf-track">
|
||||||
|
<div class="as-conf-fill" style="width:{conf}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="as-conf-pct">{conf}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="as-summary">{a.summary}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="sb-sub">Related Tickers to Watch</h3>
|
<!-- ── AFFECTED INDUSTRIES ── -->
|
||||||
<div class="sb-list">
|
{#if (a.affectedIndustries ?? []).length > 0}
|
||||||
{#each a.relatedTickers ?? [] as rt}
|
<div class="as-section">
|
||||||
<div class="sb-item">
|
<div class="as-section-header">
|
||||||
<div class="sb-ticker-row">
|
<span class="as-section-title">Affected Industries</span>
|
||||||
<span class="sb-name ticker">{rt.ticker}</span>
|
<span class="as-section-count">{a.affectedIndustries.length}</span>
|
||||||
<div class="sb-chips">
|
<div class="as-section-divider"></div>
|
||||||
{#if rt.bias}
|
</div>
|
||||||
<span class="sb-chip sb-bias" data-bias={rt.bias}>{rt.bias}</span>
|
|
||||||
{/if}
|
<div class="as-industry-list">
|
||||||
{#if rt.horizon}
|
{#each a.affectedIndustries as ind}
|
||||||
<span class="sb-chip sb-horizon">{rt.horizon}</span>
|
{@const impact = industryImpact(ind.reason)}
|
||||||
{/if}
|
<div class="as-ind-card {impact}">
|
||||||
{#if rt.sensitivity}
|
<div class="as-ind-top">
|
||||||
<span class="sb-chip sb-sensitivity" title="Sensitivity {rt.sensitivity}/5">S{rt.sensitivity}</span>
|
<span class="as-ind-name">{ind.name}</span>
|
||||||
{/if}
|
{#if impact === 'bear'}
|
||||||
|
<span class="as-impact-chip imp-bear">▼ BEAR</span>
|
||||||
|
{:else if impact === 'bull'}
|
||||||
|
<span class="as-impact-chip imp-bull">▲ BULL</span>
|
||||||
|
{:else}
|
||||||
|
<span class="as-impact-chip imp-neut">⊙ MIXED</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="as-ind-body">{@html boldKeyTerms(ind.reason)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
<span class="sb-reason">{rt.reason}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── RELATED TICKERS ── -->
|
||||||
|
{#if (a.relatedTickers ?? []).length > 0}
|
||||||
|
<div class="as-section">
|
||||||
|
<div class="as-section-header">
|
||||||
|
<span class="as-section-title">Tickers to Watch</span>
|
||||||
|
<span class="as-section-count">{a.relatedTickers.length}</span>
|
||||||
|
<div class="as-section-divider"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="as-ticker-list">
|
||||||
|
{#each a.relatedTickers as rt}
|
||||||
|
<div class="as-tick-card">
|
||||||
|
<div class="as-tick-top">
|
||||||
|
<span class="as-tick-sym">{rt.ticker}</span>
|
||||||
|
<span class="as-signal-chip {biasClass(rt.bias)}">{biasLabel(rt.bias)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="as-tick-meta">
|
||||||
|
<span class="as-conf-chip {confClass(rt.sensitivity)}">{confLabel(rt.sensitivity)}</span>
|
||||||
|
<span
|
||||||
|
class="as-score-tier"
|
||||||
|
title="Sensitivity score: S{rt.sensitivity} = {rt.sensitivity}/5 — how directly this ticker is affected by the news catalyst"
|
||||||
|
>S{rt.sensitivity}/5</span>
|
||||||
|
<span class="as-horizon-chip">{horizonLabel(rt.horizon)}</span>
|
||||||
|
</div>
|
||||||
|
<p class="as-tick-thesis">{@html boldKeyTerms(rt.reason)}</p>
|
||||||
|
<div class="as-catalyst-tag">⚡ {rt.horizon} horizon catalyst</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── SCREENER BRIDGE ── -->
|
||||||
|
{#if onScreenTickers && (a.relatedTickers ?? []).length > 0}
|
||||||
|
<div class="as-screener-prompt">
|
||||||
|
<div class="as-sp-text">
|
||||||
|
<strong>Screen these tickers</strong> to see current signals, scores, and gate results.
|
||||||
|
</div>
|
||||||
|
<button class="as-sp-btn" onclick={screenAll}>Screen All →</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── Sentiment hero ──────────────────────────────────────────────────── */
|
||||||
|
.as-sentiment-hero {
|
||||||
|
padding: 18px 16px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sent-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sent-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sent-bullish { background: #0d2e1a; color: #34d17a; border: 1px solid #1a4a2a; }
|
||||||
|
.sent-neutral { background: var(--blue-badge); color: var(--blue-muted); border: 1px solid #1a3a5c; }
|
||||||
|
.sent-bearish { background: #2e0d0d; color: #f05a5a; border: 1px solid #4a1a1a; }
|
||||||
|
|
||||||
|
.as-sent-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sent-model {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-dimmer);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* confidence bar */
|
||||||
|
.as-conf-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-conf-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dimmer);
|
||||||
|
width: 68px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-conf-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-conf-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--blue) 0%, #2dd4bf 100%);
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-conf-pct {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--blue-muted);
|
||||||
|
width: 34px;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-summary {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-summary :global(strong) { color: var(--text-secondary); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Section ─────────────────────────────────────────────────────────── */
|
||||||
|
.as-section {
|
||||||
|
padding: 14px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-section:last-of-type {
|
||||||
|
padding-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-section-title {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dimmer);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-section-count {
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-dimmer);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-section-divider {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Industry cards ──────────────────────────────────────────────────── */
|
||||||
|
.as-industry-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-ind-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-left-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-ind-card.bear { border-left-color: #f05a5a; }
|
||||||
|
.as-ind-card.bull { border-left-color: #34d17a; }
|
||||||
|
.as-ind-card.neut { border-left-color: var(--border); }
|
||||||
|
|
||||||
|
.as-ind-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-ind-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-impact-chip {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imp-bear { background: #2e0d0d; color: #f05a5a; border: 1px solid #4a1a1a; }
|
||||||
|
.imp-bull { background: #0d2e1a; color: #34d17a; border: 1px solid #1a4a2a; }
|
||||||
|
.imp-neut { background: var(--bg-elevated); color: var(--text-muted); border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.as-ind-body {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-ind-body :global(strong) { color: var(--text-secondary); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Ticker cards ────────────────────────────────────────────────────── */
|
||||||
|
.as-ticker-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-tick-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-tick-card:hover {
|
||||||
|
border-color: var(--border-input);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-tick-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-tick-sym {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-signal-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-bear { background: #2e0d0d; color: #f05a5a; border: 1px solid #4a1a1a; }
|
||||||
|
.sig-bull { background: #0d2e1a; color: #34d17a; border: 1px solid #1a4a2a; }
|
||||||
|
.sig-neut { background: var(--bg-elevated); color: var(--text-muted); border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.as-tick-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-conf-chip {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conf-high { background: #0d2e1a; color: #34d17a; }
|
||||||
|
.conf-med { background: #2e2000; color: #f0b429; }
|
||||||
|
.conf-low { background: var(--bg-elevated); color: var(--text-dimmer); border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.as-score-tier {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dimmer);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: help;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-style: dotted;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-horizon-chip {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dimmer);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-tick-thesis {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-tick-thesis :global(strong) { color: var(--text-secondary); font-weight: 600; }
|
||||||
|
|
||||||
|
.as-catalyst-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #a78bfa;
|
||||||
|
background: #1e1535;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #2d2050;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Screener bridge ─────────────────────────────────────────────────── */
|
||||||
|
.as-screener-prompt {
|
||||||
|
margin: 4px 16px 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--blue-badge);
|
||||||
|
border: 1px solid #1a3a5c;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sp-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--blue-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sp-text :global(strong) { font-weight: 600; color: var(--blue-muted); }
|
||||||
|
|
||||||
|
.as-sp-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--blue);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.as-sp-btn:hover { background: #7ec0ff; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { sigOrd, sorted } from '$lib/utils.js';
|
import { sigOrd, sorted } from '$lib/utils.js';
|
||||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||||
|
import GlossaryPanel from '$lib/components/screener/GlossaryPanel.svelte';
|
||||||
|
import SignalModal from '$lib/components/screener/SignalModal.svelte';
|
||||||
import type { AssetType, AssetResult } from '$lib/types.js';
|
import type { AssetType, AssetResult } from '$lib/types.js';
|
||||||
|
import { watchlistStore } from '$lib/stores/watchlist.store.svelte.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
type,
|
type,
|
||||||
@@ -15,9 +18,12 @@
|
|||||||
onAnalyze: () => void;
|
onAnalyze: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let mode = $state('inflated');
|
let mode = $state('inflated');
|
||||||
let expanded = $state<string | null>(null);
|
let expanded = $state<string | null>(null);
|
||||||
let sortCol = $state<string | null>(null);
|
let glossaryOpen = $state(false);
|
||||||
|
let specModalRow = $state<AssetResult | null>(null);
|
||||||
|
let glossaryFocusKey = $state<string | null>(null);
|
||||||
|
let sortCol = $state<string | null>(null);
|
||||||
let sortAsc = $state(true);
|
let sortAsc = $state(true);
|
||||||
let filterTicker = $state('');
|
let filterTicker = $state('');
|
||||||
let filterSignal = $state('');
|
let filterSignal = $state('');
|
||||||
@@ -162,6 +168,21 @@
|
|||||||
return n > 0 ? 'pos' : n < 0 ? 'neg' : '';
|
return n > 0 ? 'pos' : n < 0 ? 'neg' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive the set of metric keys present in the currently-expanded row
|
||||||
|
// so the glossary can highlight them contextually.
|
||||||
|
const METRIC_KEYS_STOCK = ['P/E','PEG','ROE%','OpMgn%','GrossM%','FCF Yld%','D/E','52W Chg','From High','Analyst','Upside','DCF Safety'];
|
||||||
|
const METRIC_KEYS_ETF = ['Exp Ratio%','5Y Return%','Yield%'];
|
||||||
|
const METRIC_KEYS_BOND = ['YTM%','Duration','Rating'];
|
||||||
|
|
||||||
|
function activeGlossaryMetrics(): string[] {
|
||||||
|
if (!expanded) return [];
|
||||||
|
const row = rows.find((r) => r.asset.ticker === expanded);
|
||||||
|
if (!row) return [];
|
||||||
|
const m = row.asset.displayMetrics ?? {};
|
||||||
|
const keys = type === 'STOCK' ? METRIC_KEYS_STOCK : type === 'ETF' ? METRIC_KEYS_ETF : METRIC_KEYS_BOND;
|
||||||
|
return keys.filter((k) => m[k] != null && m[k] !== '—');
|
||||||
|
}
|
||||||
|
|
||||||
function breakdownEntries(bd: Record<string, number> | undefined) {
|
function breakdownEntries(bd: Record<string, number> | undefined) {
|
||||||
if (!bd) return [];
|
if (!bd) return [];
|
||||||
return Object.entries(bd).sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
|
return Object.entries(bd).sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
|
||||||
@@ -172,6 +193,93 @@
|
|||||||
const max = Math.max(...Object.values(bd).map(Math.abs));
|
const max = Math.max(...Object.values(bd).map(Math.abs));
|
||||||
return max === 0 ? 1 : max;
|
return max === 0 ? 1 : max;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Factor card helpers
|
||||||
|
interface FactorCard {
|
||||||
|
key: string; // glossary key
|
||||||
|
name: string; // display name
|
||||||
|
score: number;
|
||||||
|
reason: string; // plain-English with embedded <b> tags
|
||||||
|
pct: number; // bar width %
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACTOR_META: Record<string, { name: string; key: string; reason: (val: string | undefined, score: number, threshold?: string) => string }> = {
|
||||||
|
'ROE': { name: 'Return on Equity', key: 'ROE%', reason: (v, s) => s > 0 ? `ROE <b>${v}</b> — above 15% threshold. Strong capital efficiency.` : `ROE <b>${v}</b> — below the 15% preferred threshold. Partial or no score.` },
|
||||||
|
'opMargin': { name: 'Operating Margin', key: 'OpMgn%', reason: (v, s) => s > 0 ? `Op margin <b>${v}</b> — positive and above threshold. Efficient operations.` : `Op margin <b>${v}</b> — below preferred threshold (Gate: > 10%).` },
|
||||||
|
'margin': { name: 'Gross Margin', key: 'GrossM%', reason: (v, s) => s > 0 ? `Gross margin <b>${v}</b> — strong pricing power.` : `Gross margin <b>${v}</b> — limited pricing power or high COGS.` },
|
||||||
|
'peg': { name: 'PEG Ratio', key: 'PEG', reason: (v, s) => s > 0 ? `PEG <b>${v}</b> — below 1.0 threshold. Paying less than growth justifies. (Gate: < 1.0)` : `PEG <b>${v}</b> — above 1.0 threshold. Paying a growth premium. (Gate: < 1.0)` },
|
||||||
|
'revenue': { name: 'Revenue Growth', key: 'Revenue', reason: (_v, s) => s > 0 ? `Revenue growing. Positive contribution to score.` : `Revenue growth below threshold or negative. Partial or no score.` },
|
||||||
|
'fcf': { name: 'FCF Yield', key: 'FCF Yld%', reason: (v, s) => s > 0 ? `FCF yield <b>${v}</b> — strongly positive free cash flow. High weight metric. (Gate: > 0%)` : `FCF yield <b>${v}</b> — negative or zero free cash flow. Hard gate failure.` },
|
||||||
|
'analyst': { name: 'Analyst Consensus', key: 'Analyst', reason: (v, s) => s > 0 ? `Rated <b>Buy</b> by Wall St. (Yahoo mean ≤ 2.5). Requires ≥ 3 analysts. Rating: ${v}.` : s < 0 ? `Analyst consensus <b>${v}</b> — bearish consensus or insufficient coverage.` : `Analyst consensus <b>${v}</b> — neutral range or fewer than 3 analysts.` },
|
||||||
|
'dcf': { name: 'DCF Margin of Safety', key: 'DCF Safety', reason: (v, s) => s > 0 ? `Intrinsic value <b>${v} above</b> current price. Stock appears undervalued vs DCF model. (Gate: ≥ 20%)` : s < 0 ? `Stock priced <b>above</b> DCF intrinsic value. May be overvalued by the model.` : `DCF margin of safety near zero. No significant under/overvaluation signal.` },
|
||||||
|
'cost': { name: 'Expense Ratio', key: 'Exp Ratio%', reason: (v, s) => s > 0 ? `Expense ratio <b>${v}</b> — low cost. Costs compound in your favour. (Gate: ≤ 0.20%)` : `Expense ratio <b>${v}</b> — above the 0.20% gate. Higher fees reduce long-run returns.` },
|
||||||
|
'yield': { name: 'Distribution Yield', key: 'Yield%', reason: (v, s) => s > 0 ? `Yield <b>${v}</b> — strong income distribution.` : `Yield <b>${v}</b> — below preferred level.` },
|
||||||
|
'volume': { name: 'Avg Daily Volume', key: 'Volume', reason: (_v, s) => s > 0 ? `Sufficient trading volume. Liquid, tradeable fund.` : `Low trading volume. Liquidity risk — spreads may be wide.` },
|
||||||
|
'fiveYearReturn': { name: '5-Year Return', key: '5Y Return%', reason: (v, s) => s > 0 ? `5Y annualised return <b>${v}</b> — above the 8% S&P floor.` : `5Y return <b>${v}</b> — below the 8% gate. Underperforms long-run S&P average.` },
|
||||||
|
'spread': { name: 'Credit Spread', key: 'YTM%', reason: (_v, s) => s > 0 ? `Yield spread above risk-free rate exceeds 1.5% gate. Adequate risk compensation.` : `Spread too narrow. Bond doesn't compensate enough for credit risk. (Gate: ≥ 1.5%)` },
|
||||||
|
'duration': { name: 'Duration', key: 'Duration', reason: (v, s) => s > 0 ? `Duration <b>${v}y</b> — moderate interest rate risk. (Gate: ≤ 7y)` : `Duration <b>${v}y</b> — high interest rate sensitivity. (Gate: ≤ 7y)` },
|
||||||
|
};
|
||||||
|
|
||||||
|
function verdictLabel(score: number): string {
|
||||||
|
const s = Math.abs(score);
|
||||||
|
const label = s >= 3 ? 'STRONG' : s >= 2 ? 'GOOD' : s >= 1 ? 'MODERATE' : 'NEUTRAL';
|
||||||
|
if (score > 0) return `+${score} ${label}`;
|
||||||
|
if (score < 0) return `${score} WEAK`;
|
||||||
|
return '0 NEUTRAL';
|
||||||
|
}
|
||||||
|
|
||||||
|
function verdictClass(score: number): string {
|
||||||
|
if (score > 0) return 'fv-pos';
|
||||||
|
if (score < 0) return 'fv-neg';
|
||||||
|
return 'fv-neu';
|
||||||
|
}
|
||||||
|
|
||||||
|
function factorCards(bd: Record<string, number> | undefined, displayMetrics: Record<string, unknown>): FactorCard[] {
|
||||||
|
if (!bd) return [];
|
||||||
|
const scale = maxAbs(bd);
|
||||||
|
return Object.entries(bd)
|
||||||
|
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))
|
||||||
|
.map(([factor, score]) => {
|
||||||
|
const meta = FACTOR_META[factor];
|
||||||
|
const displayKey = meta?.key;
|
||||||
|
const val = displayKey ? String(displayMetrics[displayKey] ?? '—') : '—';
|
||||||
|
const reason = meta ? meta.reason(val, score) : `${factor}: score ${score}`;
|
||||||
|
return {
|
||||||
|
key: displayKey ?? factor,
|
||||||
|
name: meta?.name ?? factor,
|
||||||
|
score,
|
||||||
|
reason,
|
||||||
|
pct: Math.round((Math.abs(score) / scale) * 100),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSpecModal(row: AssetResult, e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
specModalRow = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGlossaryTo(key: string) {
|
||||||
|
glossaryFocusKey = key;
|
||||||
|
glossaryOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sigKey(signal: string | undefined): string {
|
||||||
|
const s = signal ?? '';
|
||||||
|
if (s.includes('Strong')) return 'strong';
|
||||||
|
if (s.includes('Momentum')) return 'momentum';
|
||||||
|
if (s.includes('Speculation')) return 'spec';
|
||||||
|
if (s.includes('Neutral')) return 'neutral';
|
||||||
|
return 'avoid';
|
||||||
|
}
|
||||||
|
|
||||||
|
function googleNewsUrl(ticker: string): string {
|
||||||
|
return `https://news.google.com/search?q=${encodeURIComponent(ticker + ' stock')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yahooNewsUrl(ticker: string): string {
|
||||||
|
return `https://finance.yahoo.com/quote/${encodeURIComponent(ticker)}/news/`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
@@ -187,6 +295,15 @@
|
|||||||
<button class:active={mode === 'fundamental'} onclick={() => mode = 'fundamental'}>Graham</button>
|
<button class:active={mode === 'fundamental'} onclick={() => mode = 'fundamental'}>Graham</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn-glossary"
|
||||||
|
class:btn-glossary-active={glossaryOpen}
|
||||||
|
onclick={() => (glossaryOpen = !glossaryOpen)}
|
||||||
|
title="Open metrics glossary"
|
||||||
|
>
|
||||||
|
? Glossary
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn-analyze"
|
class="btn-analyze"
|
||||||
onclick={onAnalyze}
|
onclick={onAnalyze}
|
||||||
@@ -208,36 +325,49 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="col-expand"></th>
|
<th class="col-expand"></th>
|
||||||
<th class="col-ticker sort-th" onclick={() => setSort('ticker')}>
|
<th class="col-ticker sort-th" onclick={() => setSort('ticker')}>
|
||||||
Ticker <span class="sort-icon">{sortIcon('ticker')}</span>
|
<span class="col-tip" data-tip="Stock, ETF, or bond ticker symbol">Ticker</span>
|
||||||
|
<span class="sort-icon">{sortIcon('ticker')}</span>
|
||||||
</th>
|
</th>
|
||||||
<th class="sort-th" onclick={() => setSort('price')}>
|
<th class="sort-th" onclick={() => setSort('price')}>
|
||||||
Price <span class="sort-icon">{sortIcon('price')}</span>
|
<span class="col-tip" data-tip="Current market price">Price</span>
|
||||||
|
<span class="sort-icon">{sortIcon('price')}</span>
|
||||||
</th>
|
</th>
|
||||||
<th class="sort-th" onclick={() => setSort('signal')}>
|
<th class="sort-th" onclick={() => setSort('signal')}>
|
||||||
Signal <span class="sort-icon">{sortIcon('signal')}</span>
|
<span class="col-tip" data-tip="Overall verdict: Strong Buy passes both fundamental and market-adjusted gates; Avoid fails both">Signal</span>
|
||||||
|
<span class="sort-icon">{sortIcon('signal')}</span>
|
||||||
</th>
|
</th>
|
||||||
<th class="sort-th" onclick={() => setSort('score')}>
|
<th class="sort-th" onclick={() => setSort('score')}>
|
||||||
Score <span class="sort-icon">{sortIcon('score')}</span>
|
<span class="col-tip" data-tip="Weighted factor score (ROE, FCF, margins, PEG, analyst, DCF). Shown as dots out of 5 + raw number. ✗ means gate failed before scoring.">Score</span>
|
||||||
|
<span class="sort-icon">{sortIcon('score')}</span>
|
||||||
</th>
|
</th>
|
||||||
{#if type === 'STOCK'}
|
{#if type === 'STOCK'}
|
||||||
<th class="sort-th" title="Market cap tier" onclick={() => setSort('cap')}>
|
<th class="sort-th" onclick={() => setSort('cap')}>
|
||||||
Cap <span class="sort-icon">{sortIcon('cap')}</span>
|
<span class="col-tip" data-tip="Market cap tier: Mega (>$200B), Large ($10–200B), Mid ($2–10B), Small ($300M–$2B), Micro (<$300M)">Cap</span>
|
||||||
|
<span class="sort-icon">{sortIcon('cap')}</span>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<span class="col-tip" data-tip="Growth/style: High Growth (rev ≥15%), Growth (5–15%), Value (low growth + yield ≥3%), Stable, Turnaround, Declining">Style</span>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<span class="col-tip" data-tip="Risk flags: momentum extremes, valuation outliers, analyst divergence, DCF divergence. Hover the badge to see individual flags.">Flags</span>
|
||||||
</th>
|
</th>
|
||||||
<th title="Growth / style">Style</th>
|
|
||||||
<th>Flags</th>
|
|
||||||
{:else if type === 'ETF'}
|
{:else if type === 'ETF'}
|
||||||
<th class="sort-th" onclick={() => setSort('expense')}>
|
<th class="sort-th" onclick={() => setSort('expense')}>
|
||||||
Expense <span class="sort-icon">{sortIcon('expense')}</span>
|
<span class="col-tip" data-tip="Annual management fee as % of AUM. Gate: ≤ 0.20%. Lower is always better — costs compound against returns.">Expense</span>
|
||||||
|
<span class="sort-icon">{sortIcon('expense')}</span>
|
||||||
</th>
|
</th>
|
||||||
<th class="sort-th" onclick={() => setSort('ret5y')}>
|
<th class="sort-th" onclick={() => setSort('ret5y')}>
|
||||||
5Y Ret <span class="sort-icon">{sortIcon('ret5y')}</span>
|
<span class="col-tip" data-tip="5-year annualised return. Gate: ≥ 8% (S&P long-run floor). Benchmark: S&P 500 ≈ 10% historically.">5Y Ret</span>
|
||||||
|
<span class="sort-icon">{sortIcon('ret5y')}</span>
|
||||||
</th>
|
</th>
|
||||||
{:else}
|
{:else}
|
||||||
<th class="sort-th" onclick={() => setSort('rating')}>
|
<th class="sort-th" onclick={() => setSort('rating')}>
|
||||||
Rating <span class="sort-icon">{sortIcon('rating')}</span>
|
<span class="col-tip" data-tip="Credit rating: AAA → BBB = investment grade. Gate: ≥ BBB. BB and below = junk / high yield.">Rating</span>
|
||||||
|
<span class="sort-icon">{sortIcon('rating')}</span>
|
||||||
</th>
|
</th>
|
||||||
<th class="sort-th" onclick={() => setSort('ytm')}>
|
<th class="sort-th" onclick={() => setSort('ytm')}>
|
||||||
YTM <span class="sort-icon">{sortIcon('ytm')}</span>
|
<span class="col-tip" data-tip="Yield to Maturity — total return if held to maturity. Must exceed risk-free rate by ≥ 1.5% (spread gate).">YTM</span>
|
||||||
|
<span class="sort-icon">{sortIcon('ytm')}</span>
|
||||||
</th>
|
</th>
|
||||||
{/if}
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -299,7 +429,9 @@
|
|||||||
{@const isOpen = expanded === r.asset.ticker}
|
{@const isOpen = expanded === r.asset.ticker}
|
||||||
{@const colCount = type === 'STOCK' ? 8 : 7}
|
{@const colCount = type === 'STOCK' ? 8 : 7}
|
||||||
{@const flags = v.audit?.riskFlags ?? []}
|
{@const flags = v.audit?.riskFlags ?? []}
|
||||||
{@const rawScore = parseInt(v.scoreSummary?.replace(/\D/g, '') ?? '0', 10)}
|
{@const rawScore = v.score ?? parseInt(v.scoreSummary?.match(/-?\d+/)?.[0] ?? '0', 10)}
|
||||||
|
{@const cov = v.audit?.coverage}
|
||||||
|
{@const noData = cov != null && cov.active === 0}
|
||||||
|
|
||||||
<!-- ── Summary row ── -->
|
<!-- ── Summary row ── -->
|
||||||
<tr
|
<tr
|
||||||
@@ -308,35 +440,62 @@
|
|||||||
data-signal={sigOrd(r.signal)}
|
data-signal={sigOrd(r.signal)}
|
||||||
onclick={() => toggleExpand(r.asset.ticker)}
|
onclick={() => toggleExpand(r.asset.ticker)}
|
||||||
>
|
>
|
||||||
<td class="col-expand">{isOpen ? '▾' : '▸'}</td>
|
<td class="col-expand">
|
||||||
|
<span class="row-toggle">{isOpen ? '▾' : '▸'}</span>
|
||||||
|
<button
|
||||||
|
class="pin-btn"
|
||||||
|
class:pinned={watchlistStore.isPinned(r.asset.ticker)}
|
||||||
|
onclick={(e) => { e.stopPropagation(); watchlistStore.toggle(r.asset.ticker); }}
|
||||||
|
title={watchlistStore.isPinned(r.asset.ticker) ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||||
|
>{watchlistStore.isPinned(r.asset.ticker) ? '📌' : '🔖'}</button>
|
||||||
|
</td>
|
||||||
<td class="ticker">{r.asset.ticker}</td>
|
<td class="ticker">{r.asset.ticker}</td>
|
||||||
<td class="num">{m.Price ?? '—'}</td>
|
<td class="num">{m.Price ?? '—'}</td>
|
||||||
<!-- Signal pill -->
|
<!-- Signal pill -->
|
||||||
<td class="signal-verdict-cell">
|
<td>
|
||||||
<span class="sv-pill sv-{(r.signal ?? '').includes('Strong') ? 'strong' : (r.signal ?? '').includes('Momentum') ? 'momentum' : (r.signal ?? '').includes('Speculation') ? 'spec' : (r.signal ?? '').includes('Neutral') ? 'neutral' : 'avoid'}">{(r.signal ?? '').replace(/^[^\w\s]+\s*/, '').trim() || '—'}</span>
|
<div class="signal-verdict-cell">
|
||||||
|
<button
|
||||||
|
class="sv-pill sv-{sigKey(r.signal)} sv-pill-link"
|
||||||
|
onclick={(e) => { if (r.signal) openSpecModal(r, e); }}
|
||||||
|
title="Click to explain this signal"
|
||||||
|
>
|
||||||
|
{(r.signal ?? '').replace(/^[^\w\s]+\s*/, '').trim() || '—'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<!-- Score as dot scale -->
|
<!-- Score as dot scale -->
|
||||||
<td class="score-cell" title={v.scoreSummary}>
|
<td class="score-cell" title={cov ? `${v.scoreSummary} — ${cov.active}/${cov.total} factors had data` : v.scoreSummary}>
|
||||||
{#if v.scoreSummary?.startsWith('Gate failed')}
|
{#if v.scoreSummary?.startsWith('Gate failed')}
|
||||||
<span class="score-fail">✗</span>
|
<span class="score-fail">✗</span>
|
||||||
|
{:else if noData}
|
||||||
|
<span class="score-nodata">No data</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="score-dots">
|
<span class="score-dots">
|
||||||
{#each Array(5) as _, i}
|
{#each Array(5) as _, i}
|
||||||
<span class="score-dot" class:on={i < Math.round(rawScore / 4)}></span>
|
<span class="score-dot" class:on={rawScore > 0 && i < Math.round(rawScore / 4)}></span>
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
<span class="score-num">{rawScore}</span>
|
<span class="score-num">{rawScore}</span>
|
||||||
|
{#if cov && cov.active / cov.total < 0.5}
|
||||||
|
<span class="score-cov" title="Only {cov.active} of {cov.total} scoring factors had data — treat this score with caution">{cov.active}/{cov.total}</span>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{#if type === 'STOCK'}
|
{#if type === 'STOCK'}
|
||||||
<td><span class="tag sm cap-tag">{m['Cap Tier'] ?? '—'}</span></td>
|
<td><span class="tag sm cap-tag">{m['Cap Tier'] ?? '—'}</span></td>
|
||||||
<td><span class="tag sm style-tag">{m['Style'] ?? '—'}</span></td>
|
<td><span class="tag sm style-tag">{m['Style'] ?? '—'}</span></td>
|
||||||
<!-- Flags: count badge with hover expand -->
|
<!-- Flags: count badge with hover expand tooltip -->
|
||||||
<td class="flags-cell">
|
<td class="flags-cell">
|
||||||
{#if flags.length > 0}
|
{#if flags.length > 0}
|
||||||
<span class="flags-badge">
|
<div class="flags-badge">
|
||||||
<span class="flags-count">⚠ {flags.length}</span>
|
<span class="flags-count">⚠ {flags.length}</span>
|
||||||
</span>
|
<div class="flags-tooltip">
|
||||||
|
<div class="flags-tt-title">Risk Flags</div>
|
||||||
|
{#each flags as flag}
|
||||||
|
<div class="flags-tt-item">⚠ {flag}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{:else if type === 'ETF'}
|
{:else if type === 'ETF'}
|
||||||
@@ -358,97 +517,99 @@
|
|||||||
|
|
||||||
<!-- ══ LEFT — metric grid ══════════════════════════════════ -->
|
<!-- ══ LEFT — metric grid ══════════════════════════════════ -->
|
||||||
<div class="dp-left">
|
<div class="dp-left">
|
||||||
<div class="dp-title">Metrics</div>
|
<div class="dp-title">Metrics <span class="dp-mode-note">— click any card for full definition</span></div>
|
||||||
<div class="dp-metric-grid">
|
<div class="dp-metric-grid">
|
||||||
{#if type === 'STOCK'}
|
{#if type === 'STOCK'}
|
||||||
<div class="dp-metric-card">
|
{@const failures = [...(r.inflated.audit?.failures ?? []), ...(r.fundamental.audit?.failures ?? [])]}
|
||||||
<span class="dp-mc-label">P/E</span>
|
{@const failedKeys = failures.map(f => f.toLowerCase())}
|
||||||
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('P/E')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('P/E')} class:dp-mc-fail={failedKeys.some(f => f.includes('p/e'))} class:dp-mc-pass={!failedKeys.some(f => f.includes('p/e')) && m['P/E'] && m['P/E'] !== '—'}>
|
||||||
|
<span class="dp-mc-label">P/E <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value">{m['P/E'] ?? '—'}</span>
|
<span class="dp-mc-value">{m['P/E'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('PEG')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('PEG')} class:dp-mc-fail={failedKeys.some(f => f.includes('peg'))} class:dp-mc-pass={m['PEG'] !== '—' && m['PEG'] != null && parseFloat(String(m['PEG'])) < 1.0}>
|
||||||
<span class="dp-mc-label">PEG</span>
|
<span class="dp-mc-label">PEG <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value">{m['PEG'] ?? '—'}</span>
|
<span class="dp-mc-value">{m['PEG'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('ROE%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('ROE%')} class:dp-mc-fail={failedKeys.some(f => f.includes('roe'))} class:dp-mc-pass={parseFloat(String(m['ROE%'])) >= 15}>
|
||||||
<span class="dp-mc-label">ROE%</span>
|
<span class="dp-mc-label">ROE% <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value {signClass(m['ROE%'])}">{m['ROE%'] ?? '—'}</span>
|
<span class="dp-mc-value {signClass(m['ROE%'])}">{m['ROE%'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('OpMgn%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('OpMgn%')} class:dp-mc-fail={failedKeys.some(f => f.includes('margin') || f.includes('op'))} class:dp-mc-pass={parseFloat(String(m['OpMgn%'])) >= 10}>
|
||||||
<span class="dp-mc-label">Op Mgn%</span>
|
<span class="dp-mc-label">Op Mgn% <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value {signClass(m['OpMgn%'])}">{m['OpMgn%'] ?? '—'}</span>
|
<span class="dp-mc-value {signClass(m['OpMgn%'])}">{m['OpMgn%'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('GrossM%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('GrossM%')}>
|
||||||
<span class="dp-mc-label">Gross M%</span>
|
<span class="dp-mc-label">Gross M% <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value {signClass(m['GrossM%'])}">{m['GrossM%'] ?? '—'}</span>
|
<span class="dp-mc-value {signClass(m['GrossM%'])}">{m['GrossM%'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('FCF Yld%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('FCF Yld%')} class:dp-mc-fail={failedKeys.some(f => f.includes('fcf') || f.includes('cash'))} class:dp-mc-pass={parseFloat(String(m['FCF Yld%'])) > 0}>
|
||||||
<span class="dp-mc-label">FCF Yld%</span>
|
<span class="dp-mc-label">FCF Yld% <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value {signClass(m['FCF Yld%'])}">{m['FCF Yld%'] ?? '—'}</span>
|
<span class="dp-mc-value {signClass(m['FCF Yld%'])}">{m['FCF Yld%'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('D/E')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('D/E')} class:dp-mc-fail={failedKeys.some(f => f.includes('debt') || f.includes('d/e'))} class:dp-mc-pass={parseFloat(String(m['D/E'])) <= 1.5}>
|
||||||
<span class="dp-mc-label">D/E</span>
|
<span class="dp-mc-label">D/E <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value">{m['D/E'] ?? '—'}</span>
|
<span class="dp-mc-value">{m['D/E'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('52W Chg')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('52W Chg')}>
|
||||||
<span class="dp-mc-label">52W Chg</span>
|
<span class="dp-mc-label">52W Chg <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value {signClass(m['52W Chg'])}">{m['52W Chg'] ?? '—'}</span>
|
<span class="dp-mc-value {signClass(m['52W Chg'])}">{m['52W Chg'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('From High')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('From High')}>
|
||||||
<span class="dp-mc-label">From High</span>
|
<span class="dp-mc-label">From High <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value {signClass(m['From High'])}">{m['From High'] ?? '—'}</span>
|
<span class="dp-mc-value {signClass(m['From High'])}">{m['From High'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('Analyst')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('Analyst')}>
|
||||||
<span class="dp-mc-label">Analyst</span>
|
<span class="dp-mc-label">Analyst <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value">{m['Analyst'] ?? '—'}</span>
|
<span class="dp-mc-value">{m['Analyst'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('Analyst')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('Analyst')}>
|
||||||
<span class="dp-mc-label">Upside</span>
|
<span class="dp-mc-label">Upside <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value {signClass(m['Upside'])}">{m['Upside'] ?? '—'}</span>
|
<span class="dp-mc-value {signClass(m['Upside'])}">{m['Upside'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('DCF Safety')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('DCF Safety')} class:dp-mc-pass={parseFloat(String(m['DCF Safety'])) >= 20}>
|
||||||
<span class="dp-mc-label">DCF Safety</span>
|
<span class="dp-mc-label">DCF Safety <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value {signClass(m['DCF Safety'])}">{m['DCF Safety'] ?? '—'}</span>
|
<span class="dp-mc-value {signClass(m['DCF Safety'])}">{m['DCF Safety'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if type === 'ETF'}
|
{:else if type === 'ETF'}
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('Yield%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('Yield%')}>
|
||||||
<span class="dp-mc-label">Yield%</span>
|
<span class="dp-mc-label">Yield% <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value">{m['Yield%'] ?? '—'}</span>
|
<span class="dp-mc-value">{m['Yield%'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('Exp Ratio%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('Exp Ratio%')}>
|
||||||
<span class="dp-mc-label">AUM</span>
|
<span class="dp-mc-label">AUM <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value">{m['AUM'] ?? '—'}</span>
|
<span class="dp-mc-value">{m['AUM'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('5Y Return%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('5Y Return%')}>
|
||||||
<span class="dp-mc-label">5Y Ret%</span>
|
<span class="dp-mc-label">5Y Ret% <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value {signClass(m['5Y Return%'])}">{m['5Y Return%'] ?? '—'}</span>
|
<span class="dp-mc-value {signClass(m['5Y Return%'])}">{m['5Y Return%'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('Exp Ratio%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('Exp Ratio%')} class:dp-mc-pass={parseFloat(String(m['Exp Ratio%'])) <= 0.2} class:dp-mc-fail={parseFloat(String(m['Exp Ratio%'])) > 0.2}>
|
||||||
<span class="dp-mc-label">Exp Ratio%</span>
|
<span class="dp-mc-label">Exp Ratio% <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value">{m['Exp Ratio%'] ?? '—'}</span>
|
<span class="dp-mc-value">{m['Exp Ratio%'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('YTM%')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('YTM%')}>
|
||||||
<span class="dp-mc-label">YTM%</span>
|
<span class="dp-mc-label">YTM% <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value">{m['YTM%'] ?? '—'}</span>
|
<span class="dp-mc-value">{m['YTM%'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('Duration')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('Duration')} class:dp-mc-fail={parseFloat(String(m['Duration'])) > 7} class:dp-mc-pass={parseFloat(String(m['Duration'])) <= 7}>
|
||||||
<span class="dp-mc-label">Duration</span>
|
<span class="dp-mc-label">Duration <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value">{m['Duration'] ?? '—'}</span>
|
<span class="dp-mc-value">{m['Duration'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dp-metric-card">
|
<div class="dp-metric-card" onclick={() => openGlossaryTo('Rating')} role="button" tabindex="0" onkeypress={(e) => e.key==='Enter' && openGlossaryTo('Rating')}>
|
||||||
<span class="dp-mc-label">Rating</span>
|
<span class="dp-mc-label">Rating <span class="dp-mc-help">?</span></span>
|
||||||
<span class="dp-mc-value">{m['Rating'] ?? '—'}</span>
|
<span class="dp-mc-value">{m['Rating'] ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Gate badge chips ── -->
|
<!-- ── Gate badge chips — show which mode passed/failed + the failing rule ── -->
|
||||||
<div class="dp-gates-row">
|
<div class="dp-gates-row">
|
||||||
<span class="dp-gate-chip" class:dp-gate-chip-pass={mktPass} class:dp-gate-chip-fail={!mktPass}>
|
<span class="dp-gate-chip" class:dp-gate-chip-pass={mktPass} class:dp-gate-chip-fail={!mktPass}>
|
||||||
MKT {mktPass ? '✓' : '✗'}
|
MKT {mktPass ? '✓' : '✗'}{#if !mktPass && r.inflated.audit?.failures?.[0]} — {r.inflated.audit.failures[0]}{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="dp-gate-chip" class:dp-gate-chip-pass={grahamPass} class:dp-gate-chip-fail={!grahamPass}>
|
<span class="dp-gate-chip" class:dp-gate-chip-pass={grahamPass} class:dp-gate-chip-fail={!grahamPass}>
|
||||||
GRAHAM {grahamPass ? '✓' : '✗'}
|
GRAHAM {grahamPass ? '✓' : '✗'}{#if !grahamPass && r.fundamental.audit?.failures?.[0]} — {r.fundamental.audit.failures[0]}{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -460,13 +621,24 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── News links ── -->
|
||||||
|
<div class="dp-news-row">
|
||||||
|
<span class="dp-news-label">News:</span>
|
||||||
|
<a href={googleNewsUrl(r.asset.ticker)} target="_blank" rel="noopener noreferrer" class="dp-news-link">
|
||||||
|
Google News ↗
|
||||||
|
</a>
|
||||||
|
<a href={yahooNewsUrl(r.asset.ticker)} target="_blank" rel="noopener noreferrer" class="dp-news-link">
|
||||||
|
Yahoo Finance ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ══ RIGHT — verdict card (factor bar chart) ═════════════ -->
|
<!-- ══ RIGHT — factor score cards ════════════════════════ -->
|
||||||
<div class="dp-right">
|
<div class="dp-right">
|
||||||
<div class="dp-title">
|
<div class="dp-title">
|
||||||
Factor Scores
|
Factor Scores
|
||||||
<span class="dp-mode-note">({mode === 'inflated' ? 'Mkt-Adj' : 'Graham'})</span>
|
<span class="dp-mode-note">({mode === 'inflated' ? 'Mkt-Adj' : 'Graham'}) — click to learn more</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !v.audit?.passedGates && v.audit?.failures?.length}
|
{#if !v.audit?.passedGates && v.audit?.failures?.length}
|
||||||
@@ -476,23 +648,20 @@
|
|||||||
<div class="dp-failure-item">✗ {f}</div>
|
<div class="dp-failure-item">✗ {f}</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if breakdownEntries(v.audit?.breakdown).length}
|
{:else if factorCards(v.audit?.breakdown, m).length}
|
||||||
{@const entries = breakdownEntries(v.audit?.breakdown)}
|
{@const cards = factorCards(v.audit?.breakdown, m)}
|
||||||
{@const scale = maxAbs(v.audit?.breakdown)}
|
<div class="dp-factor-list">
|
||||||
<div class="dp-bar-chart">
|
{#each cards as card}
|
||||||
{#each entries as [factor, score]}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
{@const pct = Math.round((Math.abs(score) / scale) * 100)}
|
<div class="dp-factor-item" role="button" tabindex="0" onclick={() => openGlossaryTo(card.key)} onkeypress={(e) => e.key === 'Enter' && openGlossaryTo(card.key)}>
|
||||||
<div class="dp-bar-row">
|
<div class="dp-factor-top">
|
||||||
<span class="dp-bar-label">{factor}</span>
|
<span class="dp-factor-name">{card.name}</span>
|
||||||
<div class="dp-bar-track">
|
<span class="dp-factor-verdict {verdictClass(card.score)}">{verdictLabel(card.score)}</span>
|
||||||
<div
|
</div>
|
||||||
class="dp-bar-fill {score > 0 ? 'dp-bar-pos' : 'dp-bar-neg'}"
|
<div class="dp-factor-reason">{@html card.reason}</div>
|
||||||
style="width: {pct}%"
|
<div class="dp-bar-track">
|
||||||
></div>
|
<div class="dp-bar-fill {card.score > 0 ? 'dp-bar-pos' : 'dp-bar-neg'}" style="width:{card.pct}%"></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="dp-bar-val {score > 0 ? 'pos' : score < 0 ? 'neg' : ''}">
|
|
||||||
{score > 0 ? '+' : ''}{score}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -511,3 +680,18 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Glossary panel — z-index 99, below tearsheet/analysis sidebar (z 101) -->
|
||||||
|
<GlossaryPanel
|
||||||
|
open={glossaryOpen}
|
||||||
|
activeMetrics={activeGlossaryMetrics()}
|
||||||
|
focusKey={glossaryFocusKey}
|
||||||
|
onClose={() => { glossaryOpen = false; glossaryFocusKey = null; }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Signal modal — explains why? for any signal -->
|
||||||
|
<SignalModal
|
||||||
|
open={specModalRow !== null}
|
||||||
|
row={specModalRow}
|
||||||
|
onClose={() => (specModalRow = null)}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = false,
|
||||||
|
activeMetrics = [] as string[],
|
||||||
|
focusKey = null as string | null,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
activeMetrics?: string[];
|
||||||
|
focusKey?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let expandedItem = $state<string | null>(null);
|
||||||
|
let bodyEl = $state<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// When focusKey changes, expand and scroll to that item
|
||||||
|
$effect(() => {
|
||||||
|
if (focusKey && open) {
|
||||||
|
expandedItem = focusKey;
|
||||||
|
searchQuery = '';
|
||||||
|
tick().then(() => {
|
||||||
|
if (!bodyEl) return;
|
||||||
|
const el = bodyEl.querySelector(`[data-gkey="${focusKey}"]`);
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Glossary data ─────────────────────────────────────────────────────
|
||||||
|
type RangeBand = { val: string; label: string };
|
||||||
|
type GlossaryItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
category: 'Market Context' | 'Valuation' | 'Quality' | 'Risk' | 'Signals' | 'ETF' | 'Bond';
|
||||||
|
definition: string;
|
||||||
|
gate?: string;
|
||||||
|
goodRange?: RangeBand;
|
||||||
|
neutralRange?: RangeBand;
|
||||||
|
badRange?: RangeBand;
|
||||||
|
assetTypes?: ('STOCK' | 'ETF' | 'BOND')[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const GLOSSARY: GlossaryItem[] = [
|
||||||
|
// ── Market Context ─────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: '10Y',
|
||||||
|
label: '10Y Treasury Yield',
|
||||||
|
category: 'Market Context',
|
||||||
|
definition: 'The yield on 10-year US government bonds — the global risk-free rate benchmark. Drives discount rates for all assets: higher yield = lower present value of future earnings.',
|
||||||
|
gate: 'Rate regime: < 2% LOW | 2–5% NORMAL | > 5% HIGH. HIGH rates compress growth stock P/E multipliers.',
|
||||||
|
goodRange: { val: '2–4%', label: 'Normal, accommodative' },
|
||||||
|
neutralRange: { val: '4–5%', label: 'Elevated, watch growth' },
|
||||||
|
badRange: { val: '> 5%', label: 'HIGH regime, P/E compression' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'VIX',
|
||||||
|
label: 'VIX — Volatility Index',
|
||||||
|
category: 'Market Context',
|
||||||
|
definition: 'The CBOE Volatility Index — measures expected 30-day S&P 500 volatility derived from options prices. Known as the "fear gauge."',
|
||||||
|
gate: 'Volatility regime: < 15 CALM | 15–25 NORMAL | > 25 ELEVATED | > 35 EXTREME',
|
||||||
|
goodRange: { val: '< 15', label: 'Calm market, low fear' },
|
||||||
|
neutralRange: { val: '15–25', label: 'Normal uncertainty' },
|
||||||
|
badRange: { val: '> 25', label: 'Elevated fear' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Rate Regime',
|
||||||
|
label: 'Rate Regime',
|
||||||
|
category: 'Market Context',
|
||||||
|
definition: 'Derived from the 10Y Treasury yield. Controls how aggressively the INFLATED scoring mode adjusts P/E gates — HIGH rates tighten the multiplier from 1.5× to 1.2× of S&P P/E.',
|
||||||
|
gate: 'LOW < 2% | NORMAL 2–5% | HIGH > 5%',
|
||||||
|
goodRange: { val: 'LOW', label: 'Growth-friendly' },
|
||||||
|
neutralRange: { val: 'NORMAL', label: 'Balanced' },
|
||||||
|
badRange: { val: 'HIGH', label: 'Value favoured, growth penalised' },
|
||||||
|
},
|
||||||
|
// ── Valuation ──────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: 'P/E',
|
||||||
|
label: 'P/E Ratio',
|
||||||
|
category: 'Valuation',
|
||||||
|
definition: 'Price-to-Earnings: how many dollars investors pay per $1 of annual profit. Lower = cheaper relative to earnings.',
|
||||||
|
gate: 'Graham gate: ≤ 15× | Inflated gate: ≤ S&P P/E × 1.5 (live)',
|
||||||
|
goodRange: { val: '< 15×', label: 'Value / below sector avg' },
|
||||||
|
neutralRange: { val: '15–35×', label: 'Elevated but common' },
|
||||||
|
badRange: { val: '> 35×', label: 'Expensive without high growth' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'PEG',
|
||||||
|
label: 'PEG Ratio',
|
||||||
|
category: 'Valuation',
|
||||||
|
definition: 'P/E divided by earnings growth rate. Adjusts for growth — a 30× P/E stock growing 30% has PEG 1.0, same as a 15× stock growing 15%.',
|
||||||
|
gate: 'Gate: < 1.0 (Lynch standard) · Weight: 2',
|
||||||
|
goodRange: { val: '< 1.0', label: 'Bargain' },
|
||||||
|
neutralRange: { val: '1.0–2.0', label: 'Fair' },
|
||||||
|
badRange: { val: '> 2.0', label: 'Costly' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'DCF Safety',
|
||||||
|
label: 'DCF Margin of Safety',
|
||||||
|
category: 'Valuation',
|
||||||
|
definition: 'How much below the discounted cash flow intrinsic value the stock trades. Positive = undervalued vs. DCF model; negative = overvalued. Requires positive FCF to compute.',
|
||||||
|
gate: '≥ +20% → full score | 0–20% → +1 | -20–0% → -1 | < -20% → negative score',
|
||||||
|
goodRange: { val: '> +20%', label: 'Significant discount' },
|
||||||
|
neutralRange: { val: '0–20%', label: 'Modest discount' },
|
||||||
|
badRange: { val: '< -20%', label: 'Premium to fair value' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Upside',
|
||||||
|
label: 'Analyst Price Target Upside',
|
||||||
|
category: 'Valuation',
|
||||||
|
definition: 'Percentage gap between current price and Wall Street consensus target price. Positive = analysts expect the stock to rise.',
|
||||||
|
gate: 'Risk flag if ≥ +25% upside or ≤ -15% downside',
|
||||||
|
goodRange: { val: '+5–20%', label: 'Moderate consensus upside' },
|
||||||
|
neutralRange: { val: '0–5%', label: 'Fairly priced' },
|
||||||
|
badRange: { val: '< -10%', label: 'Analysts bearish' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
// ── Quality ────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: 'ROE%',
|
||||||
|
label: 'Return on Equity',
|
||||||
|
category: 'Quality',
|
||||||
|
definition: 'Net income as a % of shareholders\' equity. Measures how efficiently management generates profit from invested capital.',
|
||||||
|
gate: 'Gate: ROE ≥ 15%',
|
||||||
|
goodRange: { val: '> 20%', label: 'Excellent capital efficiency' },
|
||||||
|
neutralRange: { val: '10–20%', label: 'Adequate' },
|
||||||
|
badRange: { val: '< 10%', label: 'Poor capital use' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'OpMgn%',
|
||||||
|
label: 'Operating Margin',
|
||||||
|
category: 'Quality',
|
||||||
|
definition: 'Operating profit as a % of revenue — what\'s left after COGS and operating expenses, before interest and taxes.',
|
||||||
|
gate: 'Gate: Op Margin ≥ 10%',
|
||||||
|
goodRange: { val: '> 20%', label: 'High quality business' },
|
||||||
|
neutralRange: { val: '5–20%', label: 'Modest margins' },
|
||||||
|
badRange: { val: '< 5%', label: 'Thin, fragile' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'GrossM%',
|
||||||
|
label: 'Gross Margin',
|
||||||
|
category: 'Quality',
|
||||||
|
definition: 'Revenue minus cost of goods sold, as a %. Shows pricing power and production efficiency before overhead.',
|
||||||
|
gate: 'Informational — not a hard gate, used contextually',
|
||||||
|
goodRange: { val: '> 50%', label: 'Software / services quality' },
|
||||||
|
neutralRange: { val: '15–50%', label: 'Moderate' },
|
||||||
|
badRange: { val: '< 15%', label: 'Commodity-like, price-taker' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FCF Yld%',
|
||||||
|
label: 'Free Cash Flow Yield',
|
||||||
|
category: 'Quality',
|
||||||
|
definition: 'Free cash flow per share divided by price — cash the business actually generates, expressed as a yield. Unlike earnings, FCF is hard to fake.',
|
||||||
|
gate: 'Gate: FCF > 0 (negative FCF = gate fail) | Weight: 3× in scoring',
|
||||||
|
goodRange: { val: '> 5%', label: 'Strong cash generation' },
|
||||||
|
neutralRange: { val: '0–5%', label: 'Weak positive' },
|
||||||
|
badRange: { val: '< 0%', label: 'Cash-burning' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Analyst',
|
||||||
|
label: 'Analyst Consensus Rating',
|
||||||
|
category: 'Quality',
|
||||||
|
definition: 'Wall Street average recommendation on a 1–5 scale (Yahoo). 1 = Strong Buy, 5 = Strong Sell. Requires ≥ 3 analysts for signal to fire.',
|
||||||
|
gate: '≤ 2.0 → full score | ≤ 3.0 → +1 | ≤ 4.0 → -1 | > 4.0 → negative score',
|
||||||
|
goodRange: { val: '1.0–2.5', label: 'Buy consensus' },
|
||||||
|
neutralRange: { val: '2.5–4.0', label: 'Neutral / Hold' },
|
||||||
|
badRange: { val: '> 4.0', label: 'Sell consensus' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Revenue',
|
||||||
|
label: 'Revenue Growth',
|
||||||
|
category: 'Quality',
|
||||||
|
definition: 'Year-over-year percentage change in total revenue. Measures whether the business is expanding its top line. A secondary scoring factor — positive growth adds to score, declining revenue subtracts.',
|
||||||
|
gate: 'Gate: Revenue growth > 0% for positive contribution | Weight: 2× in scoring',
|
||||||
|
goodRange: { val: '> 10%', label: 'Strong expansion' },
|
||||||
|
neutralRange: { val: '0–10%', label: 'Slow growth' },
|
||||||
|
badRange: { val: '< 0%', label: 'Shrinking top line' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
// ── Risk ───────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: 'D/E',
|
||||||
|
label: 'Debt-to-Equity Ratio',
|
||||||
|
category: 'Risk',
|
||||||
|
definition: 'Total debt divided by shareholders\' equity. Measures financial leverage — how much borrowed money vs. owned capital the company uses.',
|
||||||
|
gate: 'Gate: D/E ≤ 1.5× | Tech: ≤ 2.0× | Financials: gate disabled (scored on P/B instead)',
|
||||||
|
goodRange: { val: '< 0.5×', label: 'Conservative' },
|
||||||
|
neutralRange: { val: '0.5–1.5×', label: 'Moderate' },
|
||||||
|
badRange: { val: '> 2.0×', label: 'High leverage risk' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '52W Chg',
|
||||||
|
label: '52-Week Price Change',
|
||||||
|
category: 'Risk',
|
||||||
|
definition: 'Total % price return over the past year. Captures trend strength and momentum.',
|
||||||
|
gate: 'Risk flag: ≥ +50% (at peak, reversal risk) | ≤ -30% (significant drawdown)',
|
||||||
|
goodRange: { val: '+5–30%', label: 'Steady uptrend' },
|
||||||
|
neutralRange: { val: '-5–+5%', label: 'Flat / sideways' },
|
||||||
|
badRange: { val: '< -30%', label: 'Significant drawdown' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'From High',
|
||||||
|
label: 'Distance from 52-Week High',
|
||||||
|
category: 'Risk',
|
||||||
|
definition: 'How far (%) the current price sits below the 52-week peak. Negative = below peak. A -15% reading means the stock is 15% off its high.',
|
||||||
|
gate: 'Risk flag if > -20% from high (at or near peak)',
|
||||||
|
goodRange: { val: '-5–25%', label: 'Healthy pullback' },
|
||||||
|
neutralRange: { val: '-25–40%', label: 'Larger drawdown' },
|
||||||
|
badRange: { val: '0–3%', label: 'At peak, limited buffer' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
// ── Signals ────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: 'Graham',
|
||||||
|
label: 'Graham (Fundamental) Score',
|
||||||
|
category: 'Signals',
|
||||||
|
definition: 'Strict value-investing score using fixed Graham gates: P/E ≤ 15×, PEG ≤ 1.0, D/E ≤ 1.5×, ROE ≥ 15%, FCF > 0. Does not adjust for market conditions — these thresholds never move.',
|
||||||
|
gate: 'All gates fixed regardless of S&P P/E or rate regime',
|
||||||
|
goodRange: { val: 'PASS', label: 'Passes all Graham gates' },
|
||||||
|
neutralRange: { val: 'PARTIAL', label: 'Passes some, fails others' },
|
||||||
|
badRange: { val: 'FAIL', label: 'Fails one or more hard gates' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Mkt-Adj',
|
||||||
|
label: 'Market-Adjusted Score',
|
||||||
|
category: 'Signals',
|
||||||
|
definition: 'Relaxed scoring mode that calibrates gates to live market benchmarks. P/E gate = S&P P/E × 1.5 (or × 1.2 in HIGH rate regime). Reflects what is "acceptable" in today\'s market, not absolute value.',
|
||||||
|
gate: 'P/E gate: S&P P/E × 1.5 (NORMAL) or × 1.2 (HIGH) | Tech P/E: XLK P/E × 1.3',
|
||||||
|
goodRange: { val: 'PASS', label: 'Passes mkt-adjusted gates' },
|
||||||
|
neutralRange: { val: 'PARTIAL', label: 'Borderline vs live benchmarks' },
|
||||||
|
badRange: { val: 'FAIL', label: 'Fails even relaxed gates' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'signal',
|
||||||
|
label: 'Signal',
|
||||||
|
category: 'Signals',
|
||||||
|
definition: 'Overall recommendation derived by comparing Market-Adjusted and Graham (fundamental) scores.',
|
||||||
|
gate: 'Strong Buy = passes both | Momentum = passes Mkt-Adj only | Speculation = passes Mkt-Adj, fails Graham | Neutral = borderline | Avoid = fails both',
|
||||||
|
goodRange: { val: '✅ ⚡', label: 'Strong Buy / Momentum' },
|
||||||
|
neutralRange: { val: '🔄', label: 'Neutral — hold' },
|
||||||
|
badRange: { val: '⚠️ ❌', label: 'Speculation / Avoid' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'score',
|
||||||
|
label: 'Score (dot scale)',
|
||||||
|
category: 'Signals',
|
||||||
|
definition: 'Weighted sum of factor scores (ROE, FCF, margin, PEG, revenue growth, analyst, DCF). Displayed as ●●●●○ dots out of 5 + raw number.',
|
||||||
|
gate: 'Positive factors add to score; negative riskFlags subtract. Gate failures bypass scoring entirely (shown as ✗).',
|
||||||
|
goodRange: { val: '> 12', label: 'High conviction' },
|
||||||
|
neutralRange: { val: '6–12', label: 'Borderline' },
|
||||||
|
badRange: { val: '< 6', label: 'Weak factors' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Cap Tier',
|
||||||
|
label: 'Market Cap Tier',
|
||||||
|
category: 'Signals',
|
||||||
|
definition: 'Size classification based on market capitalisation. Mega Cap (> $200B), Large ($10–200B), Mid ($2–10B), Small ($300M–$2B), Micro (< $300M).',
|
||||||
|
gate: 'Informational — not a gate. Useful for position sizing and risk calibration.',
|
||||||
|
goodRange: { val: 'Mega / Large', label: 'Most liquid' },
|
||||||
|
neutralRange: { val: 'Mid', label: 'Balanced' },
|
||||||
|
badRange: { val: 'Micro', label: 'High vol, thin liquidity' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Style',
|
||||||
|
label: 'Growth / Style Category',
|
||||||
|
category: 'Signals',
|
||||||
|
definition: 'Derived from revenue growth and earnings growth. High Growth (rev ≥ 15% or earnings ≥ 20%), Growth (5–15%), Value (< 5% + yield ≥ 3%), Stable, Turnaround, Declining.',
|
||||||
|
gate: 'Informational — not a gate. Helps match the stock to your strategy.',
|
||||||
|
goodRange: { val: 'High Growth / Growth', label: 'Matches momentum strategy' },
|
||||||
|
neutralRange: { val: 'Stable / Value', label: 'Income / defensive' },
|
||||||
|
badRange: { val: 'Declining', label: 'Revenue shrinking > -5%' },
|
||||||
|
assetTypes: ['STOCK'],
|
||||||
|
},
|
||||||
|
// ── ETF ────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: 'Exp Ratio%',
|
||||||
|
label: 'Expense Ratio',
|
||||||
|
category: 'ETF',
|
||||||
|
definition: 'Annual management fee as a % of AUM. Deducted from returns automatically. Lower is always better — costs compound against you.',
|
||||||
|
gate: 'Hard gate: Expense Ratio ≤ 0.20%',
|
||||||
|
goodRange: { val: '< 0.10%', label: 'Index-like, minimal drag' },
|
||||||
|
neutralRange: { val: '0.10–0.50%', label: 'Acceptable' },
|
||||||
|
badRange: { val: '> 0.50%', label: 'High cost drag' },
|
||||||
|
assetTypes: ['ETF'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '5Y Return%',
|
||||||
|
label: '5-Year Annualised Return',
|
||||||
|
category: 'ETF',
|
||||||
|
definition: 'Compound annual growth rate over 5 years. The S&P 500 long-run average is ~10%; use that as a baseline.',
|
||||||
|
gate: 'Gate: 5Y Return ≥ 8% (S&P long-run floor)',
|
||||||
|
goodRange: { val: '> 12%', label: 'Outperforming market' },
|
||||||
|
neutralRange: { val: '8–12%', label: 'Market-rate returns' },
|
||||||
|
badRange: { val: '< 6%', label: 'Underperforming bonds + inflation' },
|
||||||
|
assetTypes: ['ETF'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Yield%',
|
||||||
|
label: 'Distribution Yield',
|
||||||
|
category: 'ETF',
|
||||||
|
definition: 'Annual income distributions (dividends, interest) as a % of NAV. Important for income-focused or REIT ETFs.',
|
||||||
|
gate: 'REIT ETF: Yield floor based on XLRE yield × regime factor',
|
||||||
|
goodRange: { val: '> 3%', label: 'Strong income' },
|
||||||
|
neutralRange: { val: '1–3%', label: 'Low but positive' },
|
||||||
|
badRange: { val: '< 1%', label: 'Insufficient for income' },
|
||||||
|
assetTypes: ['ETF'],
|
||||||
|
},
|
||||||
|
// ── Bond ───────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
key: 'YTM%',
|
||||||
|
label: 'Yield to Maturity',
|
||||||
|
category: 'Bond',
|
||||||
|
definition: 'Total return if you hold the bond to maturity — includes coupon payments plus any price gain/loss vs. par. The true all-in yield.',
|
||||||
|
gate: 'Spread gate: YTM must exceed risk-free rate by ≥ 1.5% (NORMAL) or ≥ 1.8% (HIGH rates)',
|
||||||
|
goodRange: { val: 'Sprd > 2%', label: 'Good compensation for risk' },
|
||||||
|
neutralRange: { val: '1–2%', label: 'Adequate spread' },
|
||||||
|
badRange: { val: '< 1%', label: 'Not compensating for credit risk' },
|
||||||
|
assetTypes: ['BOND'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Duration',
|
||||||
|
label: 'Duration (years)',
|
||||||
|
category: 'Bond',
|
||||||
|
definition: 'Sensitivity to interest rate changes. A duration of 5 means a 1% rate rise → ~5% price drop. Shorter = less rate risk.',
|
||||||
|
gate: 'Gate: Duration ≤ 7 years',
|
||||||
|
goodRange: { val: '< 4 yrs', label: 'Low rate sensitivity' },
|
||||||
|
neutralRange: { val: '4–7 yrs', label: 'Moderate' },
|
||||||
|
badRange: { val: '> 10 yrs', label: 'High rate risk' },
|
||||||
|
assetTypes: ['BOND'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Rating',
|
||||||
|
label: 'Credit Rating',
|
||||||
|
category: 'Bond',
|
||||||
|
definition: 'Agency rating of default probability: AAA (safest) → AA → A → BBB (investment grade floor) → BB → B → CCC (junk).',
|
||||||
|
gate: 'Hard gate: Rating ≥ BBB (investment-grade, numeric ≥ 7)',
|
||||||
|
goodRange: { val: 'AAA–A', label: 'Very low default risk' },
|
||||||
|
neutralRange: { val: 'BBB', label: 'Investment-grade floor' },
|
||||||
|
badRange: { val: '≤ BB', label: 'High yield / junk' },
|
||||||
|
assetTypes: ['BOND'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CATEGORIES = ['Market Context', 'Valuation', 'Quality', 'Risk', 'Signals', 'ETF', 'Bond'] as const;
|
||||||
|
|
||||||
|
function filteredItems(): GlossaryItem[] {
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
if (!q) return GLOSSARY;
|
||||||
|
return GLOSSARY.filter(
|
||||||
|
(item) =>
|
||||||
|
item.label.toLowerCase().includes(q) ||
|
||||||
|
item.definition.toLowerCase().includes(q) ||
|
||||||
|
item.category.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemsForCategory(cat: string): GlossaryItem[] {
|
||||||
|
return filteredItems().filter((i) => i.category === cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(item: GlossaryItem): boolean {
|
||||||
|
return activeMetrics.some(
|
||||||
|
(k) => k === item.key || k === item.label,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleItem(key: string) {
|
||||||
|
expandedItem = expandedItem === key ? null : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<!-- Click-outside backdrop — thin, no visual overlay, just captures clicks -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="glossary-backdrop" onclick={onClose}></div>
|
||||||
|
|
||||||
|
<aside class="glossary-panel" aria-label="Metrics Glossary">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="glossary-header">
|
||||||
|
<span class="glossary-title"><span class="glossary-title-q">?</span> Metric Glossary</span>
|
||||||
|
<button class="glossary-close" onclick={onClose} aria-label="Close glossary">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="glossary-search-wrap">
|
||||||
|
<input
|
||||||
|
class="glossary-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search metrics…"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
aria-label="Search glossary"
|
||||||
|
/>
|
||||||
|
{#if searchQuery}
|
||||||
|
<button class="glossary-search-clear" onclick={() => (searchQuery = '')} aria-label="Clear search">✕</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Context banner — fixed between search and body, only when row is selected -->
|
||||||
|
{#if activeMetrics.length > 0}
|
||||||
|
<div class="glossary-ctx-banner">
|
||||||
|
✦ Highlighted metrics are relevant to the selected row
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="glossary-body" bind:this={bodyEl}>
|
||||||
|
|
||||||
|
{#each CATEGORIES as cat}
|
||||||
|
{@const items = itemsForCategory(cat)}
|
||||||
|
{#if items.length > 0}
|
||||||
|
<div class="glossary-category">
|
||||||
|
<div class="glossary-cat-header">{cat}</div>
|
||||||
|
{#each items as item}
|
||||||
|
{@const active = isActive(item)}
|
||||||
|
{@const isExpanded = expandedItem === item.key}
|
||||||
|
<div
|
||||||
|
class="glossary-item"
|
||||||
|
class:glossary-item-active={active}
|
||||||
|
class:glossary-item-open={isExpanded}
|
||||||
|
data-gkey={item.key}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="glossary-item-trigger"
|
||||||
|
onclick={() => toggleItem(item.key)}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
<span class="glossary-item-label">
|
||||||
|
{#if active}<span class="glossary-active-dot"></span>{/if}
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span class="glossary-cat-tag gcat-{cat.toLowerCase().replace(/\s/g,'-')}">{cat}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isExpanded}
|
||||||
|
<div class="glossary-item-body">
|
||||||
|
<p class="glossary-definition">{item.definition}</p>
|
||||||
|
|
||||||
|
{#if item.gate}
|
||||||
|
<div class="glossary-gate-box">
|
||||||
|
<code>{item.gate}</code>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if item.goodRange || item.neutralRange || item.badRange}
|
||||||
|
<div class="glossary-range-pills">
|
||||||
|
{#if item.goodRange}
|
||||||
|
<span class="glossary-range-pill grange-good">{item.goodRange.val}</span>
|
||||||
|
{/if}
|
||||||
|
{#if item.neutralRange}
|
||||||
|
<span class="glossary-range-pill grange-neutral">{item.neutralRange.val}</span>
|
||||||
|
{/if}
|
||||||
|
{#if item.badRange}
|
||||||
|
<span class="glossary-range-pill grange-bad">{item.badRange.val}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="glossary-range-labels">
|
||||||
|
{#if item.goodRange}
|
||||||
|
<span class="grlabel-good">{item.goodRange.label}</span>
|
||||||
|
{/if}
|
||||||
|
{#if item.neutralRange}
|
||||||
|
<span class="grlabel-neutral">{item.neutralRange.label}</span>
|
||||||
|
{/if}
|
||||||
|
{#if item.badRange}
|
||||||
|
<span class="grlabel-bad">{item.badRange.label}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if filteredItems().length === 0}
|
||||||
|
<div class="glossary-empty">No metrics match "{searchQuery}"</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AssetResult } from '$lib/types.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = false,
|
||||||
|
row,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
row: AssetResult | null;
|
||||||
|
onClose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function handleBackdrop(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignalKey = 'strong' | 'momentum' | 'spec' | 'neutral' | 'avoid';
|
||||||
|
|
||||||
|
function sigKey(signal: string | undefined): SignalKey {
|
||||||
|
const s = signal ?? '';
|
||||||
|
if (s.includes('Strong')) return 'strong';
|
||||||
|
if (s.includes('Momentum')) return 'momentum';
|
||||||
|
if (s.includes('Speculation')) return 'spec';
|
||||||
|
if (s.includes('Neutral')) return 'neutral';
|
||||||
|
return 'avoid';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNAL_META: Record<SignalKey, {
|
||||||
|
emoji: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
tip: string;
|
||||||
|
color: 'green' | 'blue' | 'amber' | 'gray' | 'red';
|
||||||
|
}> = {
|
||||||
|
strong: {
|
||||||
|
emoji: '✅',
|
||||||
|
title: 'Strong Buy',
|
||||||
|
summary: 'Passes both market-adjusted AND Graham\'s fundamental gates. The stock meets strict value criteria regardless of current market conditions. This is the highest confidence signal.',
|
||||||
|
tip: 'Strong Buy signals have the widest margin of safety. Size position accordingly — typically 3–5% of portfolio.',
|
||||||
|
color: 'green',
|
||||||
|
},
|
||||||
|
momentum: {
|
||||||
|
emoji: '⚡',
|
||||||
|
title: 'Momentum',
|
||||||
|
summary: 'Passes market-adjusted gates but only partially satisfies Graham criteria. The stock is attractively priced relative to today\'s market, though it wouldn\'t clear value investors\' stricter standards. Suitable for trend-following strategies.',
|
||||||
|
tip: 'Momentum signals ride the current market environment. They work until they don\'t — set a stop-loss or monitor quarterly.',
|
||||||
|
color: 'blue',
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
emoji: '⚠️',
|
||||||
|
title: 'Speculation',
|
||||||
|
summary: 'Passes market-adjusted gates but fails Graham\'s fundamental rules. The stock is acceptable at today\'s elevated valuations, but would not meet a strict value investor\'s criteria. Treat as a growth or momentum play — not a deep value position.',
|
||||||
|
tip: 'Speculation signals are valid positions in a bull market. Size smaller than a Strong Buy and monitor P/E compression risk.',
|
||||||
|
color: 'amber',
|
||||||
|
},
|
||||||
|
neutral: {
|
||||||
|
emoji: '🔄',
|
||||||
|
title: 'Neutral',
|
||||||
|
summary: 'Borderline — passes some gates, fails others, or falls in hold territory in one or both scoring lenses. Not a strong buy or a clear sell. Suitable for existing positions you\'re monitoring, but not a new entry signal.',
|
||||||
|
tip: 'Neutral means "hold, don\'t add." If you own it, keep watching the metrics that are borderline.',
|
||||||
|
color: 'gray',
|
||||||
|
},
|
||||||
|
avoid: {
|
||||||
|
emoji: '❌',
|
||||||
|
title: 'Avoid',
|
||||||
|
summary: 'Fails both market-adjusted AND fundamental scoring lenses. The stock does not meet quality gates or the risk-adjusted return does not justify the price. This is a high-risk entry.',
|
||||||
|
tip: 'Avoid signals are not necessarily bad businesses — they may be great companies at bad prices. Revisit when the verdict changes.',
|
||||||
|
color: 'red',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function gateRows(row: AssetResult | null) {
|
||||||
|
if (!row) return [];
|
||||||
|
const m = row.asset.displayMetrics ?? {};
|
||||||
|
const graham = row.fundamental.audit;
|
||||||
|
|
||||||
|
const items: { pass: boolean; label: string; detail: string; value: string }[] = [];
|
||||||
|
|
||||||
|
const pe = m['P/E'];
|
||||||
|
if (pe && pe !== '—') {
|
||||||
|
const peNum = parseFloat(String(pe).replace('×',''));
|
||||||
|
const grahamFail = graham.failures?.some((f: string) => f.toLowerCase().includes('p/e'));
|
||||||
|
const mktPass = row.inflated.audit?.passedGates;
|
||||||
|
if (!isNaN(peNum)) {
|
||||||
|
items.push({
|
||||||
|
pass: !grahamFail,
|
||||||
|
label: 'P/E Ratio',
|
||||||
|
detail: grahamFail ? `Graham gate (15×) failed${mktPass ? ' — mkt-adjusted passed' : ''}` : 'Passes both P/E gates',
|
||||||
|
value: `${pe}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const peg = m['PEG'];
|
||||||
|
if (peg && peg !== '—') {
|
||||||
|
const pegNum = parseFloat(String(peg));
|
||||||
|
if (!isNaN(pegNum)) {
|
||||||
|
items.push({
|
||||||
|
pass: pegNum < 1.0,
|
||||||
|
label: 'PEG Ratio',
|
||||||
|
detail: pegNum < 1.0 ? 'Below 1.0 — growth is reasonably priced' : 'Above 1.0 — paying a premium for growth',
|
||||||
|
value: `${peg}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const de = m['D/E'];
|
||||||
|
if (de && de !== '—') {
|
||||||
|
const deNum = parseFloat(String(de));
|
||||||
|
if (!isNaN(deNum)) {
|
||||||
|
items.push({
|
||||||
|
pass: deNum < 1.5,
|
||||||
|
label: 'Debt / Equity',
|
||||||
|
detail: deNum < 1.5 ? 'Conservative leverage' : 'Elevated leverage — adds risk',
|
||||||
|
value: `${de}×`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fcf = m['FCF Yld%'];
|
||||||
|
if (fcf && fcf !== '—') {
|
||||||
|
const fcfNum = parseFloat(String(fcf));
|
||||||
|
if (!isNaN(fcfNum)) {
|
||||||
|
items.push({
|
||||||
|
pass: fcfNum > 0,
|
||||||
|
label: 'FCF Yield',
|
||||||
|
detail: fcfNum > 0 ? 'Positive free cash flow' : 'Negative FCF — company is burning cash',
|
||||||
|
value: `${fcf}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roe = m['ROE%'];
|
||||||
|
if (roe && roe !== '—') {
|
||||||
|
const roeNum = parseFloat(String(roe));
|
||||||
|
if (!isNaN(roeNum)) {
|
||||||
|
items.push({
|
||||||
|
pass: roeNum > 15,
|
||||||
|
label: 'Return on Equity',
|
||||||
|
detail: roeNum > 15 ? 'Above 15% — good capital efficiency' : 'Below 15% preferred threshold',
|
||||||
|
value: `${roe}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
{#if open && row}
|
||||||
|
{@const sk = sigKey(row.signal)}
|
||||||
|
{@const meta = SIGNAL_META[sk]}
|
||||||
|
{@const m = row.asset.displayMetrics ?? {}}
|
||||||
|
{@const mktPass = row.inflated.audit?.passedGates}
|
||||||
|
{@const graOk = row.fundamental.audit?.passedGates}
|
||||||
|
{@const gates = gateRows(row)}
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="sm-backdrop" onclick={handleBackdrop}>
|
||||||
|
<div class="sm-modal sm-modal-{meta.color}">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="sm-header">
|
||||||
|
<div>
|
||||||
|
<div class="sm-title">{meta.emoji} Why "{meta.title}"?</div>
|
||||||
|
<div class="sm-sub">{row.asset.ticker} · {m['Price'] ?? ''}</div>
|
||||||
|
</div>
|
||||||
|
<button class="sm-close" onclick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary box -->
|
||||||
|
<div class="sm-summary sm-summary-{meta.color}">
|
||||||
|
<p>{meta.summary}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Side-by-side verdict comparison (stocks only) -->
|
||||||
|
{#if row.inflated && row.fundamental}
|
||||||
|
<div class="sm-verdict-compare">
|
||||||
|
<div class="sm-vb" class:sm-vb-pass={mktPass} class:sm-vb-fail={!mktPass}>
|
||||||
|
<div class="sm-vb-label" class:pass={mktPass} class:fail={!mktPass}>Mkt-Adjusted Mode</div>
|
||||||
|
<div class="sm-vb-verdict" class:pass={mktPass} class:fail={!mktPass}>{mktPass ? '✅ PASSES' : '✗ FAILS'}</div>
|
||||||
|
<div class="sm-vb-detail">
|
||||||
|
{mktPass ? 'Gates calibrated to live S&P P/E. Passes the relaxed market-adjusted threshold.' : 'Fails even the relaxed market-adjusted gates.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sm-vb" class:sm-vb-pass={graOk} class:sm-vb-fail={!graOk}>
|
||||||
|
<div class="sm-vb-label" class:pass={graOk} class:fail={!graOk}>Graham (Fundamental) Mode</div>
|
||||||
|
<div class="sm-vb-verdict" class:pass={graOk} class:fail={!graOk}>{graOk ? '✅ PASSES' : '✗ FAILS'}</div>
|
||||||
|
<div class="sm-vb-detail">
|
||||||
|
{graOk ? 'Passes Graham\'s strict 15× P/E and quality gates.' : 'Graham\'s strict 15× P/E gate is a hard rule. Fails at elevated valuations.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Gate breakdown (if we have data) -->
|
||||||
|
{#if gates.length > 0}
|
||||||
|
<div class="sm-gates">
|
||||||
|
<div class="sm-gates-title">Gate Breakdown</div>
|
||||||
|
{#each gates as g}
|
||||||
|
<div class="sm-gate-row" class:g-pass={g.pass} class:g-fail={!g.pass}>
|
||||||
|
<span class="sm-gate-icon">{g.pass ? '✓' : '✗'}</span>
|
||||||
|
<span class="sm-gate-text"><b>{g.label}</b> — {g.detail}</span>
|
||||||
|
<span class="sm-gate-value">{g.value}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Footer tip -->
|
||||||
|
<div class="sm-footer">
|
||||||
|
💡 <strong>What to do:</strong> {meta.tip}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sm-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: #00000088;
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm-modal {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-input);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 24px 64px #00000088;
|
||||||
|
animation: modal-in 0.18s ease;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-in {
|
||||||
|
from { opacity: 0; transform: scale(0.96) translateY(8px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 3px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm-close {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.sm-close:hover { background: var(--bg-elevated); color: var(--text-primary); }
|
||||||
|
|
||||||
|
.sm-summary {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.sm-summary p { font-size: 13px; line-height: 1.6; margin: 0; }
|
||||||
|
.sm-summary-green { background: #0a2e1a; border: 1px solid #1a4a2a; }
|
||||||
|
.sm-summary-green p { color: #86efac; }
|
||||||
|
.sm-summary-blue { background: #0a1e3a; border: 1px solid #1a3a6a; }
|
||||||
|
.sm-summary-blue p { color: #93c5fd; }
|
||||||
|
.sm-summary-amber { background: #2a1a00; border: 1px solid #4a3000; }
|
||||||
|
.sm-summary-amber p { color: #fbbf24; }
|
||||||
|
.sm-summary-gray { background: var(--bg-elevated); border: 1px solid var(--border); }
|
||||||
|
.sm-summary-gray p { color: var(--text-muted); }
|
||||||
|
.sm-summary-red { background: #2a0d0d; border: 1px solid #4a1a1a; }
|
||||||
|
.sm-summary-red p { color: #fca5a5; }
|
||||||
|
|
||||||
|
.sm-verdict-compare {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm-vb {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.sm-vb.sm-vb-pass { background: #0a2e1a; border-color: #1a4a2a; }
|
||||||
|
.sm-vb.sm-vb-fail { background: #2a0d0d; border-color: #4a1a1a; }
|
||||||
|
|
||||||
|
.sm-vb-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.sm-vb-label.pass { color: #4ade80; }
|
||||||
|
.sm-vb-label.fail { color: #f87171; }
|
||||||
|
|
||||||
|
.sm-vb-verdict {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.sm-vb-verdict.pass { color: #4ade80; }
|
||||||
|
.sm-vb-verdict.fail { color: #f87171; }
|
||||||
|
|
||||||
|
.sm-vb-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm-gates { margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.sm-gates-title {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm-gate-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.sm-gate-row.g-pass { background: #0a2e1a; border: 1px solid #1a4a2a; }
|
||||||
|
.sm-gate-row.g-fail { background: #2a0d0d; border: 1px solid #4a1a1a; }
|
||||||
|
|
||||||
|
.sm-gate-icon { font-size: 13px; flex-shrink: 0; }
|
||||||
|
.g-pass .sm-gate-icon { color: #4ade80; }
|
||||||
|
.g-fail .sm-gate-icon { color: #f87171; }
|
||||||
|
|
||||||
|
.sm-gate-text { flex: 1; color: var(--text-secondary); }
|
||||||
|
.sm-gate-text :global(b) { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.sm-gate-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm-footer {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AssetResult } from '$lib/types.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = false,
|
||||||
|
row,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
row: AssetResult | null;
|
||||||
|
onClose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function handleBackdrop(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build gate breakdown from audit data
|
||||||
|
function gateRows(row: AssetResult | null) {
|
||||||
|
if (!row) return [];
|
||||||
|
const mkt = row.inflated.audit;
|
||||||
|
const graham = row.fundamental.audit;
|
||||||
|
const m = row.asset.displayMetrics ?? {};
|
||||||
|
|
||||||
|
const items: { pass: boolean; label: string; detail: string; value: string }[] = [];
|
||||||
|
|
||||||
|
// P/E — the key differentiator for Speculation
|
||||||
|
const pe = m['P/E'];
|
||||||
|
if (pe && pe !== '—') {
|
||||||
|
if (graham.failures?.some((f: string) => f.toLowerCase().includes('p/e'))) {
|
||||||
|
items.push({ pass: false, label: 'P/E Ratio', detail: 'Graham gate (15×) failed', value: `${pe} > 15×` });
|
||||||
|
}
|
||||||
|
if (mkt.passedGates) {
|
||||||
|
items.push({ pass: true, label: 'P/E Ratio', detail: 'Mkt-adjusted gate passed', value: `${pe} < mkt threshold` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PEG
|
||||||
|
const peg = m['PEG'];
|
||||||
|
if (peg && peg !== '—') {
|
||||||
|
const pegNum = parseFloat(String(peg));
|
||||||
|
items.push({ pass: !isNaN(pegNum) && pegNum < 1.0, label: 'PEG Ratio', detail: pegNum < 1.0 ? 'Paying less than growth justifies' : 'PEG above 1.0 — paying a growth premium', value: `${peg} ${pegNum < 1.0 ? '<' : '>'} 1.0` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// D/E
|
||||||
|
const de = m['D/E'];
|
||||||
|
if (de && de !== '—') {
|
||||||
|
const deNum = parseFloat(String(de));
|
||||||
|
items.push({ pass: !isNaN(deNum) && deNum < 1.5, label: 'Debt / Equity', detail: deNum < 1.5 ? 'Conservative leverage' : 'Elevated leverage', value: `${de}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// FCF
|
||||||
|
const fcf = m['FCF Yld%'];
|
||||||
|
if (fcf && fcf !== '—') {
|
||||||
|
const fcfNum = parseFloat(String(fcf));
|
||||||
|
items.push({ pass: !isNaN(fcfNum) && fcfNum > 0, label: 'FCF Yield', detail: fcfNum > 0 ? 'Positive free cash flow' : 'Negative free cash flow — cash burn', value: `${fcf}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ROE
|
||||||
|
const roe = m['ROE%'];
|
||||||
|
if (roe && roe !== '—') {
|
||||||
|
const roeNum = parseFloat(String(roe));
|
||||||
|
items.push({ pass: !isNaN(roeNum) && roeNum > 15, label: 'Return on Equity', detail: roeNum > 15 ? 'Above 15% threshold — quality signal' : 'Below 15% preferred threshold', value: `${roe}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
{#if open && row}
|
||||||
|
{@const m = row.asset.displayMetrics ?? {}}
|
||||||
|
{@const mktPass = row.inflated.audit?.passedGates}
|
||||||
|
{@const grahamOk = row.fundamental.audit?.passedGates}
|
||||||
|
{@const gates = gateRows(row)}
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="spec-backdrop" onclick={handleBackdrop}>
|
||||||
|
<div class="spec-modal">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="spec-modal-header">
|
||||||
|
<div>
|
||||||
|
<div class="spec-modal-title">⚡ Why "Speculation"?</div>
|
||||||
|
<div class="spec-modal-sub">{row.asset.ticker} · {m['Price'] ?? ''}</div>
|
||||||
|
</div>
|
||||||
|
<button class="spec-modal-close" onclick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plain-English summary -->
|
||||||
|
<div class="spec-summary">
|
||||||
|
<p><strong>Passes market-adjusted gates but fails Graham's strict fundamental rules.</strong> This stock is attractive at today's market valuations, but would not meet a value investor's stricter criteria. Treat as a momentum or growth play — not a deep value position.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Side-by-side verdict comparison -->
|
||||||
|
<div class="spec-verdict-compare">
|
||||||
|
<div class="spec-vb" class:spec-vb-pass={mktPass} class:spec-vb-fail={!mktPass}>
|
||||||
|
<div class="spec-vb-label" class:pass={mktPass} class:fail={!mktPass}>Mkt-Adjusted Mode</div>
|
||||||
|
<div class="spec-vb-verdict" class:pass={mktPass} class:fail={!mktPass}>{mktPass ? '✅ PASSES' : '✗ FAILS'}</div>
|
||||||
|
<div class="spec-vb-detail">
|
||||||
|
{#if mktPass}
|
||||||
|
Gates calibrated to live S&P P/E. P/E passes the market-adjusted threshold at today's valuations.
|
||||||
|
{:else}
|
||||||
|
Failed even relaxed market-adjusted gates — rare for Speculation signal.
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="spec-vb" class:spec-vb-pass={grahamOk} class:spec-vb-fail={!grahamOk}>
|
||||||
|
<div class="spec-vb-label" class:pass={grahamOk} class:fail={!grahamOk}>Graham (Fundamental) Mode</div>
|
||||||
|
<div class="spec-vb-verdict" class:pass={grahamOk} class:fail={!grahamOk}>{grahamOk ? '✅ PASSES' : '✗ FAILS'}</div>
|
||||||
|
<div class="spec-vb-detail">
|
||||||
|
{#if !grahamOk}
|
||||||
|
Graham's strict 15× P/E gate is a hard rule. Fails immediately at elevated valuations.
|
||||||
|
{:else}
|
||||||
|
Passes Graham's fundamental gates — unusual for a Speculation signal.
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gate-by-gate breakdown -->
|
||||||
|
<div class="spec-gate-breakdown">
|
||||||
|
<div class="spec-gb-title">Gate Breakdown — which rule failed</div>
|
||||||
|
{#each gates as g}
|
||||||
|
<div class="spec-gate-row" class:g-pass={g.pass} class:g-fail={!g.pass}>
|
||||||
|
<span class="spec-gate-icon">{g.pass ? '✓' : '✗'}</span>
|
||||||
|
<span class="spec-gate-text"><b>{g.label}</b> — {g.detail}</span>
|
||||||
|
<span class="spec-gate-value">{g.value}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer tip -->
|
||||||
|
<div class="spec-modal-footer">
|
||||||
|
💡 <strong>What to do:</strong> Speculation signals are valid positions — they pass today's market standards. Consider sizing smaller than a Strong Buy and monitoring P/E compression risk if the market re-rates growth stocks downward.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.spec-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-modal {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-input);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 520px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 24px 64px #00000088;
|
||||||
|
animation: modal-in 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-in {
|
||||||
|
from { opacity: 0; transform: scale(0.96) translateY(8px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-modal-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-modal-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-modal-close {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all var(--transition);
|
||||||
|
|
||||||
|
&:hover { background: var(--bg-elevated); color: var(--text-primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-summary {
|
||||||
|
background: var(--amber-dim);
|
||||||
|
border: 1px solid #4a3000;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
p { font-size: 12px; color: var(--amber); line-height: 1.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-verdict-compare {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-vb {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
|
||||||
|
&.spec-vb-pass { background: var(--green-dim); border-color: var(--green-mid); }
|
||||||
|
&.spec-vb-fail { background: var(--red-dim); border-color: #4a1a1a; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-vb-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
&.pass { color: var(--green); }
|
||||||
|
&.fail { color: var(--red); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-vb-verdict {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
|
||||||
|
&.pass { color: var(--green); }
|
||||||
|
&.fail { color: var(--red); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-vb-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-gate-breakdown {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-gb-title {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-gate-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&.g-pass { background: var(--green-dim); }
|
||||||
|
&.g-fail { background: var(--red-dim); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-gate-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.g-pass & { color: var(--green); }
|
||||||
|
.g-fail & { color: var(--red); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-gate-text {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
b { color: var(--text-primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-gate-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-modal-footer {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { watchlistStore } from '$lib/stores/watchlist.store.svelte.js';
|
||||||
|
import { screenerStore } from '$lib/stores/screener.store.svelte.js';
|
||||||
|
import { screenTickers, analyzeTickers } from '$lib/api.js';
|
||||||
|
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||||
|
import AssetTable from '$lib/components/screener/AssetTable.svelte';
|
||||||
|
import type { AssetType, ScreenerResult } from '$lib/types.js';
|
||||||
|
|
||||||
|
let results = $state<ScreenerResult | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let screenedAt = $state('');
|
||||||
|
let collapsed = $state(false);
|
||||||
|
|
||||||
|
// Re-screen whenever the pin set changes (debounced 300ms to batch quick adds)
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
|
$effect(() => {
|
||||||
|
const tickers = watchlistStore.tickers; // reactive dependency
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
if (tickers.length === 0) { results = null; return; }
|
||||||
|
debounceTimer = setTimeout(() => screen(tickers), 300);
|
||||||
|
return () => clearTimeout(debounceTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function screen(tickers: string[]) {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
results = await screenTickers(tickers);
|
||||||
|
screenedAt = new Date().toLocaleTimeString();
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run LLM analysis for the given asset type using watchlist tickers,
|
||||||
|
// then open the shared AnalysisSidebar via screenerStore.
|
||||||
|
async function runAnalysis(type: AssetType): Promise<void> {
|
||||||
|
const tickers = (results?.[type] ?? []).map((r) => r.asset.ticker);
|
||||||
|
if (!tickers.length) return;
|
||||||
|
screenerStore.sidebar = { open: true, loading: true, analysis: null, type, error: null };
|
||||||
|
try {
|
||||||
|
const res = await analyzeTickers(tickers);
|
||||||
|
const reason = res.reason === 'no_stories' ? 'No recent news found for these tickers.' : null;
|
||||||
|
screenerStore.sidebar = {
|
||||||
|
open: true,
|
||||||
|
loading: false,
|
||||||
|
analysis: res.analysis,
|
||||||
|
type,
|
||||||
|
error: res.analysis ? null : (reason ?? 'Analysis failed — check server logs for details.'),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
screenerStore.sidebar = {
|
||||||
|
open: true, loading: false, analysis: null, type,
|
||||||
|
error: (e as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if watchlistStore.count > 0 || loading}
|
||||||
|
<div class="wl-wrapper">
|
||||||
|
<div class="wl-header">
|
||||||
|
<button
|
||||||
|
class="wl-collapse-btn"
|
||||||
|
onclick={() => collapsed = !collapsed}
|
||||||
|
title={collapsed ? 'Expand watchlist' : 'Collapse watchlist'}
|
||||||
|
>{collapsed ? '▸' : '▾'}</button>
|
||||||
|
<span class="wl-title">📌 Watchlist</span>
|
||||||
|
<span class="wl-count">{watchlistStore.count}</span>
|
||||||
|
{#if screenedAt && !collapsed}
|
||||||
|
<span class="wl-screened-at">screened {screenedAt}</span>
|
||||||
|
{/if}
|
||||||
|
{#if loading}<Spinner size="sm" />{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !collapsed}
|
||||||
|
{#if error}
|
||||||
|
<div class="error-banner">⚠ {error}</div>
|
||||||
|
{:else if loading && !results}
|
||||||
|
<div class="wl-loading"><Spinner size="lg" label="Screening watchlist…" /></div>
|
||||||
|
{:else if results}
|
||||||
|
<!-- Reuse AssetTable for each type — full feature parity with the screener -->
|
||||||
|
{#each (['STOCK', 'ETF', 'BOND'] as const) as assetType}
|
||||||
|
{#if results[assetType]?.length}
|
||||||
|
<AssetTable
|
||||||
|
type={assetType}
|
||||||
|
rows={results[assetType]}
|
||||||
|
analyzeLoading={screenerStore.sidebar.loading && screenerStore.sidebar.type === assetType}
|
||||||
|
onAnalyze={() => runAnalysis(assetType)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if results.ERROR?.length}
|
||||||
|
<div class="wl-errors">
|
||||||
|
{#each results.ERROR as e}
|
||||||
|
<span class="wl-error-item">
|
||||||
|
⚠ {e.ticker}: {e.message}
|
||||||
|
<button
|
||||||
|
class="wl-unpin-btn"
|
||||||
|
onclick={() => watchlistStore.remove(e.ticker)}
|
||||||
|
title="Remove"
|
||||||
|
>✕</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wl-wrapper {
|
||||||
|
margin-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wl-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wl-collapse-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wl-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wl-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--blue);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wl-screened-at {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wl-loading {
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wl-errors {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wl-error-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wl-unpin-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wl-unpin-btn:hover { color: #f87171; }
|
||||||
|
</style>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
{ label: 'REIT Yld', value: ctx.benchmarks?.reitYield != null ? ctx.benchmarks.reitYield.toFixed(1) + '%' : '—', color: 'amber' },
|
{ label: 'REIT Yld', value: ctx.benchmarks?.reitYield != null ? ctx.benchmarks.reitYield.toFixed(1) + '%' : '—', color: 'amber' },
|
||||||
{ label: 'IG Sprd', value: ctx.benchmarks?.igSpread != null ? ctx.benchmarks.igSpread.toFixed(2) + '%' : '—', color: 'teal' },
|
{ label: 'IG Sprd', value: ctx.benchmarks?.igSpread != null ? ctx.benchmarks.igSpread.toFixed(2) + '%' : '—', color: 'teal' },
|
||||||
{ label: 'Rates', value: ctx.rateRegime, regime: ctx.rateRegime, color: ctx.rateRegime === 'HIGH' ? 'red' : ctx.rateRegime === 'LOW' ? 'blue' : 'slate' },
|
{ label: 'Rates', value: ctx.rateRegime, regime: ctx.rateRegime, color: ctx.rateRegime === 'HIGH' ? 'red' : ctx.rateRegime === 'LOW' ? 'blue' : 'slate' },
|
||||||
{ label: 'Vol', value: ctx.volatilityRegime, regime: ctx.volatilityRegime, color: ctx.volatilityRegime === 'ELEVATED' ? 'orange' : 'slate' },
|
{ label: 'Vol', value: ctx.volatilityRegime, regime: ctx.volatilityRegime, color: ctx.volatilityRegime === 'HIGH' ? 'orange' : ctx.volatilityRegime === 'LOW' ? 'blue' : 'slate' },
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { fetchWatchlist, pinTicker, unpinTicker } from '$lib/api/watchlist.js';
|
||||||
|
|
||||||
|
class WatchlistStore {
|
||||||
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
|
pins = $state<Set<string>>(new Set());
|
||||||
|
loading = $state(false);
|
||||||
|
ready = $state(false); // true once initial load completes
|
||||||
|
|
||||||
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
|
get tickers(): string[] {
|
||||||
|
return [...this.pins];
|
||||||
|
}
|
||||||
|
get count(): number {
|
||||||
|
return this.pins.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPinned(ticker: string): boolean {
|
||||||
|
return this.pins.has(ticker);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load from server ───────────────────────────────────────────────────────
|
||||||
|
async load(): Promise<void> {
|
||||||
|
if (this.loading) return;
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const { tickers } = await fetchWatchlist();
|
||||||
|
this.pins = new Set(tickers);
|
||||||
|
} catch {
|
||||||
|
// Silently fail — user sees empty watchlist, can retry by visiting page
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
this.ready = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggle (optimistic) ────────────────────────────────────────────────────
|
||||||
|
async toggle(ticker: string): Promise<void> {
|
||||||
|
const wasPin = this.pins.has(ticker);
|
||||||
|
|
||||||
|
// Optimistic update — update UI immediately
|
||||||
|
const next = new Set(this.pins);
|
||||||
|
wasPin ? next.delete(ticker) : next.add(ticker);
|
||||||
|
this.pins = next;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (wasPin) {
|
||||||
|
await unpinTicker(ticker);
|
||||||
|
} else {
|
||||||
|
await pinTicker(ticker);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Rollback on failure
|
||||||
|
const rollback = new Set(this.pins);
|
||||||
|
wasPin ? rollback.add(ticker) : rollback.delete(ticker);
|
||||||
|
this.pins = rollback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Remove ─────────────────────────────────────────────────────────────────
|
||||||
|
async remove(ticker: string): Promise<void> {
|
||||||
|
const next = new Set(this.pins);
|
||||||
|
next.delete(ticker);
|
||||||
|
this.pins = next;
|
||||||
|
try {
|
||||||
|
await unpinTicker(ticker);
|
||||||
|
} catch {
|
||||||
|
// Rollback
|
||||||
|
const rollback = new Set(this.pins);
|
||||||
|
rollback.add(ticker);
|
||||||
|
this.pins = rollback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const watchlistStore = new WatchlistStore();
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
export function verdictShort(label: string | null | undefined): string {
|
export function verdictShort(label: string | null | undefined): string {
|
||||||
if (!label) return '—';
|
if (!label) return '—';
|
||||||
|
if (label.includes('No Data')) return 'No Data';
|
||||||
if (label.includes('High Conviction')) return 'Strong Buy';
|
if (label.includes('High Conviction')) return 'Strong Buy';
|
||||||
if (label.includes('Speculative')) return 'Speculative';
|
if (label.includes('Speculative')) return 'Speculative';
|
||||||
if (label.includes('Momentum')) return 'Momentum';
|
if (label.includes('Momentum')) return 'Momentum';
|
||||||
@@ -34,6 +35,8 @@ export function vClass(
|
|||||||
label: string | null | undefined,
|
label: string | null | undefined,
|
||||||
): 'green' | 'yellow' | 'red' | 'blue' | 'gray' {
|
): 'green' | 'yellow' | 'red' | 'blue' | 'gray' {
|
||||||
if (!label) return 'gray';
|
if (!label) return 'gray';
|
||||||
|
// Insufficient data is unknown, not a neutral opinion — render gray
|
||||||
|
if (label.includes('No Data')) return 'gray';
|
||||||
if (
|
if (
|
||||||
label.startsWith('🟢') ||
|
label.startsWith('🟢') ||
|
||||||
label.includes('High Conviction') ||
|
label.includes('High Conviction') ||
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import '../styles/app.scss';
|
import '../styles/app.scss';
|
||||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||||
import { authStore } from '$lib/stores/auth.store.svelte.js';
|
import { authStore } from '$lib/stores/auth.store.svelte.js';
|
||||||
|
import { watchlistStore } from '$lib/stores/watchlist.store.svelte.js';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
let { children }: { children: Snippet } = $props();
|
let { children }: { children: Snippet } = $props();
|
||||||
|
|
||||||
@@ -19,6 +20,13 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load watchlist once when user is authenticated
|
||||||
|
$effect(() => {
|
||||||
|
if (authStore.isLoggedIn && !watchlistStore.ready && !watchlistStore.loading) {
|
||||||
|
watchlistStore.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const navLabel = $derived(
|
const navLabel = $derived(
|
||||||
activePath === '/portfolio' ? 'Loading portfolio…' :
|
activePath === '/portfolio' ? 'Loading portfolio…' :
|
||||||
activePath?.startsWith('/calls') ? 'Loading market calls…' :
|
activePath?.startsWith('/calls') ? 'Loading market calls…' :
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import MarketContextStrip from '$lib/components/shared/MarketContextStrip.svelte';
|
import MarketContextStrip from '$lib/components/shared/MarketContextStrip.svelte';
|
||||||
import AssetTable from '$lib/components/screener/AssetTable.svelte';
|
import AssetTable from '$lib/components/screener/AssetTable.svelte';
|
||||||
import AnalysisSidebar from '$lib/components/screener/AnalysisSidebar.svelte';
|
import AnalysisSidebar from '$lib/components/screener/AnalysisSidebar.svelte';
|
||||||
|
import WatchlistPanel from '$lib/components/screener/WatchlistPanel.svelte';
|
||||||
|
|
||||||
const s = screenerStore;
|
const s = screenerStore;
|
||||||
|
|
||||||
@@ -92,6 +93,12 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<WatchlistPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnalysisSidebar sidebar={s.sidebar} onClose={() => s.closeSidebar()} />
|
<AnalysisSidebar
|
||||||
|
sidebar={s.sidebar}
|
||||||
|
onClose={() => s.closeSidebar()}
|
||||||
|
onScreenTickers={(tickers) => { s.input = tickers.join(', '); s.screen(); }}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
onMount(() => goto('/'));
|
||||||
|
</script>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
||||||
@@ -75,6 +75,34 @@ button {
|
|||||||
&:hover:not(:disabled) { background: #163356; }
|
&:hover:not(:disabled) { background: #163356; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── btn-glossary (ghost — "? Glossary" in section header) ──────────────
|
||||||
|
|
||||||
|
.btn-glossary {
|
||||||
|
@extend %btn-inline-flex;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 4px 11px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-color: var(--border-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-glossary-active {
|
||||||
|
color: #60a5fa;
|
||||||
|
background: #1e3a5f22;
|
||||||
|
border-color: #1e3a5f88;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── btn-analyze (ghost blue — "✦ Analyze" in section header) ────────────
|
// ── btn-analyze (ghost blue — "✦ Analyze" in section header) ────────────
|
||||||
|
|
||||||
.btn-analyze {
|
.btn-analyze {
|
||||||
|
|||||||
@@ -113,3 +113,20 @@ main { flex: 1; padding: 28px 32px; }
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
margin-left: 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--blue);
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: var(--font-ui);
|
||||||
background: var(--bg-base);
|
background: var(--bg-base);
|
||||||
color: var(--text-secondary);
|
color: var(--text-primary);
|
||||||
font-size: var(--fs-md);
|
font-size: var(--fs-md);
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button { font-family: inherit; cursor: pointer; }
|
||||||
|
input, select { font-family: inherit; }
|
||||||
|
|||||||
+367
-229
@@ -106,32 +106,23 @@
|
|||||||
|
|
||||||
.asset-section { margin-bottom: 24px; }
|
.asset-section { margin-bottom: 24px; }
|
||||||
|
|
||||||
.analyze-btn {
|
// ── Inline filter row ────────────────────────────────────────────────────
|
||||||
margin-left: auto;
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
padding: 5px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Inline filter row (inside thead) ─────────────────────────────────────
|
|
||||||
|
|
||||||
.filter-row td {
|
.filter-row td {
|
||||||
padding: 4px var(--space-lg) !important;
|
padding: 5px 8px !important;
|
||||||
background: #0a1628;
|
background: var(--bg-base);
|
||||||
border-bottom: 1px solid var(--border) !important;
|
border-bottom: 1px solid var(--border) !important;
|
||||||
|
|
||||||
// sticky first cell matches the header sticky column
|
|
||||||
&:first-child {
|
&:first-child {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
background: #0a1628;
|
background: var(--bg-base);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pair of inputs side-by-side (e.g. price min/max)
|
|
||||||
.th-filter-pair {
|
.th-filter-pair {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
max-width: 160px;
|
max-width: 160px;
|
||||||
|
|
||||||
@@ -145,7 +136,6 @@
|
|||||||
&::-webkit-inner-spin-button { -webkit-appearance: none; }
|
&::-webkit-inner-spin-button { -webkit-appearance: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checkbox + label for flag filter
|
|
||||||
.th-filter-check {
|
.th-filter-check {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -155,44 +145,31 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
input[type='checkbox'] {
|
input[type='checkbox'] { accent-color: var(--blue); width: 12px; height: 12px; cursor: pointer; }
|
||||||
accent-color: var(--blue);
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input / select sitting inside a th filter cell
|
|
||||||
.th-filter {
|
.th-filter {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
background: #0f1e33;
|
background: var(--bg-input);
|
||||||
border: 1px solid #1e3050;
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
padding: 3px 7px;
|
padding: 4px 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color var(--transition);
|
transition: border-color var(--transition);
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
|
||||||
|
|
||||||
&:focus { border-color: var(--blue); box-shadow: 0 0 0 2px #3b82f620; }
|
&:focus { border-color: var(--blue); }
|
||||||
&::placeholder { color: var(--text-faint); }
|
&::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
// Style active (non-empty) filter
|
option { background: var(--bg-surface); }
|
||||||
&:not([value='']):not(:placeholder-shown) {
|
|
||||||
border-color: var(--blue);
|
|
||||||
color: #93c5fd;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Clear filters" link in section header
|
|
||||||
.filter-clear-btn {
|
.filter-clear-btn {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-faint);
|
color: var(--text-muted);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -203,211 +180,257 @@
|
|||||||
&:hover { color: var(--red); }
|
&:hover { color: var(--red); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sortable column header
|
// ── Column headers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
.sort-th {
|
.sort-th {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
&:hover { color: var(--text-primary); }
|
||||||
&:hover { color: var(--text-muted); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sort-icon {
|
.sort-icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
color: var(--text-faint);
|
color: var(--text-muted);
|
||||||
|
opacity: 0.4;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
line-height: 1;
|
|
||||||
|
.sort-th:hover & { opacity: 0.8; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand toggle column
|
|
||||||
.col-expand {
|
.col-expand {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
min-width: 24px;
|
min-width: 24px;
|
||||||
color: var(--text-faint);
|
color: var(--text-muted);
|
||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent Verdict column from collapsing — pill needs room
|
|
||||||
.asset-table th:nth-child(5),
|
|
||||||
.asset-table td:nth-child(5) {
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent Signal column from collapsing
|
|
||||||
.asset-table th:nth-child(4),
|
.asset-table th:nth-child(4),
|
||||||
.asset-table td:nth-child(4) {
|
.asset-table td:nth-child(4) { min-width: 160px; } // signal cell — pill + why? link
|
||||||
min-width: 110px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Merged Signal/Verdict pill ────────────────────────────────────────────
|
// ── Signal pill ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
.signal-verdict-cell {
|
.signal-verdict-cell {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 3px;
|
gap: 6px;
|
||||||
min-width: 120px;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main verdict pill — colored by signal class
|
|
||||||
.sv-pill {
|
.sv-pill {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
font-size: 11px;
|
align-items: center;
|
||||||
font-weight: 700;
|
gap: 5px;
|
||||||
padding: 2px 9px;
|
padding: 4px 12px;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
letter-spacing: 0.03em;
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
width: fit-content;
|
transition: filter var(--transition);
|
||||||
|
|
||||||
&.sv-strong { background: #14532d33; color: #4ade80; border: 1px solid #14532d66; }
|
&.sv-strong { background: var(--green-dim); color: var(--green); border: 1px solid var(--green-mid); }
|
||||||
&.sv-momentum { background: #1e3a5f33; color: #60a5fa; border: 1px solid #1e3a5f66; }
|
&.sv-momentum { background: var(--blue-dim); color: var(--blue); border: 1px solid var(--blue-mid); }
|
||||||
&.sv-spec { background: #7c2d1233; color: #fb923c; border: 1px solid #7c2d1266; }
|
&.sv-spec { background: var(--amber-dim); color: var(--amber); border: 1px solid #4a3000; }
|
||||||
&.sv-neutral { background: #1e293b; color: #94a3b8; border: 1px solid #334155; }
|
&.sv-neutral { background: var(--bg-elevated); color: var(--text-secondary); border: 1px solid var(--border-input); }
|
||||||
&.sv-avoid { background: #450a0a33; color: #f87171; border: 1px solid #450a0a66; }
|
&.sv-avoid { background: var(--red-dim); color: var(--red); border: 1px solid #4a1a1a; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sub-label showing the full signal text
|
// Pill as a clickable button — dotted underline signals interactivity
|
||||||
.sv-signal-label {
|
button.sv-pill-link {
|
||||||
font-size: 10px;
|
cursor: pointer;
|
||||||
color: var(--text-faint);
|
background: none; // reset button default, color set by sv-* variant above
|
||||||
letter-spacing: 0.02em;
|
text-decoration: underline;
|
||||||
white-space: nowrap;
|
text-decoration-style: dotted;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
|
||||||
|
&:hover { filter: brightness(1.2); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Score dot scale ───────────────────────────────────────────────────────
|
// ── Score dot scale ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
.score-cell {
|
.score-cell { white-space: nowrap; min-width: 80px; }
|
||||||
white-space: nowrap;
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-dots {
|
.score-dots {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 3px;
|
gap: 2px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-dot {
|
.score-dot {
|
||||||
width: 8px;
|
width: 7px;
|
||||||
height: 8px;
|
height: 7px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #1e3050;
|
background: var(--border-input);
|
||||||
border: 1px solid #2a4060;
|
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
|
||||||
&.on { background: var(--blue); border-color: #60a5fa; box-shadow: 0 0 4px #3b82f644; }
|
&.on { background: var(--blue); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-num {
|
.score-num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim);
|
color: var(--text-muted);
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-fail {
|
.score-fail { color: var(--red); font-size: 12px; font-weight: 700; }
|
||||||
color: var(--red);
|
|
||||||
font-size: 12px;
|
.score-nodata {
|
||||||
font-weight: 700;
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Flags badge with hover-expand tooltip ────────────────────────────────
|
// Low-coverage chip — shown when fewer than half the scoring factors had data
|
||||||
|
.score-cov {
|
||||||
.flags-cell {
|
font-family: var(--font-mono);
|
||||||
position: relative;
|
font-size: 10px;
|
||||||
min-width: 48px;
|
color: var(--amber, #d97706);
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
margin-left: 5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cap / Style chips ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap-tag { color: var(--text-secondary); }
|
||||||
|
.style-tag { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
// ── Flags badge with hover tooltip ───────────────────────────────────────
|
||||||
|
|
||||||
|
.flags-cell { position: relative; min-width: 48px; }
|
||||||
|
|
||||||
.flags-badge {
|
.flags-badge {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
||||||
&:hover .flags-tooltip { opacity: 1; pointer-events: auto; }
|
&:hover .flags-tooltip { display: block; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.flags-count {
|
.flags-count {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
background: #431a0033;
|
align-items: center;
|
||||||
border: 1px solid #431a0066;
|
gap: 4px;
|
||||||
color: #fb923c;
|
background: var(--amber-dim);
|
||||||
|
color: var(--amber);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
padding: 2px 8px;
|
padding: 3px 9px;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-xs);
|
||||||
|
border: 1px solid #3a2800;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.flags-tooltip {
|
.flags-tooltip {
|
||||||
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + 6px);
|
bottom: calc(100% + 6px);
|
||||||
left: 0;
|
right: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
background: #0c1829;
|
background: var(--bg-elevated);
|
||||||
border: 1px solid #1e3a5f;
|
border: 1px solid var(--border-input);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 8px 10px;
|
padding: 10px 12px;
|
||||||
display: flex;
|
min-width: 240px;
|
||||||
flex-direction: column;
|
max-width: 300px;
|
||||||
gap: 4px;
|
|
||||||
min-width: 180px;
|
|
||||||
max-width: 280px;
|
|
||||||
box-shadow: 0 8px 24px #00000066;
|
box-shadow: 0 8px 24px #00000066;
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary row — clickable; open state gets left accent bar + lifted bg
|
.flags-tt-title {
|
||||||
// NOTE: background is set on the <tr> so that position:sticky td:first-child
|
font-size: 9px;
|
||||||
// inherits it (sticky cells create their own stacking context).
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flags-tt-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--amber);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary row (data row) ────────────────────────────────────────────────
|
||||||
|
|
||||||
.summary-row {
|
.summary-row {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background var(--transition);
|
||||||
|
|
||||||
&:hover { background: #1e2f48; }
|
&:hover { background: var(--bg-surface); }
|
||||||
|
|
||||||
&.row-open {
|
&.row-open {
|
||||||
background: #1a2f4a;
|
background: var(--bg-row-sel);
|
||||||
|
border-left: 2px solid var(--blue);
|
||||||
|
|
||||||
td { border-bottom: none !important; }
|
td { border-bottom: none !important; }
|
||||||
|
.col-expand { color: var(--blue); }
|
||||||
// Brighter left accent on the expand cell
|
|
||||||
.col-expand {
|
|
||||||
box-shadow: inset 3px 0 0 #60a5fa;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ticker — monospace, bold
|
||||||
|
.ticker {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Inline detail row ────────────────────────────────────────────────────
|
// ── Inline detail row ────────────────────────────────────────────────────
|
||||||
|
|
||||||
.detail-row td {
|
.detail-row td {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
border-bottom: none !important;
|
border-bottom: none !important;
|
||||||
box-shadow: inset 0 -3px 0 #1e3a5f;
|
box-shadow: inset 0 -2px 0 var(--blue);
|
||||||
}
|
}
|
||||||
.detail-cell { padding: 0 !important; }
|
.detail-cell { padding: 0 !important; }
|
||||||
|
|
||||||
// Two-column layout: left = metrics (55%), right = bar chart (45%)
|
|
||||||
// min-width: 0 on each child prevents grid blowout
|
|
||||||
.detail-panel {
|
.detail-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 55% 45%;
|
grid-template-columns: 1fr 360px;
|
||||||
gap: 0;
|
border-top: 1px solid var(--blue);
|
||||||
background: #0c1829;
|
background: #0c1520;
|
||||||
border-top: 2px solid var(--blue);
|
|
||||||
border-left: 3px solid var(--blue);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Left zone ────────────────────────────────────────────────────────────
|
// ── Left zone ────────────────────────────────────────────────────────────
|
||||||
.dp-left {
|
.dp-left {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 16px 18px 16px 16px;
|
padding: 16px 18px;
|
||||||
border-right: 1px solid #1e3a5f;
|
border-right: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -418,176 +441,234 @@
|
|||||||
.dp-right {
|
.dp-right {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
|
background: #0a1018;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Section title ─────────────────────────────────────────────────────────
|
// ── Section label ─────────────────────────────────────────────────────────
|
||||||
.dp-title {
|
.dp-title {
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
color: #4a7aaa;
|
color: var(--text-muted);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-mode-note {
|
.dp-mode-note {
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
color: #3a5a7a;
|
color: var(--text-muted);
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Metric grid cards ─────────────────────────────────────────────────────
|
// ── Metric grid cards ─────────────────────────────────────────────────────
|
||||||
// Fixed 4-col grid on left panel — no min-width auto-fill to prevent overflow
|
|
||||||
.dp-metric-grid {
|
.dp-metric-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 5px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-metric-card {
|
.dp-metric-card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: #0f2040;
|
background: var(--bg-surface);
|
||||||
border: 1px solid #1a3050;
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 5px 7px;
|
padding: 8px 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--transition), background var(--transition);
|
||||||
|
|
||||||
|
&:hover { border-color: var(--purple); background: var(--bg-elevated); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-mc-label {
|
.dp-mc-label {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.06em;
|
||||||
color: #3d5a7a;
|
color: var(--text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? icon inside metric card label — appears on card hover
|
||||||
|
.dp-mc-help {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.dp-metric-card:hover & { opacity: 1; color: var(--purple); border-color: var(--purple); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass / fail color states on metric cards
|
||||||
|
.dp-metric-card.dp-mc-pass {
|
||||||
|
border-color: var(--green-mid);
|
||||||
|
background: linear-gradient(180deg, #0d2e1a 0%, var(--bg-surface) 100%);
|
||||||
|
|
||||||
|
.dp-mc-label { color: var(--green); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-metric-card.dp-mc-fail {
|
||||||
|
border-color: #4a1a1a;
|
||||||
|
background: linear-gradient(180deg, #2e0d0d 0%, var(--bg-surface) 100%);
|
||||||
|
|
||||||
|
.dp-mc-label { color: var(--red); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-mc-value {
|
.dp-mc-value {
|
||||||
font-size: 13px;
|
font-family: var(--font-mono);
|
||||||
font-weight: 700;
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Gate badge chips ──────────────────────────────────────────────────────
|
// ── Gate badge chips ──────────────────────────────────────────────────────
|
||||||
.dp-gates-row {
|
|
||||||
display: flex;
|
.dp-gates-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dp-gate-chip {
|
.dp-gate-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 5px;
|
||||||
font-size: 11px;
|
padding: 4px 12px;
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-gate-chip-pass {
|
.dp-gate-chip-pass {
|
||||||
background: #14532d33;
|
background: var(--green-dim);
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
border-color: #14532d66;
|
border-color: var(--green-mid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-gate-chip-fail {
|
.dp-gate-chip-fail {
|
||||||
background: #450a0a33;
|
background: var(--red-dim);
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
border-color: #450a0a66;
|
border-color: #4a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Risk flag pills ───────────────────────────────────────────────────────
|
// ── Risk flag pills ───────────────────────────────────────────────────────
|
||||||
.dp-risk-row {
|
|
||||||
display: flex;
|
.dp-risk-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dp-risk-pill {
|
.dp-risk-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 11px;
|
gap: 4px;
|
||||||
font-weight: 600;
|
padding: 3px 10px;
|
||||||
padding: 2px 9px;
|
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
background: #431a0033;
|
font-size: 11px;
|
||||||
color: #fb923c;
|
font-weight: 500;
|
||||||
border: 1px solid #431a0066;
|
background: var(--amber-dim);
|
||||||
|
color: var(--amber);
|
||||||
|
border: 1px solid #3a2800;
|
||||||
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transition: background var(--transition);
|
||||||
|
|
||||||
|
&:hover { background: #3a2800; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Horizontal bar chart (factor scores) ─────────────────────────────────
|
// ── Factor score cards (redesigned) ──────────────────────────────────────
|
||||||
.dp-bar-chart {
|
|
||||||
|
.dp-factor-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-bar-row {
|
.dp-factor-item {
|
||||||
display: grid;
|
background: var(--bg-surface);
|
||||||
grid-template-columns: 88px 1fr 36px;
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
|
||||||
|
&:hover { border-color: var(--purple); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-factor-top {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-bar-label {
|
.dp-factor-name {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: #6b8aad;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
color: var(--text-secondary);
|
||||||
overflow: hidden;
|
text-transform: uppercase;
|
||||||
text-overflow: ellipsis;
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-factor-verdict {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
|
||||||
|
&.fv-pos { background: var(--green-dim); color: var(--green); }
|
||||||
|
&.fv-neg { background: var(--red-dim); color: var(--red); }
|
||||||
|
&.fv-neu { background: var(--bg-elevated); color: var(--text-secondary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-factor-reason {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
b { color: var(--text-secondary); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-bar-track {
|
.dp-bar-track {
|
||||||
height: 10px;
|
height: 5px;
|
||||||
background: #0f2040;
|
background: var(--border);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid #1a3050;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-bar-fill {
|
.dp-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.5s ease;
|
||||||
|
|
||||||
&.dp-bar-pos {
|
&.dp-bar-pos { background: var(--green); }
|
||||||
background: linear-gradient(90deg, #16a34a, #4ade80);
|
&.dp-bar-neg { background: var(--red); }
|
||||||
box-shadow: 0 0 6px #4ade8044;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.dp-bar-neg {
|
|
||||||
background: linear-gradient(90deg, #991b1b, #f87171);
|
|
||||||
box-shadow: 0 0 6px #f8717144;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-bar-val {
|
// Gate failures list (no factor cards)
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Gate failures (shown instead of bars when gates fail) ─────────────────
|
|
||||||
.dp-failures {
|
.dp-failures {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -595,37 +676,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dp-failure-item {
|
.dp-failure-item {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: #f87171;
|
color: var(--red);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dp-no-factors {
|
.dp-no-factors {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #3a5a7a;
|
color: var(--text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* score-cell base — layout only; visual rules are in the dot-scale block above */
|
|
||||||
|
|
||||||
/* Classification tags */
|
|
||||||
.cap-tag { color: var(--blue-light, #93c5fd); border-color: var(--blue-dim, #1e3a5f); }
|
|
||||||
.style-tag { color: var(--text-muted); }
|
|
||||||
|
|
||||||
/* Signed % colouring */
|
/* Signed % colouring */
|
||||||
.pos { color: var(--green); }
|
.pos { color: var(--green); }
|
||||||
.neg { color: var(--red); }
|
.neg { color: var(--red); }
|
||||||
|
|
||||||
/* Analyst label — not a number */
|
/* Analyst label */
|
||||||
.analyst-cell {
|
.analyst-cell {
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .flag / .flag-more removed — replaced by .flags-badge hover-expand system */
|
|
||||||
|
|
||||||
// ── MarketContext (collapsible card) ──────────────────────────────────────
|
// ── MarketContext (collapsible card) ──────────────────────────────────────
|
||||||
|
|
||||||
.ctx-grid {
|
.ctx-grid {
|
||||||
@@ -820,3 +892,69 @@
|
|||||||
.tip-wrap:hover .tip-box { display: block; }
|
.tip-wrap:hover .tip-box { display: block; }
|
||||||
|
|
||||||
.ctx-value { font-size: 17px; font-weight: 700; color: var(--text-primary); margin-top: 4px; }
|
.ctx-value { font-size: 17px; font-weight: 700; color: var(--text-primary); margin-top: 4px; }
|
||||||
|
|
||||||
|
// ── Pin button + expand toggle ────────────────────────────────────────────
|
||||||
|
|
||||||
|
.col-expand {
|
||||||
|
width: 52px;
|
||||||
|
min-width: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-toggle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 1px 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.35;
|
||||||
|
transition: opacity var(--transition), transform var(--transition);
|
||||||
|
|
||||||
|
&:hover { opacity: 0.85; transform: scale(1.15); }
|
||||||
|
&.pinned { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── News links in detail panel ────────────────────────────────────────────
|
||||||
|
|
||||||
|
.dp-news-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-news-label {
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-news-link {
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
color: var(--blue);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
transition: background var(--transition), border-color var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-color: var(--blue);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,3 +153,387 @@
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GlossaryPanel ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.glossary-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 98; // below panel (99) — transparent click catcher
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 340px;
|
||||||
|
background: #0d1117;
|
||||||
|
border-left: 1px solid #1e2a3a;
|
||||||
|
z-index: 99;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: -6px 0 32px #00000088;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-title-q {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--text-dimmer);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dimmer);
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-close {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-dimmer);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover { color: var(--text-muted); background: var(--bg-card); border-color: var(--border-input); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-search {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-input);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 7px 28px 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&::placeholder { color: var(--text-faint); }
|
||||||
|
&:focus { border-color: var(--blue); box-shadow: 0 0 0 2px #3b82f618; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 20px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { color: var(--text-muted); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 0 20px;
|
||||||
|
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #1e3a5f transparent;
|
||||||
|
&::-webkit-scrollbar { width: 4px; }
|
||||||
|
&::-webkit-scrollbar-thumb { background: #1e3a5f; border-radius: 2px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Context banner — fixed between search and body ────────────────────────
|
||||||
|
|
||||||
|
.glossary-ctx-banner {
|
||||||
|
background: #0a2e1a;
|
||||||
|
border-top: 1px solid #1a4a2a;
|
||||||
|
border-bottom: 1px solid #1a4a2a;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4ade80;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 0; // flush with panel edges
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-category { margin-bottom: 2px; }
|
||||||
|
|
||||||
|
.glossary-cat-header {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
padding: 12px 14px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Individual item ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.glossary-item {
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: border-color 0.15s ease, background 0.1s ease;
|
||||||
|
margin: 0 8px 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
// Highlighted = relevant to selected row → green left border, no bg change
|
||||||
|
&.glossary-item-active {
|
||||||
|
border-left-color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open/expanded → purple tint + purple left border
|
||||||
|
&.glossary-item-open {
|
||||||
|
border-left-color: #7c3aed;
|
||||||
|
background: #1a1035;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-item-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 9px 10px 9px 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
|
||||||
|
&:hover { background: #151f2e; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-item-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-active-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 4px #22c55e66;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Category tags — all same gray pill, label text only ───────────────────
|
||||||
|
|
||||||
|
.glossary-cat-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: capitalize;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
// All same gray — no per-category color variation
|
||||||
|
background: #1e2736;
|
||||||
|
color: #64748b;
|
||||||
|
border: 1px solid #2a3544;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expanded body — card style matching mockup ────────────────────────────
|
||||||
|
|
||||||
|
.glossary-item-body {
|
||||||
|
margin: 0 8px 10px 8px;
|
||||||
|
padding: 12px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
background: #131c2a;
|
||||||
|
border: 1px solid #1e2d40;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden; // prevent pill overflow clipping
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-definition {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gate box (code-styled monospace block) ────────────────────────────────
|
||||||
|
|
||||||
|
.glossary-gate-box {
|
||||||
|
background: #0d1520;
|
||||||
|
border: 1px solid #1e2d40;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'JetBrains Mono', var(--font-mono, 'SF Mono', 'Fira Code', monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #93c5fd;
|
||||||
|
line-height: 1.6;
|
||||||
|
display: block;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Range pills ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.glossary-range-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-range-pill {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.3;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
&.grange-good {
|
||||||
|
background: #0d2e1a;
|
||||||
|
color: #4ade80;
|
||||||
|
border: 1px solid #1a4a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.grange-bad {
|
||||||
|
background: #2e0d0d;
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid #4a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.grange-neutral {
|
||||||
|
background: #1e2736;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #2a3544;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Range labels (below pills) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
.glossary-range-labels {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
span {
|
||||||
|
flex: 1 1 0;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grlabel-good { color: #4ade80; }
|
||||||
|
.grlabel-neutral { color: #64748b; }
|
||||||
|
.grlabel-bad { color: #f87171; }
|
||||||
|
|
||||||
|
// ── Empty state ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.glossary-empty {
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dimmer);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Column-header tooltip (300ms CSS delay) ───────────────────────────────
|
||||||
|
// Only active inside <thead> — metric cards in detail panel use dp-mc-help instead
|
||||||
|
|
||||||
|
thead .col-tip {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
cursor: help;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: attr(data-tip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: max-content;
|
||||||
|
max-width: 220px;
|
||||||
|
white-space: normal;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-input);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease 300ms;
|
||||||
|
z-index: 200;
|
||||||
|
box-shadow: 0 4px 16px #00000055;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::after { opacity: 1; }
|
||||||
|
}
|
||||||
|
|||||||
+52
-36
@@ -1,56 +1,68 @@
|
|||||||
// ── Design Tokens ────────────────────────────────────────────────────────
|
// ── Design Tokens ────────────────────────────────────────────────────────
|
||||||
// SCSS maps generate CSS custom properties. Organised by category so new
|
// Single strict surface hierarchy. No decorative colors — semantic only.
|
||||||
// tokens are easy to locate and add. All runtime theming uses var(--name).
|
|
||||||
|
|
||||||
// Background layers — darkest to lightest
|
// Background layers — darkest to lightest
|
||||||
$bg: (
|
$bg: (
|
||||||
base: #0f1117, // body, nav background
|
base: #0a0e14, // body, nav
|
||||||
surface: #0d1117, // section cards, sidebar
|
surface: #111820, // section cards, sidebar, table headers
|
||||||
elevated: #111827, // section headers, table headers
|
elevated: #1a2332, // input bg, metric cards, chips
|
||||||
card: #1e293b, // input bg, tags, summary cards, ctx cards
|
input: #0d1319, // filter inputs, search fields
|
||||||
card-hover: #131c2b, // table row hover
|
overlay: #000000a6, // modal backdrop
|
||||||
row-alt: #1a2233, // portfolio table row border
|
row-sel: #0f1f35, // selected/expanded row background
|
||||||
);
|
);
|
||||||
|
|
||||||
// Borders
|
// Borders
|
||||||
$borders: (
|
$borders: (
|
||||||
border: #1e293b, // primary — cards, sections, inputs
|
border: #1e2d3d, // primary — cards, sections
|
||||||
border-subtle: #161f2e, // table row separators
|
border-subtle: #10182a, // table row separators
|
||||||
border-input: #2d3f55, // form input borders
|
border-input: #263447, // form input borders, chips
|
||||||
);
|
);
|
||||||
|
|
||||||
// Text — brightest to most muted
|
// Text — brightest to most muted
|
||||||
$text: (
|
$text: (
|
||||||
text-primary: #f1f5f9, // headings, tickers, card values
|
text-primary: #e2eaf4, // headings, tickers, values
|
||||||
text-secondary: #e2e8f0, // body text, input values
|
text-secondary: #7a93ad, // body text, labels, column headers
|
||||||
text-muted: #94a3b8, // secondary text, reasons
|
text-muted: #3d5166, // very muted — timestamps, category labels
|
||||||
text-dim: #64748b, // labels, table headers, muted values
|
|
||||||
text-dimmer: #475569, // very muted — timestamps, hints
|
|
||||||
text-faint: #334155, // count badge text, column headers
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Blue accent ('blue' is a CSS color name — must be quoted)
|
// Blue accent
|
||||||
$blues: (
|
$blues: (
|
||||||
'blue': #3b82f6, // focus ring, progress bar, bar-fill
|
'blue': #4da6ff, // selection border, active states
|
||||||
'blue-dark': #2563eb, // primary button bg
|
'blue-dim': #0d2240, // active tab bg
|
||||||
'blue-darker': #1d4ed8, // primary button hover
|
'blue-mid': #1a3a5c, // hover states
|
||||||
'blue-muted': #60a5fa, // active tab text, edit icon hover
|
'blue-dark': #1a4a8a, // primary button bg
|
||||||
'blue-surface': #1e3a5f, // active tab bg, mode tab bg
|
'blue-darker': #2060aa, // primary button hover
|
||||||
'blue-deep': #0f2240, // analyze button hover bg
|
// legacy aliases — kept so other partials don't break
|
||||||
'blue-badge': #0d1e30, // sidebar header background
|
'blue-muted': #4da6ff,
|
||||||
|
'blue-surface': #1a3a5c,
|
||||||
|
'blue-deep': #0d2240,
|
||||||
|
'blue-badge': #0d1319,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Signal / semantic colors (green/yellow/red/orange are CSS color names — must be quoted)
|
// Semantic colors
|
||||||
$signals: (
|
$signals: (
|
||||||
'green': #4ade80,
|
// green
|
||||||
'green-bg': #14532d33,
|
'green': #34d17a,
|
||||||
'yellow': #facc15,
|
'green-dim': #0d2e1a,
|
||||||
'yellow-bg': #71350033,
|
'green-mid': #1a4a2a,
|
||||||
'red': #f87171,
|
'green-bg': #0d2e1a, // alias for old name
|
||||||
'red-bg': #450a0a33,
|
// red
|
||||||
'red-deep': #450a0a55, // error banner bg
|
'red': #f05a5a,
|
||||||
'red-border': #7f1d1d, // error banner border
|
'red-dim': #2e0d0d,
|
||||||
'orange': #fb923c,
|
'red-bg': #2e0d0d,
|
||||||
|
'red-deep': #2e0d0d,
|
||||||
|
'red-border': #4a1a1a,
|
||||||
|
// amber / warning
|
||||||
|
'amber': #f0b429,
|
||||||
|
'amber-dim': #2e2000,
|
||||||
|
// orange — alias kept
|
||||||
|
'orange': #f0b429,
|
||||||
|
// purple — glossary accent
|
||||||
|
'purple': #a78bfa,
|
||||||
|
'purple-dim': #1e1535,
|
||||||
|
// yellow alias
|
||||||
|
'yellow': #f0b429,
|
||||||
|
'yellow-bg': #2e2000,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Emit all maps as CSS custom properties ───────────────────────────────
|
// ── Emit all maps as CSS custom properties ───────────────────────────────
|
||||||
@@ -62,6 +74,9 @@ $signals: (
|
|||||||
@each $name, $val in $signals { --#{$name}: #{$val}; }
|
@each $name, $val in $signals { --#{$name}: #{$val}; }
|
||||||
|
|
||||||
// Typography
|
// Typography
|
||||||
|
--font-ui: 'Inter', -apple-system, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||||
|
|
||||||
--fs-2xs: 9px;
|
--fs-2xs: 9px;
|
||||||
--fs-xs: 10px;
|
--fs-xs: 10px;
|
||||||
--fs-sm: 11px;
|
--fs-sm: 11px;
|
||||||
@@ -87,5 +102,6 @@ $signals: (
|
|||||||
--space-3xl: 32px;
|
--space-3xl: 32px;
|
||||||
|
|
||||||
// Transitions
|
// Transitions
|
||||||
--transition: 0.15s;
|
--transition: 0.18s ease;
|
||||||
|
--transition-slow: 0.28s cubic-bezier(.4,0,.2,1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user