From 662a7179162f3bca143a88c82753fdd1e03ef500 Mon Sep 17 00:00:00 2001 From: saikiranvella Date: Tue, 9 Jun 2026 19:34:31 -0400 Subject: [PATCH] UI enhancemnts --- Dockerfile | 49 + Dockerfile.api | 19 + Dockerfile.ui | 31 + docker-compose.yml | 25 + glossary-prototype.html | 1368 +++++++++++++++++ llm-analysis-prototype.html | 631 ++++++++ package.json | 5 +- server/app.ts | 8 +- server/domains/auth/AuthService.ts | 2 + server/domains/screener/ScreenerEngine.ts | 16 +- server/domains/screener/scorers/BondScorer.ts | 4 + server/domains/screener/scorers/EtfScorer.ts | 69 +- .../domains/screener/scorers/StockScorer.ts | 61 +- .../domains/screener/screener.controller.ts | 53 +- .../domains/screener/transform/DataMapper.ts | 38 +- server/domains/shared/config/constants.ts | 1 + .../domains/shared/db/DatabaseConnection.ts | 9 + server/domains/shared/db/queries.constant.ts | 106 ++ server/domains/shared/entities/Etf.ts | 28 +- server/domains/shared/index.ts | 2 + .../persistence/SignalSnapshotRepository.ts | 90 ++ .../shared/services/BenchmarkProvider.ts | 42 +- server/domains/shared/types/asset.model.ts | 13 + server/domains/shared/types/index.ts | 8 +- server/domains/shared/types/models.model.ts | 22 +- .../shared/types/repositories.model.ts | 22 + .../domains/watchlist/WatchlistRepository.ts | 35 + server/domains/watchlist/index.ts | 2 + .../domains/watchlist/watchlist.controller.ts | 53 + tests/benchmark-regime.test.ts | 43 + tests/etf-scorer.test.ts | 43 + tests/signal-snapshot.test.ts | 87 ++ tests/stock-scorer.test.ts | 91 +- ui/src/app.html | 3 + ui/src/lib/api/index.ts | 1 + ui/src/lib/api/watchlist.ts | 25 + .../screener/AnalysisSidebar.svelte | 594 ++++++- .../lib/components/screener/AssetTable.svelte | 352 ++++- .../components/screener/GlossaryPanel.svelte | 503 ++++++ .../components/screener/SignalModal.svelte | 396 +++++ .../screener/SpeculationModal.svelte | 317 ++++ .../components/screener/WatchlistPanel.svelte | 200 +++ .../shared/MarketContextStrip.svelte | 2 +- ui/src/lib/stores/watchlist.store.svelte.ts | 75 + ui/src/lib/utils/verdicts.ts | 3 + ui/src/routes/+layout.svelte | 8 + ui/src/routes/+page.svelte | 9 +- ui/src/routes/watchlist/+page.svelte | 5 + ui/src/routes/watchlist/+page.ts | 1 + ui/src/styles/_buttons.scss | 28 + ui/src/styles/_layout.scss | 17 + ui/src/styles/_reset.scss | 8 +- ui/src/styles/_screener.scss | 596 ++++--- ui/src/styles/_sidebar.scss | 384 +++++ ui/src/styles/_tokens.scss | 88 +- 55 files changed, 6226 insertions(+), 465 deletions(-) create mode 100644 Dockerfile create mode 100644 Dockerfile.api create mode 100644 Dockerfile.ui create mode 100644 docker-compose.yml create mode 100644 glossary-prototype.html create mode 100644 llm-analysis-prototype.html create mode 100644 server/domains/shared/persistence/SignalSnapshotRepository.ts create mode 100644 server/domains/watchlist/WatchlistRepository.ts create mode 100644 server/domains/watchlist/index.ts create mode 100644 server/domains/watchlist/watchlist.controller.ts create mode 100644 tests/benchmark-regime.test.ts create mode 100644 tests/signal-snapshot.test.ts create mode 100644 ui/src/lib/api/watchlist.ts create mode 100644 ui/src/lib/components/screener/GlossaryPanel.svelte create mode 100644 ui/src/lib/components/screener/SignalModal.svelte create mode 100644 ui/src/lib/components/screener/SpeculationModal.svelte create mode 100644 ui/src/lib/components/screener/WatchlistPanel.svelte create mode 100644 ui/src/lib/stores/watchlist.store.svelte.ts create mode 100644 ui/src/routes/watchlist/+page.svelte create mode 100644 ui/src/routes/watchlist/+page.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fa004b6 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..f7848ad --- /dev/null +++ b/Dockerfile.api @@ -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"] diff --git a/Dockerfile.ui b/Dockerfile.ui new file mode 100644 index 0000000..97f4c58 --- /dev/null +++ b/Dockerfile.ui @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b39e7ec --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/glossary-prototype.html b/glossary-prototype.html new file mode 100644 index 0000000..bdcf016 --- /dev/null +++ b/glossary-prototype.html @@ -0,0 +1,1368 @@ + + + + + +Market Screener — UI Prototype v2 + + + + + + + + + + + +
+ + + Last screened 12:50:02 AM +
+ + +
+
+
10Y ?
+
4.55%
+
+
+
VIX ?
+
17.7
+
+
+
S&P ?
+
7,411.62
+
+
+
S&P P/E ?
+
26.7x
+
+
+
TECH P/E ?
+
33.0x
+
+
+
REIT YLD ?
+
3.5%
+
+
+
IG SPRD ?
+
0.10%
+
+
+
RATES ?
+
NORMAL
+
+
+
VOL ?
+
NORMAL
+
+
+ + +
+
+ STOCKS + 35 +
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TICKER ?
PRICE ?
SIGNAL ?
SCORE ?
CAP ?
STYLE ?
FLAGS ?
+ + + + + +
WDAY$137.88 + ⚡ Speculation + why? + +
+
+
+
+
+
+ 14 +
+
LARGE CAPHIGH GROWTH +
+
⚠ 3
+
+
Risk Flags
+
⚠ Significant drawdown: -43% in 52 weeks
+
⚠ 46% off 52-week high
+
⚠ DCF: 79% margin of safety (undervalued)
+
+
+
+
+ + +
+ +
+
P/E ?
43.0
+
PEG ?
0.57
+
ROE% ?
10.9%
+
OP MGN% ?
13.3%
+
GROSS M% ?
75.8%
+
FCF YLD% ?
11.3%
+
D/E ?
0.57
+
52W CHG ?
-43.1%
+
FROM HIGH ?
-46.4%
+
ANALYST ?
Buy
+
UPSIDE ?
+24.1%
+
DCF SAFETY ?
+79.4%
+
+ +
+ MKT ✓ + GRAHAM ✗ — P/E 43 > 15× +
+ + +
+ ⚠ -43% in 52W + ⚠ 46% off high + ⚠ DCF +79% (undervalued) +
+
+ + +
+ +
+ +
+
+ PEG Ratio + +3 STRONG +
+
PEG 0.57 — below 1.0 threshold. Paying less than growth justifies. (Gate: < 1.0)
+
+
+ +
+
+ FCF Yield + +3 STRONG +
+
FCF yield 11.3% — strongly positive free cash flow. High weight metric. (Gate: > 0%)
+
+
+ +
+
+ Analyst Consensus + +2 GOOD +
+
Rated Buy by Wall St. (Yahoo mean ≤ 2.5). Requires ≥ 3 analysts. Upside: +24.1% to target.
+
+
+ +
+
+ DCF Margin of Safety + +2 GOOD +
+
Intrinsic value 79% above current price. Stock appears undervalued vs DCF model. (Gate: ≥ 20%)
+
+
+ +
+
+ Return on Equity + +1 WEAK +
+
ROE 10.9% — above minimum but below the 15% preferred threshold. Partial score.
+
+
+ +
+
+ Operating Margin + +1 WEAK +
+
Op margin 13.3% — positive but below strong threshold for software sector. (Preferred: > 20%)
+
+
+ +
+
+ Gross Margin + +1 GOOD +
+
Gross margin 75.8% — strong for SaaS. High pricing power and product economics.
+
+
+ +
+
+ Revenue Growth + +1 MODERATE +
+
Revenue growth is positive but below the high-growth threshold. Contributes partial score.
+
+
+ +
+
+
+
NVDA$208.64✅ Strong Buy
13
MEGA CAPHIGH GROWTH
⚠ 3
Risk Flags
⚠ High volatility (β 2.20)
⚠ Analyst 43% upside to target
⚠ DCF: 153% above intrinsic value
BAC$53.63⊙ Neutral
3
MEGA CAPGROWTH
TRET$0.35✕ Avoid
0
MICRO CAPHIGH GROWTH
+
+ + + + + +
+
+
? Metric Glossary
+ +
+
+ +
+
+
✦ Highlighted metrics are relevant to the selected row
+ + +
Market Context
+
10Y Treasury YieldRate
+
Benchmark risk-free rate. The higher it goes, the more bonds compete with stocks, and the more P/E multiples compress.
Regime: <2% LOW · 2–5% NORMAL · >5% HIGH
<2% Growth wins
2–5% Balanced
>5% Value wins
+ +
VIX — Volatility IndexRisk
+
The market's "fear gauge." Measures expected 30-day S&P 500 volatility. High VIX = elevated uncertainty — risk premiums rise and valuations compress.
Regime: <15 Calm · 15–25 Normal · >25 Elevated
<15 Calm
15–25 Normal
>25 Fear
+ +
Rate RegimeRegime
+
Derived from the 10Y yield. Controls how P/E gates are adjusted. In HIGH rate regimes, the multiplier tightens from 1.5× to 1.2× — future earnings are worth less when rates are high.
HIGH rates: P/E gate = S&P P/E × 1.2 (stricter)
LOW Growth favored
NORMAL Balanced
HIGH Value favored
+ + +
Valuation
+
P/E RatioValuation
+
Price-to-Earnings. How many dollars you pay per $1 of annual profit. Uses trailing (audited) earnings — not analyst estimates. The most fundamental valuation gate.
Graham gate: <15× · Mkt-Adj gate: S&P P/E × 1.5 ≈ 40×
<15× Graham
15–35× Fair
>35× Expensive
+ +
PEG RatioValuation
+
P/E adjusted for growth rate. A PEG of 1.0 means you pay exactly what the growth justifies — Peter Lynch's "fair price" benchmark. Below 1.0 = growth is cheap.
Gate: < 1.0 (Lynch standard) · Weight: 2
<1.0 Bargain
1.0–2.0 Fair
>2.0 Costly
+ +
DCF Safety MarginValuation
+
Two-stage discounted cash flow model. Compares intrinsic value (from future FCF) to today's price. Positive = undervalued. Negative = trading above fair value. Only fires when FCF > 0.
Scoring: ≥20% → +dcf weight · <-20% → -dcf weight
≥20% Undervalued
0–20% Fair
<0% Overvalued
+ + +
Quality
+
ROE %Quality
+
Return on Equity. Profit generated per $1 of shareholder equity. The best single proxy for whether management allocates capital well. Warren Buffett's preferred quality metric.
Gate: >15% preferred · Weight: 3 (highest tier)
>20% Exceptional
10–20% Decent
<10% Weak
+ +
Operating Margin %Quality
+
Operating profit as % of revenue. Measures core business efficiency before interest and tax. Expanding margins signal pricing power and operational leverage over time.
Varies by sector · Weight: 2
>20% Strong
10–20% Decent
<10% Thin
+ +
FCF Yield %Quality
+
Free Cash Flow yield. Real cash generated after capex, divided by market cap. Unlike earnings, FCF cannot be easily manipulated by accounting choices. Negative FCF is a hard gate failure.
Gate: FCF must be > 0 · Weight: 3 (highest tier)
>5% Strong
1–5% Adequate
<0% Negative
+ +
Gross Margin %Quality
+
Revenue minus cost of goods, as % of revenue. Shows product economics and pricing power before any overhead. High gross margin = room to invest in growth without destroying profitability.
Display only — not yet a hard gate · informational
>50% Premium
30–50% Decent
<30% Thin
+ + +
Risk
+
Debt / EquityRisk
+
Total debt divided by shareholders' equity. Measures financial leverage. Higher D/E means more debt relative to the equity cushion — raises distress risk when earnings decline.
Gate: <1.5× (Graham) · Tech allowed up to 2.0×
<0.5 Conservative
0.5–1.5 Acceptable
>1.5 Leveraged
+ +
Beta (β)Risk
+
Measures how much a stock moves relative to the S&P 500. β = 1.0 tracks the market. β = 2.2 means 2.2× the move — in both directions. High beta stocks can gap down quickly on bad news.
Flag triggered: β > 1.5
<1.0 Low vol
1.0–1.5 Market-like
>1.5 High vol
+ +
52W Change %Momentum
+
Total price return over the past 52 weeks. Used with "From High" to detect strong momentum (uptrend) or deep dip (opportunity or distress) conditions.
Flag: ≥+50% uptrend · ≤-30% significant drawdown
≤-30% Drawdown
-30 to +30%
≥+50% Uptrend
+ +
From 52W High %Momentum
+
How far below the 52-week peak the stock currently sits. Negative = below its recent high. Large negative values signal either dip opportunity (quality stocks) or distress (weak stocks).
Flag: >20% off high
0% At peak
-5 to -20% Dip
<-20% Deep dip
+ + +
Signals
+
SignalOutput
+
The combined verdict from both scoring modes. Strong Buy = passes both Graham AND Mkt-Adjusted. Speculation = passes Mkt-Adj only. Avoid = fails both. Neutral = borderline in one or both.
Derived by: comparing Fundamental + Inflated mode verdicts
Strong Buy Both pass
Neutral Borderline
Avoid Both fail
+ +
ScoreOutput
+
Weighted sum of all factor contributions. Revenue +4, ROE +3, FCF +3, opMargin +2, PEG +2, analyst +2, DCF +2. Displayed as a dot scale and raw number. Max is approximately 20.
Weights: ROE×3 · FCF×3 · opMargin×2 · revenue×2 · analyst×2 · dcf×2
≥10 Strong
5–10 Moderate
<5 Weak
+ +
Analyst ConsensusExpert
+
Wall Street consensus from Yahoo Finance. Scale: 1.0 = Strong Buy, 5.0 = Strong Sell. Inverted for scoring. Requires ≥ 3 analysts to prevent noise from thin coverage stocks.
Scoring: ≤2.0 full score · ≥4.0 full penalty · min 3 analysts
1.0–2.0 Strong Buy
2.0–3.0 Buy/Hold
4.0–5.0 Sell
+ +
FlagsWarning
+
Count of active risk warnings. Flags do NOT disqualify a stock — they are warnings to inform your decision. A Strong Buy with 3 flags can still be a great opportunity — but know what you're taking on.
Triggers: β>1.5 · 52W≥+50% · DCF>±30% · analyst divergence · >20% off high
0 Clean
1–2 Watch
3+ Risky
+ +
Revenue GrowthQuality
+
Year-over-year revenue growth rate. Highest weight factor in the scoring system. Strong revenue growth signals demand is real, not a cost-cutting story.
Weight: 4 (highest in system) · Gate: positive growth preferred
>15% High Growth
5–15% Growth
<0% Declining
+ +
+
+ + +
+
+
+
+
+ + + + diff --git a/llm-analysis-prototype.html b/llm-analysis-prototype.html new file mode 100644 index 0000000..7d4d305 --- /dev/null +++ b/llm-analysis-prototype.html @@ -0,0 +1,631 @@ + + + + + +LLM Analysis — Redesign Prototype + + + + + + + + +
+
❌ Before — Current Design
+
+
+ 🤖 LLM Analysis + STOCKS + × +
+
+
NEUTRAL
+
+ 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. +
+
AFFECTED INDUSTRIES
+
+
Semiconductor Equipment & Materials
+
AI disappointment from AAPL reduces near-term demand signals for chip manufacturers; capex guidance revisions possible as OEMs delay purchasing cycles.
+
+
+
Enterprise Software & Cloud Infrastructure
+
Inflation data and Fed rate expectations influence SaaS margin profiles and customer IT budget allocation; higher rates pressure growth-at-all-costs valuations.
+
+
+
Consumer Discretionary & Travel/Hospitality
+
Earnings misses at MTN signal consumer spending weakness; tariff concerns (Trump pivot) threaten cost structures for imported goods and leisure operators.
+
+
+
RELATED TICKERS TO WATCH
+
+
+ LRCX + BEAR + MEDIUM + S4 +
+
Semiconductor equipment supplier directly exposed to AI capex cycles; Apple AI letdown signals delayed fab tool orders and potential guidance misses.
+
+
+
+ ASML + BEAR + MEDIUM + S3 +
+
Upstream equipment vendor to chip makers; weakening AI demand narrative pressures customer capex visibility and order book confidence.
+
+
+
+
+ + +
+
✅ After — Redesigned
+
+ + +
+ 🤖 + LLM Analysis + STOCKS + +
+ +
+ + +
+
+ + ⊙ Neutral + +
+ 2 min ago + claude-sonnet +
+
+ + +
+ Confidence +
+
+
+ 72% +
+ +
+ Tech sector faces a consolidation phase as Apple's underwhelming AI announcements weigh on mega-cap sentiment, while financial stocks and fintech show relative strength. Market braces for inflation data and Fed decisions — elevated volatility expected across semiconductors and growth equities. +
+
+ + +
+
+ Affected Industries + 4 +
+
+ +
+ +
+
+ Semiconductor Equipment & Materials + ▼ BEAR +
+
+ AAPL AI letdown reduces near-term demand signals for chip manufacturers. Capex guidance revisions possible as OEMs delay purchasing cycles. +
+
+ +
+
+ Enterprise Software & Cloud Infrastructure + ▼ BEAR +
+
+ Higher rates pressure SaaS margin profiles and customer IT budget allocation. Growth-at-all-costs valuations face multiple compression. +
+
+ +
+
+ Consumer Discretionary & Travel + ▼ BEAR +
+
+ MTN earnings miss signals consumer spending weakness. Tariff concerns threaten cost structures for imported goods and leisure operators. +
+
+ +
+
+ Private Credit & Non-Bank Lending + ▲ BULL +
+
+ Rising yields reflect well on BDC net interest margins. Fintech lenders like SOFI benefit from institutional inflows, though spread compression is a risk. +
+
+ +
+
+ + +
+
+ Tickers to Watch + 5 +
+
+ +
+ +
+
+ LRCX + Lam Research Corp + ▼ BEARISH +
+
+ MED confidence + Screener S4 +
+
+ Semiconductor equipment supplier directly exposed to AI capex cycles. Apple AI letdown signals delayed fab tool orders and potential guidance misses. +
+
⚡ Catalyst: AAPL AI capex cut
+
+ +
+
+ ASML + ASML Holding NV + ▼ BEARISH +
+
+ MED confidence + Screener S3 +
+
+ Upstream equipment vendor. Weakening AI demand narrative pressures customer capex visibility and order book confidence near-term. +
+
⚡ Catalyst: AI capex slowdown
+
+ +
+
+ SOFI + SoFi Technologies + ▲ BULLISH +
+
+ MED confidence + Screener S6 +
+
+ Fintech lender benefiting from institutional inflows as yields rise. Watch for spread compression risk if credit conditions tighten further. +
+
⚡ Catalyst: Rate environment tailwind
+
+ +
+
+ MTN + Vail Resorts Inc + ▼ BEARISH +
+
+ HIGH confidence + Screener S2 +
+
+ Recent earnings miss directly signals consumer discretionary softness. Tariff pressure compounds cost-side risks. Monitor forward guidance closely. +
+
⚡ Catalyst: Earnings miss + tariff risk
+
+ +
+
+ NVDA + NVIDIA Corp + ⊙ WATCH +
+
+ LOW confidence + Screener S13 +
+
+ Dual exposure: benefits from AI capex but indirectly exposed if Apple's AI pullback signals broader industry caution. Monitor hyperscaler guidance. +
+
⚡ Catalyst: Hyperscaler capex announcements
+
+ +
+
+ + +
+
+ Screen these tickers to see current signals, scores, and gate results. +
+ +
+ +
+
+
+ + + diff --git a/package.json b/package.json index ad6644c..992af28 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,12 @@ "prepare": "husky" }, "lint-staged": { - "*.{ts,js}": [ + "{server,bin,tests}/**/*.{ts,js}": [ "eslint --fix", "prettier --write" + ], + "ui/src/**/*.ts": [ + "prettier --write" ] }, "dependencies": { diff --git a/server/app.ts b/server/app.ts index 577c3c9..c3bdb69 100644 --- a/server/app.ts +++ b/server/app.ts @@ -10,6 +10,7 @@ import { PortfolioAdvisor } from './domains/portfolio'; import { CallsController, CalendarService } from './domains/calls'; import { AuthController, AuthService, UserStore, verifyJwt } from './domains/auth'; import type { TokenPayload } from './domains/auth'; +import { WatchlistController, WatchlistRepository } from './domains/watchlist'; // Shared infrastructure import { @@ -19,6 +20,7 @@ import { LLMAnalyst, MarketCallRepository, PortfolioRepository, + SignalSnapshotRepository, createDb, DatabaseConnection, QueryAudit, @@ -124,12 +126,14 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption const innerWidth = Math.max(line1.length, line2.length) + 2; const hr = '─'.repeat(innerWidth); 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(pad('')); console.log(pad(line1)); console.log(pad(line2)); console.log(pad('')); console.log(`└${hr}┘\n`); + /* eslint-enable no-console */ const userStore = new UserStore(db); const authService = new AuthService(userStore, JWT_SECRET); @@ -137,7 +141,7 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption // Register controllers // 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, { authGuard, traderGuard, @@ -148,6 +152,8 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption }).register(app); new AnalyzeController(catalystCache, llm).register(app); + new WatchlistController(new WatchlistRepository(db), { authGuard }).register(app); + app.get('/health', async () => ({ status: 'ok' })); return app; diff --git a/server/domains/auth/AuthService.ts b/server/domains/auth/AuthService.ts index 4774ca4..7fd883c 100644 --- a/server/domains/auth/AuthService.ts +++ b/server/domains/auth/AuthService.ts @@ -119,9 +119,11 @@ export class AuthService { this.#store.createResetToken(user.id, token, expiresAt); 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(' Link (expires in 1 hour):'); console.log(` ${link}\n`); + /* eslint-enable no-console */ } /** diff --git a/server/domains/screener/ScreenerEngine.ts b/server/domains/screener/ScreenerEngine.ts index 85b998a..992f9be 100644 --- a/server/domains/screener/ScreenerEngine.ts +++ b/server/domains/screener/ScreenerEngine.ts @@ -143,7 +143,7 @@ export class ScreenerEngine { asset, fundamental, inflated, - signal: this.signal(fundamental.label, inflated.label), + signal: this.signal(fundamental, inflated), }); } catch (err) { results.ERROR.push({ @@ -184,13 +184,13 @@ export class ScreenerEngine { } } - private signal(fundamentalLabel: string, inflatedLabel: string): Signal { - const green = (l: string) => l.startsWith('🟢'); - const yellow = (l: string) => l.startsWith('🟡'); - if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY; - if (green(inflatedLabel) && yellow(fundamentalLabel)) return SIGNAL.MOMENTUM; - if (green(inflatedLabel) && !green(fundamentalLabel)) return SIGNAL.SPECULATION; - if (yellow(fundamentalLabel) || yellow(inflatedLabel)) return SIGNAL.NEUTRAL; + // Signal derives from the structured verdict tier — never from label strings. + // Rewording a display label can no longer silently corrupt signals. + private signal(fundamental: ScoreResult, inflated: ScoreResult): Signal { + if (fundamental.tier === 'PASS') return SIGNAL.STRONG_BUY; + if (inflated.tier === 'PASS' && fundamental.tier === 'HOLD') return SIGNAL.MOMENTUM; + if (inflated.tier === 'PASS') return SIGNAL.SPECULATION; + if (fundamental.tier === 'HOLD' || inflated.tier === 'HOLD') return SIGNAL.NEUTRAL; return SIGNAL.AVOID; } diff --git a/server/domains/screener/scorers/BondScorer.ts b/server/domains/screener/scorers/BondScorer.ts index 91eb200..7ccf689 100644 --- a/server/domains/screener/scorers/BondScorer.ts +++ b/server/domains/screener/scorers/BondScorer.ts @@ -22,6 +22,8 @@ export class BondScorer { if (metrics.creditRatingNumeric < gates.minCreditRating) { return { label: '🔴 REJECT', + tier: 'REJECT', + score: null, scoreSummary: `Credit rating gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`, audit: { passedGates: false, @@ -42,6 +44,8 @@ export class BondScorer { return { label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid', + tier: score >= 4 ? 'PASS' : score >= 1 ? 'HOLD' : 'REJECT', + score, scoreSummary: `Score: ${score}`, audit: { passedGates: true, breakdown }, }; diff --git a/server/domains/screener/scorers/EtfScorer.ts b/server/domains/screener/scorers/EtfScorer.ts index db1654b..e71cd5f 100644 --- a/server/domains/screener/scorers/EtfScorer.ts +++ b/server/domains/screener/scorers/EtfScorer.ts @@ -1,6 +1,13 @@ import type { EtfMetrics, ScoreResult } from '../../../domains/shared'; 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( m: EtfMetrics, rules: { @@ -11,51 +18,77 @@ export class EtfScorer { ): ScoreResult { const { gates, weights, thresholds } = rules; const metrics = { - expenseRatio: parseFloat(String(m.expenseRatio)) || 0, - yield: parseFloat(String(m.yield)) || 0, - volume: parseFloat(String(m.volume)) || 0, - fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0, + expenseRatio: EtfScorer.n(m.expenseRatio), + yield: EtfScorer.n(m.yield), + volume: EtfScorer.n(m.volume), + 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[] = []; - if (metrics.expenseRatio > gates.maxExpenseRatio) { + if (metrics.expenseRatio != null && metrics.expenseRatio > gates.maxExpenseRatio) { failures.push(`Expense ratio: ${metrics.expenseRatio} > ${gates.maxExpenseRatio}`); } if ( + metrics.fiveYearReturn != null && thresholds.minFiveYearReturn != null && 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}`); } if (failures.length > 0) { return { label: '🔴 REJECT', + tier: 'REJECT', + score: null, scoreSummary: `Gate failed: ${failures.map((f) => f.split(':')[0]).join(', ')}`, audit: { passedGates: false, failures }, }; } - const breakdown: Record = { - cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3, - yield: metrics.yield >= thresholds.minYield ? weights.yield : -1, - vol: metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2, - fiveYearReturn: - thresholds.minFiveYearReturn != null - ? metrics.fiveYearReturn >= thresholds.minFiveYearReturn - ? (weights.fiveYearReturn ?? 1) - : -1 - : 0, - }; + // Factors only fire when the underlying data exists. + const breakdown: Record = {}; + if (metrics.expenseRatio != null) { + breakdown.cost = metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3; + } + if (metrics.yield != null) { + breakdown.yield = metrics.yield >= thresholds.minYield ? weights.yield : -1; + } + if (metrics.volume != null) { + 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); + 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 { label: score >= 3 ? '🟢 Efficient' : score >= 0 ? '🟡 Neutral' : '🔴 Expensive/Low Yield', + tier: score >= 3 ? 'PASS' : score >= 0 ? 'HOLD' : 'REJECT', + score, scoreSummary: `Score: ${score}`, - audit: { passedGates: true, breakdown }, + audit: { passedGates: true, breakdown, coverage: { active: activeFactors, total: 4 } }, }; } } diff --git a/server/domains/screener/scorers/StockScorer.ts b/server/domains/screener/scorers/StockScorer.ts index 5c1ecf5..2566758 100644 --- a/server/domains/screener/scorers/StockScorer.ts +++ b/server/domains/screener/scorers/StockScorer.ts @@ -1,9 +1,24 @@ import type { NumVal, SanitizedMetrics, ScoreResult, StockMetrics } from '../../../domains/shared'; 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 { + if (v == null) return null; 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 { @@ -46,6 +61,8 @@ export class StockScorer { if (failures.length > 0) { return { label: '🔴 REJECT', + tier: 'REJECT', + score: null, scoreSummary: `Gate failed: ${failures.join(' | ')}`, audit: { passedGates: false, failures }, }; @@ -172,6 +189,8 @@ export class StockScorer { breakdown[f.key] = f.fn() as number; return sum + breakdown[f.key]; }, 0); + const activeFactors = Object.keys(breakdown).length; + const coverage = { active: activeFactors, total: factors.length }; const riskFlags = [ 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`, ].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 { label: StockScorer.label(totalScore), + tier: StockScorer.tier(totalScore), + 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'; } + 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 { const w52 = m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0 @@ -229,16 +278,16 @@ export class StockScorer { return { debtToEquity: StockScorer.n(m.debtToEquity), quickRatio: StockScorer.n(m.quickRatio), - peRatio: StockScorer.n(m.peRatio), - pegRatio: StockScorer.n(m.pegRatio), - priceToBook: StockScorer.n(m.priceToBook), + peRatio: StockScorer.pos(m.peRatio), + pegRatio: StockScorer.pos(m.pegRatio), + priceToBook: StockScorer.pos(m.priceToBook), netProfitMargin: StockScorer.n(m.netProfitMargin), operatingMargin: StockScorer.n(m.operatingMargin), returnOnEquity: StockScorer.n(m.returnOnEquity), revenueGrowth: StockScorer.n(m.revenueGrowth), fcfYield: StockScorer.n(m.fcfYield), dividendYield: StockScorer.n(m.dividendYield), - pFFO: StockScorer.n(m.pFFO), + pFFO: StockScorer.pos(m.pFFO), beta: StockScorer.n(m.beta), week52Position: w52, week52Change: StockScorer.n(m.week52Change), diff --git a/server/domains/screener/screener.controller.ts b/server/domains/screener/screener.controller.ts index 00acff1..f63c841 100644 --- a/server/domains/screener/screener.controller.ts +++ b/server/domains/screener/screener.controller.ts @@ -1,13 +1,15 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; import { ScreenerEngine } from './ScreenerEngine'; -import { CatalystCache } from '../../domains/shared'; -import type { LiveAssetResult } from '../../domains/shared'; +import { CatalystCache, SignalSnapshotRepository } from '../../domains/shared'; +import type { LiveAssetResult, ScreenerResult } from '../../domains/shared'; import { screenSchema } from '../../domains/shared/types/schemas'; export class ScreenerController { constructor( private readonly engine: ScreenerEngine, private readonly catalystCache: CatalystCache, + // Optional so tests and minimal setups work without a database. + private readonly snapshots?: SignalSnapshotRepository, ) {} register(app: FastifyInstance): void { @@ -21,6 +23,29 @@ export class ScreenerController { { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, 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[]) { @@ -39,6 +64,7 @@ export class ScreenerController { private async screen(req: FastifyRequest) { const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase()); const results = await this.engine.screenTickers(tickers); + this.recordSnapshots(results, req); return { ...results, 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() { const { tickers, stories } = await this.catalystCache.get(); return { tickers, stories }; diff --git a/server/domains/screener/transform/DataMapper.ts b/server/domains/screener/transform/DataMapper.ts index b6573e7..f956758 100644 --- a/server/domains/screener/transform/DataMapper.ts +++ b/server/domains/screener/transform/DataMapper.ts @@ -7,14 +7,20 @@ export class DataMapper { // ── Public entry point ──────────────────────────────────────────────────── static mapToStandardFormat(ticker: string, summary: YahooSummary): MappedData { const quoteType = summary.price?.quoteType as string | undefined; - const category = ((summary.assetProfile?.category as string) || '').toLowerCase(); - const yieldVal = (summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0; + // Prefer fundProfile.categoryName (Morningstar category, e.g. "Intermediate + // 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 = category.includes('bond') || category.includes('fixed income') || - category.includes('treasury') || - (quoteType === 'ETF' && yieldVal > 0.02 && category === ''); + category.includes('treasury'); if (quoteType === 'ETF') { return isBond @@ -143,17 +149,23 @@ export class DataMapper { } // ── 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) { + 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 { - expenseRatio: ((summary.summaryDetail?.expenseRatio as number) ?? 0) * 100, - totalAssets: (summary.summaryDetail?.totalAssets as number) ?? 0, - yield: ((summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0) * 100, - fiveYearReturn: ((summary.defaultKeyStatistics?.fiveYearAverageReturn as number) ?? 0) * 100, - volume: - (summary.summaryDetail?.averageVolume as number) ?? - (summary.price?.averageVolume as number) ?? - 0, - currentPrice: (summary.price?.regularMarketPrice as number) ?? 0, + expenseRatio: expenseRatio != null ? expenseRatio * 100 : null, + totalAssets: num(summary.summaryDetail?.totalAssets), + yield: dividendYield != null ? dividendYield * 100 : null, + fiveYearReturn: fiveYearReturn != null ? fiveYearReturn * 100 : null, + volume: num(summary.summaryDetail?.averageVolume) ?? num(summary.price?.averageVolume), + currentPrice: num(summary.price?.regularMarketPrice) ?? 0, }; } diff --git a/server/domains/shared/config/constants.ts b/server/domains/shared/config/constants.ts index c091879..47d8487 100644 --- a/server/domains/shared/config/constants.ts +++ b/server/domains/shared/config/constants.ts @@ -60,6 +60,7 @@ export const YAHOO_MODULES: string[] = [ 'defaultKeyStatistics', 'price', 'summaryDetail', + 'fundProfile', // categoryName drives ETF vs bond-fund classification in DataMapper ]; export const SIGNAL_ORDER: Record = { diff --git a/server/domains/shared/db/DatabaseConnection.ts b/server/domains/shared/db/DatabaseConnection.ts index f208dbd..302ef34 100644 --- a/server/domains/shared/db/DatabaseConnection.ts +++ b/server/domains/shared/db/DatabaseConnection.ts @@ -139,6 +139,15 @@ export class DatabaseConnection { return txn(); } + /** + * Execute a raw SQL SELECT and return all rows. + * Use only when QueryBuilder is not practical (e.g. static named queries). + */ + rawAll>(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. * Use only when QueryBuilder is not practical (e.g. auth domain with static queries). diff --git a/server/domains/shared/db/queries.constant.ts b/server/domains/shared/db/queries.constant.ts index 7fb9fb1..5f1f6af 100644 --- a/server/domains/shared/db/queries.constant.ts +++ b/server/domains/shared/db/queries.constant.ts @@ -130,6 +130,82 @@ export const RESET_TOKEN_QUERIES = { // ── 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 = ` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, @@ -167,6 +243,36 @@ export const DDL = ` snapshot TEXT NOT NULL, -- JSON object 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) ──────────────────────── diff --git a/server/domains/shared/entities/Etf.ts b/server/domains/shared/entities/Etf.ts index c512058..efad610 100644 --- a/server/domains/shared/entities/Etf.ts +++ b/server/domains/shared/entities/Etf.ts @@ -6,24 +6,34 @@ export class Etf extends Asset { constructor(data: EtfData) { 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 = { - expenseRatio: parseFloat(String(data.expenseRatio)) || 0, - totalAssets: parseFloat(String(data.totalAssets)) || 0, - yield: parseFloat(String(data.yield)) || 0, - volume: parseFloat(String(data.volume)) || 0, - fiveYearReturn: parseFloat(String(data.fiveYearReturn)) || 0, + expenseRatio: num(data.expenseRatio), + totalAssets: num(data.totalAssets), + yield: num(data.yield), + volume: num(data.volume), + fiveYearReturn: num(data.fiveYearReturn), }; } getDisplayMetrics(): Record { + const m = this.metrics; + const fmt = (v: number | null, dec: number, suffix = '') => + v != null ? `${v.toFixed(dec)}${suffix}` : '—'; return { Ticker: this.ticker, Type: 'ETF', Price: this.formatCurrency(this.currentPrice), - 'Exp Ratio%': `${this.metrics.expenseRatio.toFixed(2)}%`, - 'Yield%': `${this.metrics.yield.toFixed(2)}%`, - AUM: this.formatLargeNumber(this.metrics.totalAssets), - '5Y Return%': `${this.metrics.fiveYearReturn.toFixed(1)}%`, + 'Exp Ratio%': fmt(m.expenseRatio, 2, '%'), + 'Yield%': fmt(m.yield, 2, '%'), + AUM: m.totalAssets != null ? this.formatLargeNumber(m.totalAssets) : '—', + '5Y Return%': fmt(m.fiveYearReturn, 1, '%'), }; } } diff --git a/server/domains/shared/index.ts b/server/domains/shared/index.ts index 1e2162e..b85fdbf 100644 --- a/server/domains/shared/index.ts +++ b/server/domains/shared/index.ts @@ -25,6 +25,8 @@ export { MarketRegime } from './scoring/MarketRegime'; // Persistence (repositories) export { MarketCallRepository } from './persistence/MarketCallRepository'; export { PortfolioRepository } from './persistence/PortfolioRepository'; +export { SignalSnapshotRepository } from './persistence/SignalSnapshotRepository'; +export type { SnapshotInput } from './persistence/SignalSnapshotRepository'; export { DatabaseConnection, QueryAudit, createDb } from './db/index'; // Config & Constants diff --git a/server/domains/shared/persistence/SignalSnapshotRepository.ts b/server/domains/shared/persistence/SignalSnapshotRepository.ts new file mode 100644 index 0000000..abef074 --- /dev/null +++ b/server/domains/shared/persistence/SignalSnapshotRepository.ts @@ -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(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(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(qb); + } + + private static today(): string { + return new Date().toISOString().slice(0, 10); + } +} diff --git a/server/domains/shared/services/BenchmarkProvider.ts b/server/domains/shared/services/BenchmarkProvider.ts index 2a6ba86..6605726 100644 --- a/server/domains/shared/services/BenchmarkProvider.ts +++ b/server/domains/shared/services/BenchmarkProvider.ts @@ -12,19 +12,52 @@ export class BenchmarkProvider { private static readonly TTL_MS = 60 * 60 * 1000; 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 = { sp500Price: 5000, riskFreeRate: 4.5, vixLevel: 20, - rateRegime: 'HIGH', + rateRegime: 'NORMAL', volatilityRegime: 'NORMAL', 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'] { 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'] { 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 logger: Logger; + /** Last known rate regime — survives cache expiry so hysteresis has memory. */ + private lastRegime: MarketContext['rateRegime'] | null = null; constructor( private readonly client: YahooFinanceClient, @@ -47,6 +82,8 @@ export class BenchmarkProvider { try { if (!existsSync(BenchmarkProvider.CACHE_PATH)) return { data: null, expiresAt: 0 }; 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 }; } catch { // corrupt or missing — ignore @@ -95,7 +132,7 @@ export class BenchmarkProvider { sp500Price, riskFreeRate, vixLevel, - rateRegime: BenchmarkProvider.rateRegime(riskFreeRate), + rateRegime: BenchmarkProvider.resolveRateRegime(riskFreeRate, this.lastRegime), volatilityRegime: BenchmarkProvider.volRegime(vixLevel), benchmarks: { marketPE: BenchmarkProvider.pe(spy) ?? 22, @@ -107,6 +144,7 @@ export class BenchmarkProvider { const expiresAt = Date.now() + BenchmarkProvider.TTL_MS; this.cache = { data: context, expiresAt }; + this.lastRegime = context.rateRegime; this.saveDiskCache(context, expiresAt); return context; } catch (err) { diff --git a/server/domains/shared/types/asset.model.ts b/server/domains/shared/types/asset.model.ts index 2252242..2b5c682 100644 --- a/server/domains/shared/types/asset.model.ts +++ b/server/domains/shared/types/asset.model.ts @@ -45,12 +45,25 @@ export interface ScoreAudit { breakdown?: Record; riskFlags?: string[] | null; 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 { label: string; scoreSummary: string; 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 diff --git a/server/domains/shared/types/index.ts b/server/domains/shared/types/index.ts index c3e3fad..3ef0232 100644 --- a/server/domains/shared/types/index.ts +++ b/server/domains/shared/types/index.ts @@ -46,7 +46,13 @@ export type { BondData, BondMetrics, } 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 { BenchmarkProviderOptions, diff --git a/server/domains/shared/types/models.model.ts b/server/domains/shared/types/models.model.ts index 098debd..e2dd4e9 100644 --- a/server/domains/shared/types/models.model.ts +++ b/server/domains/shared/types/models.model.ts @@ -86,20 +86,22 @@ export interface StockMetrics { export interface EtfData { ticker?: string; currentPrice?: number; - expenseRatio?: string | number; - totalAssets?: string | number; - yield?: string | number; - volume?: string | number; - fiveYearReturn?: string | number; + expenseRatio?: string | number | null; + totalAssets?: string | number | null; + yield?: string | number | null; + volume?: string | number | null; + fiveYearReturn?: string | number | null; [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 { - expenseRatio: number; - totalAssets: number; - yield: number; - volume: number; - fiveYearReturn: number; + expenseRatio: number | null; + totalAssets: number | null; + yield: number | null; + volume: number | null; + fiveYearReturn: number | null; } // ── Bond ─────────────────────────────────────────────────────────────────── diff --git a/server/domains/shared/types/repositories.model.ts b/server/domains/shared/types/repositories.model.ts index 684b333..78e7a62 100644 --- a/server/domains/shared/types/repositories.model.ts +++ b/server/domains/shared/types/repositories.model.ts @@ -37,6 +37,28 @@ export interface HoldingRow { 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) ─────────────────────────── export interface StoreData { diff --git a/server/domains/watchlist/WatchlistRepository.ts b/server/domains/watchlist/WatchlistRepository.ts new file mode 100644 index 0000000..96aecca --- /dev/null +++ b/server/domains/watchlist/WatchlistRepository.ts @@ -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]); + } +} diff --git a/server/domains/watchlist/index.ts b/server/domains/watchlist/index.ts new file mode 100644 index 0000000..cb8c658 --- /dev/null +++ b/server/domains/watchlist/index.ts @@ -0,0 +1,2 @@ +export { WatchlistController } from './watchlist.controller.js'; +export { WatchlistRepository } from './WatchlistRepository.js'; diff --git a/server/domains/watchlist/watchlist.controller.ts b/server/domains/watchlist/watchlist.controller.ts new file mode 100644 index 0000000..7487cbd --- /dev/null +++ b/server/domains/watchlist/watchlist.controller.ts @@ -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 }; + } +} diff --git a/tests/benchmark-regime.test.ts b/tests/benchmark-regime.test.ts new file mode 100644 index 0000000..0b492ed --- /dev/null +++ b/tests/benchmark-regime.test.ts @@ -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'); + }); +}); diff --git a/tests/etf-scorer.test.ts b/tests/etf-scorer.test.ts index 894ec79..91acdae 100644 --- a/tests/etf-scorer.test.ts +++ b/tests/etf-scorer.test.ts @@ -255,6 +255,49 @@ test('EtfScorer', async (t) => { 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', () => { const metrics: EtfMetrics = { expenseRatio: 0.1, diff --git a/tests/signal-snapshot.test.ts b/tests/signal-snapshot.test.ts new file mode 100644 index 0000000..31cdb20 --- /dev/null +++ b/tests/signal-snapshot.test.ts @@ -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'), []); + }); +}); diff --git a/tests/stock-scorer.test.ts b/tests/stock-scorer.test.ts index 22994d1..c0fe17e 100644 --- a/tests/stock-scorer.test.ts +++ b/tests/stock-scorer.test.ts @@ -238,8 +238,97 @@ test('StockScorer', async (t) => { } as any; 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.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', () => { diff --git a/ui/src/app.html b/ui/src/app.html index 84ffad1..f9b6d43 100644 --- a/ui/src/app.html +++ b/ui/src/app.html @@ -4,6 +4,9 @@ + + + %sveltekit.head% diff --git a/ui/src/lib/api/index.ts b/ui/src/lib/api/index.ts index 6a2368b..248934d 100644 --- a/ui/src/lib/api/index.ts +++ b/ui/src/lib/api/index.ts @@ -6,3 +6,4 @@ export { screenTickers, fetchCatalysts, analyzeTickers } from './screener.js'; export { fetchPortfolio, addHolding, removeHolding, fetchMarketContext } from './finance.js'; export { fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar } from './calls.js'; export { login, register, authFetch } from './auth.js'; +export { fetchWatchlist, pinTicker, unpinTicker } from './watchlist.js'; diff --git a/ui/src/lib/api/watchlist.ts b/ui/src/lib/api/watchlist.ts new file mode 100644 index 0000000..4840eb3 --- /dev/null +++ b/ui/src/lib/api/watchlist.ts @@ -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 { + 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 { + 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()); +} diff --git a/ui/src/lib/components/screener/AnalysisSidebar.svelte b/ui/src/lib/components/screener/AnalysisSidebar.svelte index b5fbb77..639db85 100644 --- a/ui/src/lib/components/screener/AnalysisSidebar.svelte +++ b/ui/src/lib/components/screener/AnalysisSidebar.svelte @@ -2,7 +2,100 @@ import Spinner from '$lib/components/shared/Spinner.svelte'; 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, '$1'); + } + + // 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); + } {#if sidebar.open} @@ -17,16 +110,19 @@ > -