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
+
+
+ [close]
+
+
+
+ {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 (
+
+ setOpen(!open)}
+ onMouseEnter={() => setOpen(true)}
+ onMouseLeave={() => setOpen(false)}
+ onFocus={() => setOpen(true)}
+ onBlur={() => setOpen(false)}
+ aria-label={`Learn more about ${term}`}
+ style={{
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: 16,
+ height: 16,
+ borderRadius: '50%',
+ border: '1px solid var(--text-muted)',
+ background: 'transparent',
+ color: 'var(--text-muted)',
+ fontSize: 10,
+ lineHeight: 1,
+ cursor: 'pointer',
+ marginLeft: 4,
+ flexShrink: 0,
+ fontFamily: 'serif',
+ fontStyle: 'italic',
+ fontWeight: 600,
+ padding: 0,
+ }}
+ >
+ i
+
+ {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)
-
+
+ setShowGlossary(true)}
+ className="px-2 py-1 text-xs font-mono rounded transition-colors hover:opacity-80"
+ style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}
+ >
+ Glossary
+
+
+
@@ -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';