diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 51479ea..2e519b8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/node": "^25.5.0", "@types/react": "^19.2.14", @@ -38,6 +39,13 @@ "vitest": "^4.1.5" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -868,9 +876,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -888,9 +893,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -908,9 +910,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -928,9 +927,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -948,9 +944,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -968,9 +961,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1187,9 +1177,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1206,9 +1193,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1225,9 +1209,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1244,9 +1225,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1376,6 +1354,33 @@ "node": ">=18" } }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/react": { "version": "16.3.2", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", @@ -2139,6 +2144,13 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2706,6 +2718,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3017,9 +3039,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3040,9 +3059,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3063,9 +3079,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3086,9 +3099,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3203,6 +3213,16 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -3476,6 +3496,20 @@ "dev": true, "license": "MIT" }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3609,6 +3643,19 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5438229..d0948bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/node": "^25.5.0", "@types/react": "^19.2.14", diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx index ac37c6d..892a765 100644 --- a/frontend/src/components/DetailPanel.tsx +++ b/frontend/src/components/DetailPanel.tsx @@ -1,6 +1,8 @@ import { useEffect, useRef } from 'react'; import { createChart, CandlestickSeries, LineSeries, HistogramSeries } from 'lightweight-charts'; import { useScannerStore } from '../store/scannerStore'; +import { InfoTooltip } from './InfoTooltip'; +import { SelectionRationale } from './SelectionRationale'; export default function DetailPanel() { const { results, selectedSymbol } = useScannerStore(); @@ -153,23 +155,45 @@ export default function DetailPanel() {
-

ADR%

+

+ ADR% + Average Daily Range — the avg % difference between high and low over 20 trading days. Higher ADR means more volatility. The golden rule: ADR should be ≥5% for momentum setups. +

{stock.adr.toFixed(2)}%

-

RS Momentum

+

+ RS Momentum + Relative Strength Momentum — measures how far the stock has climbed from its lowest point. Large % moves from the low indicate strong upward momentum. Scored up to 4 points. +

{stock.rs_pct.toFixed(0)}%

-

Stop Loss

+

+ Stop Loss + The price level at which you would exit to limit losses. Set at 1.5× ATR below price or at the EMA10, whichever is higher. +

${stock.suggested_stop.toFixed(2)}

-

R:R Ratio

+

+ R:R Ratio + Risk-to-Reward — compares potential upside (to 52-week high) against potential downside (to stop loss). A ratio above 2:1 is considered favourable. +

{stock.rr_ratio.toFixed(2)}

+
+
+

+ Selection Rationale + Breakdown of how each scoring factor contributed to this stock's rating. Bars show score received vs maximum possible per category. +

+ +
+
+ {stock.signals.length > 0 && (
diff --git a/frontend/src/components/GlossaryPanel.test.tsx b/frontend/src/components/GlossaryPanel.test.tsx new file mode 100644 index 0000000..f499374 --- /dev/null +++ b/frontend/src/components/GlossaryPanel.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { GlossaryPanel } from './GlossaryPanel'; + +describe('GlossaryPanel', () => { + it('renders nothing when closed', () => { + const { container } = render( {}} />); + expect(container.textContent).toBe(''); + }); + + it('renders all glossary entries when open', () => { + render( {}} />); + expect(screen.getByText('Glossary')).toBeInTheDocument(); + expect(screen.getByText('ADR%')).toBeInTheDocument(); + expect(screen.getByText('RS Momentum')).toBeInTheDocument(); + expect(screen.getByText('EMA Alignment')).toBeInTheDocument(); + expect(screen.getByText('Tightness')).toBeInTheDocument(); + expect(screen.getByText('Volume Surge')).toBeInTheDocument(); + expect(screen.getByText('ATR')).toBeInTheDocument(); + expect(screen.getByText('Stop Loss')).toBeInTheDocument(); + expect(screen.getByText('R:R Ratio')).toBeInTheDocument(); + expect(screen.getByText('52-Week High')).toBeInTheDocument(); + expect(screen.getByText('Score')).toBeInTheDocument(); + }); + + it('has a close button', () => { + render( {}} />); + expect(screen.getByText('[close]')).toBeInTheDocument(); + }); + + it('calls onClose when clicking close button', () => { + let called = false; + render( { called = true; }} />); + fireEvent.click(screen.getByText('[close]')); + expect(called).toBe(true); + }); + + it('calls onClose on Escape key', () => { + let called = false; + render( { called = true; }} />); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(called).toBe(true); + }); + + it('sets role="dialog"', () => { + render( {}} />); + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/GlossaryPanel.tsx b/frontend/src/components/GlossaryPanel.tsx new file mode 100644 index 0000000..5e5a443 --- /dev/null +++ b/frontend/src/components/GlossaryPanel.tsx @@ -0,0 +1,123 @@ +import { useEffect, useRef } from 'react'; + +interface GlossaryEntry { + term: string; + definition: string; +} + +const GLOSSARY: GlossaryEntry[] = [ + { term: 'ADR%', definition: 'Average Daily Range — the average percentage difference between a stock\'s high and low price over the last 20 trading days. Higher ADR means more volatility and bigger potential moves.' }, + { term: 'RS Momentum', definition: 'Relative Strength Momentum — measures how far a stock has climbed from its lowest point over the lookback period. Large moves from the low indicate strong upward momentum.' }, + { term: 'EMA Alignment', definition: 'Exponential Moving Average Alignment — checks whether shorter-term EMAs (10, 20, 50) are properly stacked with price above them all. Bullish alignment means the trend is accelerating.' }, + { term: 'Tightness', definition: 'Measures how compact the last 5 days of trading are relative to the stock\'s ADR. Tight consolidation before a breakout is a classic momentum setup.' }, + { term: 'Volume Surge', definition: 'Compares recent 3-day average volume to the 20-day average. A surge above 1.5x confirms institutional interest in the move.' }, + { term: 'ATR', definition: 'Average True Range — measures market volatility by decomposing the entire range of a stock\'s price movement over 14 days. Used to set intelligent stop losses.' }, + { term: 'Stop Loss', definition: 'The price level at which you would exit a trade to limit losses. The scanner sets this 1.5× ATR below price or at the EMA10, whichever is higher.' }, + { term: 'R:R Ratio', definition: 'Risk-to-Reward Ratio — compares potential upside (to 52-week high) against potential downside (to stop loss). A ratio above 2:1 is considered favourable.' }, + { term: '52-Week High', definition: 'The highest price the stock has traded at in the last 52 weeks. Used as the target price for reward calculations.' }, + { term: 'Score', definition: 'The Qullamaggie 20-point scoring system aggregates five sub-scores: ADR (5pts), RS Momentum (4pts), EMA Alignment (7pts), Tightness (2pts), and Volume Surge (2pts). Higher is better.' }, +]; + +function GlossaryCard({ entry }: { entry: GlossaryEntry }) { + return ( +
+

+ {entry.term} +

+

+ {entry.definition} +

+
+ ); +} + +interface GlossaryPanelProps { + open: boolean; + onClose: () => void; +} + +export function GlossaryPanel({ open, onClose }: GlossaryPanelProps) { + const panelRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleEsc); + return () => document.removeEventListener('keydown', handleEsc); + }, [open, onClose]); + + useEffect(() => { + if (!open) return; + const handleClickOutside = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + onClose(); + } + }; + // Use a timeout so the click that opened it doesn't immediately close it + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + }, 0); + return () => { + clearTimeout(timer); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
+
+

+ Glossary +

+ +
+
+ {GLOSSARY.map((entry) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/InfoTooltip.test.tsx b/frontend/src/components/InfoTooltip.test.tsx new file mode 100644 index 0000000..61a9a29 --- /dev/null +++ b/frontend/src/components/InfoTooltip.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { InfoTooltip } from './InfoTooltip'; + +describe('InfoTooltip', () => { + it('renders the info icon button', () => { + render(Tooltip content); + const btn = screen.getByRole('button', { name: /learn more about score/i }); + expect(btn).toBeInTheDocument(); + }); + + it('shows tooltip on click', () => { + render(Tooltip content); + const btn = screen.getByRole('button', { name: /learn more about score/i }); + fireEvent.click(btn); + expect(screen.getByText('Tooltip content')).toBeInTheDocument(); + }); + + it('hides tooltip on second click', () => { + render(Tooltip content); + const btn = screen.getByRole('button', { name: /learn more about score/i }); + fireEvent.click(btn); + expect(screen.getByText('Tooltip content')).toBeInTheDocument(); + fireEvent.click(btn); + expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument(); + }); + + it('shows tooltip on mouse enter and hides on mouse leave', () => { + render(Tooltip content); + const btn = screen.getByRole('button', { name: /learn more about score/i }); + fireEvent.mouseEnter(btn); + expect(screen.getByText('Tooltip content')).toBeInTheDocument(); + fireEvent.mouseLeave(btn); + expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument(); + }); + + it('hides tooltip on Escape key after click', () => { + render(Tooltip content); + const btn = screen.getByRole('button', { name: /learn more about score/i }); + fireEvent.click(btn); + expect(screen.getByText('Tooltip content')).toBeInTheDocument(); + fireEvent.keyDown(btn, { key: 'Escape' }); + expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument(); + }); + + it('renders with aria-label matching the term', () => { + render(ADR explanation); + expect(screen.getByLabelText('Learn more about ADR%')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/InfoTooltip.tsx b/frontend/src/components/InfoTooltip.tsx new file mode 100644 index 0000000..216da81 --- /dev/null +++ b/frontend/src/components/InfoTooltip.tsx @@ -0,0 +1,107 @@ +import { useState, useRef, useEffect } from 'react'; + +interface InfoTooltipProps { + term: string; + children: React.ReactNode; + width?: number; +} + +export function InfoTooltip({ term, children, width = 260 }: InfoTooltipProps) { + const [open, setOpen] = useState(false); + const tooltipRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handleClickOutside = (e: MouseEvent) => { + if (tooltipRef.current && !tooltipRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [open]); + + useEffect(() => { + if (!open) return; + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('keydown', handleEsc); + return () => document.removeEventListener('keydown', handleEsc); + }, [open]); + + return ( + + + {open && ( +
+ + {term} + + {children} +
+
+ )} + + ); +} diff --git a/frontend/src/components/ResultsTable.tsx b/frontend/src/components/ResultsTable.tsx index de3c052..d59f878 100644 --- a/frontend/src/components/ResultsTable.tsx +++ b/frontend/src/components/ResultsTable.tsx @@ -1,10 +1,14 @@ +import { useState } from 'react'; import { useScannerStore } from '../store/scannerStore'; import { ScanProgressSkeleton } from './ScanProgressSkeleton'; import { EmptyState } from './EmptyState'; import { ExportToolbar } from './ExportToolbar'; +import { InfoTooltip } from './InfoTooltip'; +import { GlossaryPanel } from './GlossaryPanel'; export default function ResultsTable() { - const { results, selectedSymbol, setSelectedSymbol, progress, isLoading } = useScannerStore(); + const { results, selectedSymbol, setSelectedSymbol, progress, isLoading, filteredStocks } = useScannerStore(); + const [showGlossary, setShowGlossary] = useState(false); const getScoreColor = (score: number) => { if (score >= 15) return 'bg-green-900/50 text-green-400'; @@ -26,7 +30,17 @@ export default function ResultsTable() {

Scan Results ({results.length} stocks)

- +
+ + +
@@ -35,9 +49,18 @@ export default function ResultsTable() { Symbol Price - Score - ADR% - RS% + + Score + The Qullamaggie 20-point system. ADR (5pts), RS Momentum (4pts), EMA Alignment (7pts), Tightness (2pts), Volume Surge (2pts). Higher is better. + + + ADR% + Average Daily Range — average % difference between high and low over 20 days. Higher ADR = more volatility and bigger potential moves. + + + RS% + Relative Strength Momentum — how far the stock has climbed from its lowest point over the lookback period. + Signals @@ -74,6 +97,26 @@ export default function ResultsTable() {
+ + {filteredStocks.length > 0 && ( +
+
+ + {filteredStocks.length} stocks filtered out + +
+ {filteredStocks.map((f) => ( +
+ {f.symbol} + {f.reason} +
+ ))} +
+
+
+ )} + + setShowGlossary(false)} />
); } diff --git a/frontend/src/components/ScannerControls.tsx b/frontend/src/components/ScannerControls.tsx index 254e839..9cc899c 100644 --- a/frontend/src/components/ScannerControls.tsx +++ b/frontend/src/components/ScannerControls.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useCallback } from 'react'; import { useScannerStore } from '../store/scannerStore'; -import { calculateMetrics, filterAndSortResults } from '../utils/calculations'; +import { calculateMetrics, filterAndSortResults, type StockMetrics } from '../utils/calculations'; import { fetchBatchHistoricalData, STOCK_UNIVERSES, type StockUniverse } from '../services/stockApi'; import { UniverseSelect, FilterInputs } from './FilterComponents'; import { ScannerStatus } from './ScannerStatus'; @@ -14,6 +14,7 @@ export default function ScannerControls() { setError, setProgress, setResults, + setFilteredStocks, isLoading, error, results, @@ -44,14 +45,18 @@ export default function ScannerControls() { throw new Error('No data received. Please try again.'); } - const scanResults = []; + const scanResults: StockMetrics[] = []; + const filteredList: { symbol: string; reason: string }[] = []; for (const [symbol, data] of Array.from(dataMap.entries())) { - const { metrics } = calculateMetrics(data, symbol, filters); + const { metrics, reason } = calculateMetrics(data, symbol, filters); if (metrics) { scanResults.push(metrics); + } else if (reason) { + filteredList.push({ symbol, reason }); } } + setFilteredStocks(filteredList); const filteredResults = filterAndSortResults(scanResults, filters.minScore); setResults(filteredResults); setProgress(null); diff --git a/frontend/src/components/SelectionRationale.test.tsx b/frontend/src/components/SelectionRationale.test.tsx new file mode 100644 index 0000000..a8063b6 --- /dev/null +++ b/frontend/src/components/SelectionRationale.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SelectionRationale } from './SelectionRationale'; + +describe('SelectionRationale', () => { + const sampleLineage: Record = { + 'ADR (%)': '5.5% (Score: 4/5)', + 'RS Momentum': '70% (Score: 3/4)', + 'EMA Alignment': 'Bullish (Score: 6/7)', + 'Tightness (5d)': 'Compact (Score: 2/2)', + 'Volume Surge': '120% (Score: 1/2)', + }; + + it('renders the Score Breakdown header', () => { + render(); + expect(screen.getByText('Score Breakdown')).toBeInTheDocument(); + }); + + it('renders all 5 score categories', () => { + render(); + expect(screen.getByText('ADR (%)')).toBeInTheDocument(); + expect(screen.getByText('RS Momentum')).toBeInTheDocument(); + expect(screen.getByText('EMA Alignment')).toBeInTheDocument(); + expect(screen.getByText('Tightness (5d)')).toBeInTheDocument(); + expect(screen.getByText('Volume Surge')).toBeInTheDocument(); + }); + + it('renders progress bars for each score', () => { + render(); + const bars = document.querySelectorAll('[style*="width"]'); + expect(bars.length).toBeGreaterThanOrEqual(5); + }); + + it('handles missing score data gracefully', () => { + render(); + expect(screen.getByText('ADR (%)')).toBeInTheDocument(); + }); + + it('renders all 5 categories even with empty lineage', () => { + const { container } = render(); + expect(screen.getByText('Score Breakdown')).toBeInTheDocument(); + expect(screen.getByText('ADR (%)')).toBeInTheDocument(); + expect(container.textContent).toContain('0/5'); + }); + + it('ignores unknown lineage keys (only predefined categories rendered)', () => { + const { container } = render(); + expect(screen.getByText('Score Breakdown')).toBeInTheDocument(); + expect(container.textContent).not.toContain('Custom Key'); + }); +}); diff --git a/frontend/src/components/SelectionRationale.tsx b/frontend/src/components/SelectionRationale.tsx new file mode 100644 index 0000000..4738ce8 --- /dev/null +++ b/frontend/src/components/SelectionRationale.tsx @@ -0,0 +1,83 @@ +interface LineageItem { + label: string; + detail: string; + score: number; + maxScore: number; +} + +function parseLineage(lineage: Record): LineageItem[] { + const knownMetrics: { key: string; max: number }[] = [ + { key: 'ADR (%)', max: 5 }, + { key: 'RS Momentum', max: 4 }, + { key: 'EMA Alignment', max: 7 }, + { key: 'Tightness (5d)', max: 2 }, + { key: 'Volume Surge', max: 2 }, + ]; + + return knownMetrics.map(({ key, max }) => { + const val = lineage[key] || ''; + const scoreMatch = val.match(/Score:\s*(\d+)\/(\d+)/); + const score = scoreMatch ? parseInt(scoreMatch[1], 10) : 0; + const maxFromMatch = scoreMatch ? parseInt(scoreMatch[2], 10) : max; + const detail = val.replace(/\(Score:\s*\d+\/\d+\)/, '').trim(); + return { label: key, detail, score, maxScore: maxFromMatch }; + }); +} + +interface SelectionRationaleProps { + lineage: Record; + totalScore: number; +} + +export function SelectionRationale({ lineage, totalScore }: SelectionRationaleProps) { + const items = parseLineage(lineage); + + return ( +
+
+ + Score Breakdown + + + {totalScore}/20 + +
+
+ {items.map((item) => ( +
+
+ + {item.label} + + + {item.score}/{item.maxScore} + +
+
+
0 + ? 'var(--accent-amber)' + : 'var(--text-muted)', + }} + /> +
+ {item.detail && ( +

+ {item.detail} +

+ )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/store/scannerStore.ts b/frontend/src/store/scannerStore.ts index 50c4521..56b5d10 100644 --- a/frontend/src/store/scannerStore.ts +++ b/frontend/src/store/scannerStore.ts @@ -2,10 +2,16 @@ import { create } from 'zustand'; import type { StockMetrics, ScanConfig } from '../utils/calculations'; import { STOCK_UNIVERSES, getAllUniverses, type StockUniverse } from '../services/stockApi'; +interface FilteredStock { + symbol: string; + reason: string; +} + interface ScannerState { // Data universe: string[]; results: StockMetrics[]; + filteredStocks: FilteredStock[]; selectedSymbol: string | null; // UI State @@ -19,6 +25,7 @@ interface ScannerState { // Actions setUniverse: (universe: string[]) => void; setResults: (results: StockMetrics[]) => void; + setFilteredStocks: (filteredStocks: FilteredStock[]) => void; setSelectedSymbol: (symbol: string | null) => void; setIsLoading: (loading: boolean) => void; setError: (error: string | null) => void; @@ -41,6 +48,7 @@ export const useScannerStore = create((set) => ({ // Initial state universe: STOCK_UNIVERSES[2].symbols, results: [], + filteredStocks: [], selectedSymbol: null, isLoading: false, error: null, @@ -54,6 +62,8 @@ export const useScannerStore = create((set) => ({ setResults: (results) => set({ results, error: null }), + setFilteredStocks: (filteredStocks) => set({ filteredStocks }), + setSelectedSymbol: (symbol) => set({ selectedSymbol: symbol }), setIsLoading: (loading) => set({ isLoading: loading }), @@ -86,6 +96,7 @@ export const useScannerStore = create((set) => ({ resetScan: () => set({ results: [], + filteredStocks: [], selectedSymbol: null, progress: null, isLoading: false, diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 889dd21..8661530 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1,4 +1,5 @@ import '@testing-library/dom'; +import '@testing-library/jest-dom/vitest'; import { cleanup } from '@testing-library/react'; import { afterEach } from 'vitest';