Brajmovech commited on
Commit
dfe26b6
·
1 Parent(s): 6ff9f9f

feat: add ticker autocomplete with local database prefix search

Browse files
app.py CHANGED
@@ -155,11 +155,12 @@ def _report_matches_symbol(report: dict, target: str) -> bool:
155
 
156
  try:
157
  from ticker_validator import validate_ticker as _validate_ticker
158
- from ticker_db import load_ticker_db as _load_ticker_db
159
  _VALIDATOR_AVAILABLE = True
160
  except ImportError:
161
  _VALIDATOR_AVAILABLE = False
162
  _load_ticker_db = None
 
163
 
164
  _validation_logger = logging.getLogger("iris.ticker_validation")
165
 
@@ -740,6 +741,26 @@ def health_check():
740
  "ticker_count": ticker_count}), 200
741
 
742
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
743
  if __name__ == '__main__':
744
  # Run the Flask app
745
  app.run(debug=True, port=5000)
 
155
 
156
  try:
157
  from ticker_validator import validate_ticker as _validate_ticker
158
+ from ticker_db import load_ticker_db as _load_ticker_db, search_tickers as _search_tickers
159
  _VALIDATOR_AVAILABLE = True
160
  except ImportError:
161
  _VALIDATOR_AVAILABLE = False
162
  _load_ticker_db = None
163
+ _search_tickers = None
164
 
165
  _validation_logger = logging.getLogger("iris.ticker_validation")
166
 
 
741
  "ticker_count": ticker_count}), 200
742
 
743
 
744
+ @app.route('/api/tickers/search', methods=['GET'])
745
+ def search_tickers_endpoint():
746
+ """Prefix search over the local ticker database for autocomplete."""
747
+ q = str(request.args.get('q', '') or '').strip()
748
+ if not q:
749
+ return jsonify({"results": []}), 200
750
+ try:
751
+ limit = max(1, min(int(request.args.get('limit', 8)), 50))
752
+ except (ValueError, TypeError):
753
+ limit = 8
754
+ if _VALIDATOR_AVAILABLE and _search_tickers is not None:
755
+ try:
756
+ results = _search_tickers(q, limit)
757
+ except Exception:
758
+ results = []
759
+ else:
760
+ results = []
761
+ return jsonify({"results": results}), 200
762
+
763
+
764
  if __name__ == '__main__':
765
  # Run the Flask app
766
  app.run(debug=True, port=5000)
data/ticker_names.json ADDED
The diff for this file is too large to render. See raw diff
 
static/app.js CHANGED
@@ -337,6 +337,142 @@ document.addEventListener('DOMContentLoaded', () => {
337
  }
338
  // --- End ticker validation ---
339
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  let historyRequestId = 0;
341
  const usdFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
342
 
@@ -1299,12 +1435,11 @@ document.addEventListener('DOMContentLoaded', () => {
1299
  const isLink = /^https?:\/\//i.test(url);
1300
 
1301
  const li = document.createElement('li');
1302
- const category = typeof headline === 'string' ? null : (headline?.category || null);
1303
- let liClass = 'headline-item';
1304
- if (!isLink) liClass += ' headline-item--no-url';
1305
- if (category === 'geo') liClass += ' headline-item--geo';
1306
- if (category === 'macro') liClass += ' headline-item--macro';
1307
- li.className = liClass;
1308
 
1309
  // Title — clickable link or plain span
1310
  const titleEl = document.createElement(isLink ? 'a' : 'span');
@@ -1340,12 +1475,11 @@ document.addEventListener('DOMContentLoaded', () => {
1340
  metaEl.appendChild(srcSpan);
1341
  }
1342
 
1343
- if (category === 'geo' || category === 'macro') {
1344
- const tag = document.createElement('span');
1345
- tag.className = 'headline-tag';
1346
- tag.textContent = category === 'geo' ? 'Geopolitical' : 'Macro';
1347
- titleEl.appendChild(document.createTextNode(' '));
1348
- titleEl.appendChild(tag);
1349
  }
1350
 
1351
  li.appendChild(titleEl);
@@ -1355,28 +1489,17 @@ document.addEventListener('DOMContentLoaded', () => {
1355
  }
1356
 
1357
  // Show scroll hint if list overflows its capped height
 
 
 
 
 
 
1358
  if (!headlinesList.children.length) {
1359
  const li = document.createElement('li');
1360
  li.className = 'headline-item headline-item--empty';
1361
  li.textContent = 'No recent headlines found.';
1362
  headlinesList.appendChild(li);
1363
  }
1364
-
1365
- // Scroll hint for headlines
1366
- const scrollHint = document.getElementById('headlines-scroll-hint');
1367
- if (scrollHint && headlinesList) {
1368
- requestAnimationFrame(() => {
1369
- if (headlinesList.scrollHeight > headlinesList.clientHeight + 10) {
1370
- scrollHint.classList.add('visible');
1371
- } else {
1372
- scrollHint.classList.remove('visible');
1373
- }
1374
- });
1375
- headlinesList.addEventListener('scroll', () => {
1376
- if (headlinesList.scrollTop + headlinesList.clientHeight >= headlinesList.scrollHeight - 20) {
1377
- scrollHint.classList.remove('visible');
1378
- }
1379
- });
1380
- }
1381
  }
1382
  });
 
337
  }
338
  // --- End ticker validation ---
339
 
340
+ // --- Ticker autocomplete ---
341
+ const dropdown = document.getElementById('ticker-dropdown');
342
+ const acLiveRegion = document.getElementById('ticker-ac-live');
343
+
344
+ let _acDebounceTimer = null;
345
+ let _acAbortController = null;
346
+ let _acActiveIndex = -1;
347
+ let _acResults = [];
348
+
349
+ function _hideDropdown() {
350
+ if (!dropdown) return;
351
+ dropdown.classList.add('hidden');
352
+ if (input) input.setAttribute('aria-expanded', 'false');
353
+ if (input) input.removeAttribute('aria-activedescendant');
354
+ _acActiveIndex = -1;
355
+ _acResults = [];
356
+ }
357
+
358
+ function _highlightItem(index) {
359
+ if (!dropdown) return;
360
+ const items = dropdown.querySelectorAll('.ticker-dropdown-item');
361
+ _acActiveIndex = Math.max(-1, Math.min(index, items.length - 1));
362
+ items.forEach((el, i) => {
363
+ const active = i === _acActiveIndex;
364
+ el.setAttribute('aria-selected', active ? 'true' : 'false');
365
+ if (active) {
366
+ el.scrollIntoView({ block: 'nearest' });
367
+ if (input) input.setAttribute('aria-activedescendant', el.id);
368
+ }
369
+ });
370
+ if (_acActiveIndex === -1 && input) input.removeAttribute('aria-activedescendant');
371
+ }
372
+
373
+ function _selectItem(ticker) {
374
+ _hideDropdown();
375
+ if (input) input.value = ticker;
376
+ _triggerValidation(ticker);
377
+ if (acLiveRegion) acLiveRegion.textContent = ticker + ' selected';
378
+ }
379
+
380
+ function _renderDropdown(results) {
381
+ if (!dropdown) return;
382
+ dropdown.innerHTML = '';
383
+ _acResults = results || [];
384
+ if (!_acResults.length) {
385
+ _hideDropdown();
386
+ return;
387
+ }
388
+ _acResults.forEach((item, i) => {
389
+ const li = document.createElement('li');
390
+ li.id = 'ac-item-' + i;
391
+ li.setAttribute('role', 'option');
392
+ li.className = 'ticker-dropdown-item';
393
+ li.setAttribute('aria-selected', 'false');
394
+
395
+ const tickerSpan = document.createElement('span');
396
+ tickerSpan.className = 'ticker-dropdown-ticker';
397
+ tickerSpan.textContent = item.ticker;
398
+
399
+ const nameSpan = document.createElement('span');
400
+ nameSpan.className = 'ticker-dropdown-name';
401
+ nameSpan.textContent = item.name || '';
402
+
403
+ li.appendChild(tickerSpan);
404
+ li.appendChild(nameSpan);
405
+
406
+ // mousedown prevents blur before click registers
407
+ li.addEventListener('mousedown', (e) => {
408
+ e.preventDefault();
409
+ _selectItem(item.ticker);
410
+ });
411
+
412
+ dropdown.appendChild(li);
413
+ });
414
+ dropdown.classList.remove('hidden');
415
+ if (input) input.setAttribute('aria-expanded', 'true');
416
+ _acActiveIndex = -1;
417
+ }
418
+
419
+ async function _fetchAutocomplete(query) {
420
+ if (_acAbortController) _acAbortController.abort();
421
+ _acAbortController = new AbortController();
422
+ try {
423
+ const resp = await fetch(
424
+ '/api/tickers/search?q=' + encodeURIComponent(query) + '&limit=8',
425
+ { signal: _acAbortController.signal }
426
+ );
427
+ if (!resp.ok) { _hideDropdown(); return; }
428
+ const data = await resp.json();
429
+ _renderDropdown(data.results || []);
430
+ } catch (err) {
431
+ if (err.name !== 'AbortError') _hideDropdown();
432
+ }
433
+ }
434
+
435
+ if (input) {
436
+ // Autocomplete on input — 200 ms debounce
437
+ input.addEventListener('input', () => {
438
+ clearTimeout(_acDebounceTimer);
439
+ const q = input.value.trim();
440
+ if (q.length < 1) { _hideDropdown(); return; }
441
+ _acDebounceTimer = setTimeout(() => _fetchAutocomplete(q), 200);
442
+ });
443
+
444
+ // Keyboard navigation inside the dropdown
445
+ input.addEventListener('keydown', (e) => {
446
+ if (!dropdown || dropdown.classList.contains('hidden')) return;
447
+ const items = dropdown.querySelectorAll('.ticker-dropdown-item');
448
+ if (e.key === 'ArrowDown') {
449
+ e.preventDefault();
450
+ _highlightItem(Math.min(_acActiveIndex + 1, items.length - 1));
451
+ } else if (e.key === 'ArrowUp') {
452
+ e.preventDefault();
453
+ _highlightItem(Math.max(_acActiveIndex - 1, 0));
454
+ } else if (e.key === 'Enter' && _acActiveIndex >= 0) {
455
+ e.preventDefault();
456
+ _selectItem(_acResults[_acActiveIndex].ticker);
457
+ } else if (e.key === 'Escape') {
458
+ _hideDropdown();
459
+ }
460
+ });
461
+
462
+ // Hide when focus leaves the input
463
+ input.addEventListener('blur', () => {
464
+ setTimeout(_hideDropdown, 150);
465
+ });
466
+ }
467
+
468
+ // Hide dropdown on click outside
469
+ document.addEventListener('click', (e) => {
470
+ if (dropdown && !dropdown.contains(e.target) && e.target !== input) {
471
+ _hideDropdown();
472
+ }
473
+ });
474
+ // --- End ticker autocomplete ---
475
+
476
  let historyRequestId = 0;
477
  const usdFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
478
 
 
1435
  const isLink = /^https?:\/\//i.test(url);
1436
 
1437
  const li = document.createElement('li');
1438
+ const category = String(typeof headline === 'string' ? 'financial' : (headline?.category || 'financial')).trim().toLowerCase();
1439
+ const catClass = category === 'geopolitical' ? ' headline-item--geo'
1440
+ : category === 'macro' ? ' headline-item--macro'
1441
+ : '';
1442
+ li.className = 'headline-item' + catClass + (isLink ? '' : ' headline-item--no-url');
 
1443
 
1444
  // Title — clickable link or plain span
1445
  const titleEl = document.createElement(isLink ? 'a' : 'span');
 
1475
  metaEl.appendChild(srcSpan);
1476
  }
1477
 
1478
+ if (category === 'geopolitical' || category === 'macro') {
1479
+ const tagEl = document.createElement('span');
1480
+ tagEl.className = 'headline-tag';
1481
+ tagEl.textContent = category === 'macro' ? 'Macro' : 'Geopolitical';
1482
+ metaEl.appendChild(tagEl);
 
1483
  }
1484
 
1485
  li.appendChild(titleEl);
 
1489
  }
1490
 
1491
  // Show scroll hint if list overflows its capped height
1492
+ const hintEl = headlinesList.parentElement?.querySelector('.headlines-scroll-hint');
1493
+ if (hintEl) {
1494
+ const overflows = headlinesList.scrollHeight > headlinesList.clientHeight;
1495
+ hintEl.classList.toggle('visible', overflows);
1496
+ }
1497
+
1498
  if (!headlinesList.children.length) {
1499
  const li = document.createElement('li');
1500
  li.className = 'headline-item headline-item--empty';
1501
  li.textContent = 'No recent headlines found.';
1502
  headlinesList.appendChild(li);
1503
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1504
  }
1505
  });
static/style.css CHANGED
@@ -1496,4 +1496,70 @@ footer p {
1496
  animation: shimmer 1.4s ease-in-out infinite;
1497
  }
1498
  .skeleton-line { height: 14px; width: 55%; }
1499
- .skeleton-block { height: 48px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1496
  animation: shimmer 1.4s ease-in-out infinite;
1497
  }
1498
  .skeleton-line { height: 14px; width: 55%; }
1499
+ .skeleton-block { height: 48px; }
1500
+
1501
+ /* ============================================================
1502
+ Screen-reader only utility
1503
+ ============================================================ */
1504
+ .sr-only {
1505
+ position: absolute;
1506
+ width: 1px;
1507
+ height: 1px;
1508
+ padding: 0;
1509
+ margin: -1px;
1510
+ overflow: hidden;
1511
+ clip: rect(0, 0, 0, 0);
1512
+ white-space: nowrap;
1513
+ border: 0;
1514
+ }
1515
+
1516
+ /* ============================================================
1517
+ Ticker autocomplete dropdown
1518
+ ============================================================ */
1519
+ .ticker-dropdown {
1520
+ position: absolute;
1521
+ top: calc(100% + 4px);
1522
+ left: 0;
1523
+ right: 0;
1524
+ z-index: 200;
1525
+ list-style: none;
1526
+ margin: 0;
1527
+ padding: 4px 0;
1528
+ background: var(--panel-bg);
1529
+ border: 1px solid var(--panel-border);
1530
+ border-radius: 10px;
1531
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
1532
+ max-height: 280px;
1533
+ overflow-y: auto;
1534
+ }
1535
+ .ticker-dropdown.hidden { display: none; }
1536
+
1537
+ .ticker-dropdown-item {
1538
+ display: flex;
1539
+ align-items: baseline;
1540
+ gap: 0.5rem;
1541
+ padding: 0.55rem 0.9rem;
1542
+ cursor: pointer;
1543
+ transition: background 0.1s;
1544
+ }
1545
+ .ticker-dropdown-item:hover,
1546
+ .ticker-dropdown-item[aria-selected="true"] {
1547
+ background: var(--bg-subtle);
1548
+ }
1549
+
1550
+ .ticker-dropdown-ticker {
1551
+ font-weight: 700;
1552
+ font-size: 0.9rem;
1553
+ color: var(--text-main);
1554
+ min-width: 3.5rem;
1555
+ letter-spacing: 0.02em;
1556
+ flex-shrink: 0;
1557
+ }
1558
+
1559
+ .ticker-dropdown-name {
1560
+ font-size: 0.8rem;
1561
+ color: var(--text-muted);
1562
+ white-space: nowrap;
1563
+ overflow: hidden;
1564
+ text-overflow: ellipsis;
1565
+ }
templates/index.html CHANGED
@@ -14,7 +14,7 @@
14
  })();
15
  </script>
16
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
17
- <link rel="stylesheet" href="/static/style.css?v=2">
18
  <link rel="icon" type="image/svg+xml"
19
  href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22 fill=%22%233b82f6%22>IA</text></svg>">
20
  </head>
@@ -52,7 +52,11 @@
52
  <div class="input-wrapper" id="ticker-input-wrapper">
53
  <input type="text" id="ticker-input"
54
  placeholder="Enter stock ticker (e.g., AAPL)"
55
- autocomplete="off" spellcheck="false" maxlength="10">
 
 
 
 
56
  <button type="button" id="ticker-clear" class="ticker-clear hidden"
57
  aria-label="Clear input" tabindex="-1">×</button>
58
  <span id="ticker-val-indicator" class="ticker-val-indicator hidden"
@@ -70,6 +74,7 @@
70
  </div>
71
  <div id="error-message" class="error-msg hidden"></div>
72
  </section>
 
73
 
74
  <!-- Error Banner (shown on 422 / 429 / 500 / timeout) -->
75
  <div id="error-banner" class="error-banner hidden" role="alert" aria-live="assertive">
 
14
  })();
15
  </script>
16
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
17
+ <link rel="stylesheet" href="/static/style.css?v=7">
18
  <link rel="icon" type="image/svg+xml"
19
  href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22 fill=%22%233b82f6%22>IA</text></svg>">
20
  </head>
 
52
  <div class="input-wrapper" id="ticker-input-wrapper">
53
  <input type="text" id="ticker-input"
54
  placeholder="Enter stock ticker (e.g., AAPL)"
55
+ autocomplete="off" spellcheck="false" maxlength="10"
56
+ role="combobox" aria-expanded="false" aria-haspopup="listbox"
57
+ aria-autocomplete="list" aria-controls="ticker-dropdown">
58
+ <ul id="ticker-dropdown" role="listbox" class="ticker-dropdown hidden"
59
+ aria-label="Ticker suggestions"></ul>
60
  <button type="button" id="ticker-clear" class="ticker-clear hidden"
61
  aria-label="Clear input" tabindex="-1">×</button>
62
  <span id="ticker-val-indicator" class="ticker-val-indicator hidden"
 
74
  </div>
75
  <div id="error-message" class="error-msg hidden"></div>
76
  </section>
77
+ <div id="ticker-ac-live" class="sr-only" aria-live="polite" aria-atomic="true"></div>
78
 
79
  <!-- Error Banner (shown on 422 / 429 / 500 / timeout) -->
80
  <div id="error-banner" class="error-banner hidden" role="alert" aria-live="assertive">
tests/test_autocomplete.js ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+ /**
3
+ * Unit tests for the ticker autocomplete dropdown behaviour.
4
+ * Run with: node --test tests/test_autocomplete.js
5
+ * Requires: npm install (installs jsdom)
6
+ */
7
+
8
+ const { test, describe, beforeEach, afterEach } = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const { JSDOM } = require('jsdom');
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers to build a minimal DOM environment for each test
14
+ // ---------------------------------------------------------------------------
15
+
16
+ function buildDOM() {
17
+ const dom = new JSDOM(`<!DOCTYPE html>
18
+ <html>
19
+ <body>
20
+ <div id="ticker-input-wrapper" style="position:relative">
21
+ <input type="text" id="ticker-input" role="combobox"
22
+ aria-expanded="false" aria-haspopup="listbox"
23
+ aria-autocomplete="list" aria-controls="ticker-dropdown">
24
+ <ul id="ticker-dropdown" role="listbox" class="ticker-dropdown hidden"></ul>
25
+ </div>
26
+ <div id="ticker-ac-live" aria-live="polite"></div>
27
+ </body>
28
+ </html>`, { pretendToBeVisual: true });
29
+ return dom;
30
+ }
31
+
32
+ // Minimal standalone versions of the autocomplete helpers so we can unit-test
33
+ // without loading the full app.js (which depends on many other DOM elements).
34
+ function buildHelpers(doc) {
35
+ const input = doc.getElementById('ticker-input');
36
+ const dropdown = doc.getElementById('ticker-dropdown');
37
+ const liveRegion = doc.getElementById('ticker-ac-live');
38
+
39
+ let _acActiveIndex = -1;
40
+ let _acResults = [];
41
+
42
+ function _hideDropdown() {
43
+ dropdown.classList.add('hidden');
44
+ input.setAttribute('aria-expanded', 'false');
45
+ input.removeAttribute('aria-activedescendant');
46
+ _acActiveIndex = -1;
47
+ _acResults = [];
48
+ }
49
+
50
+ function _highlightItem(index) {
51
+ const items = dropdown.querySelectorAll('.ticker-dropdown-item');
52
+ _acActiveIndex = Math.max(-1, Math.min(index, items.length - 1));
53
+ items.forEach((el, i) => {
54
+ const active = i === _acActiveIndex;
55
+ el.setAttribute('aria-selected', active ? 'true' : 'false');
56
+ if (active) input.setAttribute('aria-activedescendant', el.id);
57
+ });
58
+ if (_acActiveIndex === -1) input.removeAttribute('aria-activedescendant');
59
+ }
60
+
61
+ function _selectItem(ticker) {
62
+ _hideDropdown();
63
+ input.value = ticker;
64
+ if (liveRegion) liveRegion.textContent = ticker + ' selected';
65
+ }
66
+
67
+ function _renderDropdown(results) {
68
+ dropdown.innerHTML = '';
69
+ _acResults = results || [];
70
+ if (!_acResults.length) { _hideDropdown(); return; }
71
+ _acResults.forEach((item, i) => {
72
+ const li = doc.createElement('li');
73
+ li.id = 'ac-item-' + i;
74
+ li.setAttribute('role', 'option');
75
+ li.className = 'ticker-dropdown-item';
76
+ li.setAttribute('aria-selected', 'false');
77
+
78
+ const ts = doc.createElement('span');
79
+ ts.className = 'ticker-dropdown-ticker';
80
+ ts.textContent = item.ticker;
81
+
82
+ const ns = doc.createElement('span');
83
+ ns.className = 'ticker-dropdown-name';
84
+ ns.textContent = item.name || '';
85
+
86
+ li.appendChild(ts);
87
+ li.appendChild(ns);
88
+ li.addEventListener('mousedown', (e) => { e.preventDefault(); _selectItem(item.ticker); });
89
+ dropdown.appendChild(li);
90
+ });
91
+ dropdown.classList.remove('hidden');
92
+ input.setAttribute('aria-expanded', 'true');
93
+ _acActiveIndex = -1;
94
+ }
95
+
96
+ return { _hideDropdown, _highlightItem, _selectItem, _renderDropdown,
97
+ get acActiveIndex() { return _acActiveIndex; },
98
+ get acResults() { return _acResults; } };
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Tests
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe('autocomplete dropdown rendering', () => {
106
+
107
+ test('test_render_shows_dropdown_with_results', () => {
108
+ const { document } = buildDOM().window;
109
+ const { _renderDropdown } = buildHelpers(document);
110
+ const dropdown = document.getElementById('ticker-dropdown');
111
+ const input = document.getElementById('ticker-input');
112
+
113
+ _renderDropdown([
114
+ { ticker: 'AAPL', name: 'Apple Inc.' },
115
+ { ticker: 'AAMT', name: 'AAMT Corp' },
116
+ ]);
117
+
118
+ assert.equal(dropdown.classList.contains('hidden'), false,
119
+ 'dropdown should be visible after rendering results');
120
+ assert.equal(input.getAttribute('aria-expanded'), 'true');
121
+ assert.equal(dropdown.querySelectorAll('.ticker-dropdown-item').length, 2);
122
+ });
123
+
124
+ test('test_render_empty_hides_dropdown', () => {
125
+ const { document } = buildDOM().window;
126
+ const { _renderDropdown } = buildHelpers(document);
127
+ const dropdown = document.getElementById('ticker-dropdown');
128
+
129
+ _renderDropdown([]);
130
+
131
+ assert.equal(dropdown.classList.contains('hidden'), true,
132
+ 'dropdown should be hidden when results are empty');
133
+ });
134
+
135
+ test('test_item_has_ticker_and_name_spans', () => {
136
+ const { document } = buildDOM().window;
137
+ const { _renderDropdown } = buildHelpers(document);
138
+ const dropdown = document.getElementById('ticker-dropdown');
139
+
140
+ _renderDropdown([{ ticker: 'NVDA', name: 'NVIDIA Corp' }]);
141
+
142
+ const item = dropdown.querySelector('.ticker-dropdown-item');
143
+ assert.ok(item, 'item should exist');
144
+ assert.equal(item.querySelector('.ticker-dropdown-ticker').textContent, 'NVDA');
145
+ assert.equal(item.querySelector('.ticker-dropdown-name').textContent, 'NVIDIA Corp');
146
+ });
147
+
148
+ });
149
+
150
+ describe('autocomplete keyboard navigation', () => {
151
+
152
+ test('test_arrow_down_highlights_first_item', () => {
153
+ const { document } = buildDOM().window;
154
+ const { _renderDropdown, _highlightItem, acActiveIndex } = buildHelpers(document);
155
+
156
+ _renderDropdown([
157
+ { ticker: 'AAPL', name: 'Apple Inc.' },
158
+ { ticker: 'AAP', name: 'Advance Auto' },
159
+ ]);
160
+
161
+ const helpers = buildHelpers(document);
162
+ helpers._renderDropdown([
163
+ { ticker: 'AAPL', name: 'Apple Inc.' },
164
+ { ticker: 'AAP', name: 'Advance Auto' },
165
+ ]);
166
+ helpers._highlightItem(0);
167
+
168
+ const items = document.querySelectorAll('.ticker-dropdown-item');
169
+ // The second call to buildHelpers operated on the same DOM
170
+ // so the last rendered item set is what matters for aria-selected
171
+ // Re-query after highlight
172
+ const firstItem = document.getElementById('ac-item-0');
173
+ // helpers._acActiveIndex should be 0
174
+ assert.equal(helpers.acActiveIndex, 0);
175
+ });
176
+
177
+ test('test_select_item_fills_input_and_hides_dropdown', () => {
178
+ const { document } = buildDOM().window;
179
+ const helpers = buildHelpers(document);
180
+ const dropdown = document.getElementById('ticker-dropdown');
181
+ const input = document.getElementById('ticker-input');
182
+
183
+ helpers._renderDropdown([{ ticker: 'MSFT', name: 'Microsoft Corp' }]);
184
+ helpers._selectItem('MSFT');
185
+
186
+ assert.equal(input.value, 'MSFT');
187
+ assert.equal(dropdown.classList.contains('hidden'), true,
188
+ 'dropdown should be hidden after selection');
189
+ });
190
+
191
+ });
tests/test_ticker_search.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for GET /api/tickers/search autocomplete endpoint."""
2
+
3
+ import unittest
4
+ from unittest.mock import patch
5
+
6
+ import sys
7
+ import os
8
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
9
+
10
+ import app as flask_app
11
+
12
+
13
+ class TestTickerSearchEndpoint(unittest.TestCase):
14
+
15
+ def setUp(self):
16
+ flask_app.app.config['TESTING'] = True
17
+ self.client = flask_app.app.test_client()
18
+
19
+ def _mock_search(self, query, limit=8):
20
+ db = {
21
+ 'AAPL': 'Apple Inc.',
22
+ 'AAMT': 'AAMT CORP',
23
+ 'AAP': 'Advance Auto Parts',
24
+ 'GOOGL': 'Alphabet Inc.',
25
+ }
26
+ q = query.strip().upper()
27
+ results = sorted(t for t in db if t.startswith(q))[:limit]
28
+ return [{'ticker': t, 'name': db[t]} for t in results]
29
+
30
+ def test_returns_results_for_valid_prefix(self):
31
+ with patch('app._search_tickers', side_effect=self._mock_search):
32
+ resp = self.client.get('/api/tickers/search?q=AA')
33
+ self.assertEqual(resp.status_code, 200)
34
+ data = resp.get_json()
35
+ self.assertIn('results', data)
36
+ tickers = [r['ticker'] for r in data['results']]
37
+ self.assertIn('AAPL', tickers)
38
+ self.assertIn('AAP', tickers)
39
+
40
+ def test_returns_empty_for_blank_query(self):
41
+ resp = self.client.get('/api/tickers/search?q=')
42
+ self.assertEqual(resp.status_code, 200)
43
+ data = resp.get_json()
44
+ self.assertEqual(data['results'], [])
45
+
46
+ def test_respects_limit_parameter(self):
47
+ with patch('app._search_tickers', side_effect=self._mock_search):
48
+ resp = self.client.get('/api/tickers/search?q=A&limit=2')
49
+ self.assertEqual(resp.status_code, 200)
50
+ data = resp.get_json()
51
+ self.assertLessEqual(len(data['results']), 2)
52
+
53
+ def test_result_items_have_ticker_and_name(self):
54
+ with patch('app._search_tickers', side_effect=self._mock_search):
55
+ resp = self.client.get('/api/tickers/search?q=AAPL')
56
+ self.assertEqual(resp.status_code, 200)
57
+ results = resp.get_json()['results']
58
+ self.assertTrue(len(results) > 0)
59
+ first = results[0]
60
+ self.assertIn('ticker', first)
61
+ self.assertIn('name', first)
62
+ self.assertEqual(first['ticker'], 'AAPL')
63
+ self.assertEqual(first['name'], 'Apple Inc.')
64
+
65
+
66
+ if __name__ == '__main__':
67
+ unittest.main()
ticker_db.py CHANGED
@@ -13,10 +13,12 @@ import requests
13
  logger = logging.getLogger(__name__)
14
 
15
  _DATA_FILE = os.path.join(os.path.dirname(__file__), "data", "valid_tickers.json")
 
16
  _SEC_URL = "https://www.sec.gov/files/company_tickers.json"
17
  _USER_AGENT = "IRIS-AI-Demo admin@iris-ai.app"
18
 
19
  _ticker_cache: set[str] | None = None
 
20
 
21
 
22
  def initialize_ticker_db() -> set[str]:
@@ -35,7 +37,8 @@ def initialize_ticker_db() -> set[str]:
35
  )
36
  response.raise_for_status()
37
  data = response.json()
38
- tickers = {entry["ticker"].upper() for entry in data.values()}
 
39
  logger.info("Downloaded %d tickers from SEC.", len(tickers))
40
 
41
  os.makedirs(os.path.dirname(_DATA_FILE), exist_ok=True)
@@ -43,6 +46,10 @@ def initialize_ticker_db() -> set[str]:
43
  json.dump(sorted(tickers), f)
44
  logger.info("Saved ticker database to %s.", _DATA_FILE)
45
 
 
 
 
 
46
  except Exception as exc:
47
  logger.warning("Failed to download SEC tickers: %s", exc)
48
  if os.path.exists(_DATA_FILE):
@@ -92,6 +99,41 @@ def find_similar_tickers(ticker: str, max_results: int = 3) -> list[str]:
92
  return difflib.get_close_matches(normalized, db, n=max_results, cutoff=0.6)
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  if __name__ == "__main__":
96
  logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
97
  initialize_ticker_db()
 
13
  logger = logging.getLogger(__name__)
14
 
15
  _DATA_FILE = os.path.join(os.path.dirname(__file__), "data", "valid_tickers.json")
16
+ _NAME_FILE = os.path.join(os.path.dirname(__file__), "data", "ticker_names.json")
17
  _SEC_URL = "https://www.sec.gov/files/company_tickers.json"
18
  _USER_AGENT = "IRIS-AI-Demo admin@iris-ai.app"
19
 
20
  _ticker_cache: set[str] | None = None
21
+ _name_cache: dict[str, str] | None = None
22
 
23
 
24
  def initialize_ticker_db() -> set[str]:
 
37
  )
38
  response.raise_for_status()
39
  data = response.json()
40
+ names = {entry["ticker"].upper(): entry.get("title", "") for entry in data.values()}
41
+ tickers = set(names.keys())
42
  logger.info("Downloaded %d tickers from SEC.", len(tickers))
43
 
44
  os.makedirs(os.path.dirname(_DATA_FILE), exist_ok=True)
 
46
  json.dump(sorted(tickers), f)
47
  logger.info("Saved ticker database to %s.", _DATA_FILE)
48
 
49
+ with open(_NAME_FILE, "w", encoding="utf-8") as f:
50
+ json.dump(names, f)
51
+ logger.info("Saved ticker names to %s.", _NAME_FILE)
52
+
53
  except Exception as exc:
54
  logger.warning("Failed to download SEC tickers: %s", exc)
55
  if os.path.exists(_DATA_FILE):
 
99
  return difflib.get_close_matches(normalized, db, n=max_results, cutoff=0.6)
100
 
101
 
102
+ def load_ticker_names() -> dict[str, str]:
103
+ """Return a dict mapping uppercase ticker symbol → company name.
104
+
105
+ Loaded from data/ticker_names.json if present; returns an empty dict otherwise.
106
+ Result is cached in memory for the lifetime of the process.
107
+ """
108
+ global _name_cache
109
+ if _name_cache is not None:
110
+ return _name_cache
111
+ if os.path.exists(_NAME_FILE):
112
+ with open(_NAME_FILE, encoding="utf-8") as f:
113
+ _name_cache = json.load(f)
114
+ logger.info("Loaded %d ticker names from %s.", len(_name_cache), _NAME_FILE)
115
+ else:
116
+ logger.warning("Ticker names file not found at %s; names unavailable.", _NAME_FILE)
117
+ _name_cache = {}
118
+ return _name_cache
119
+
120
+
121
+ def search_tickers(query: str, limit: int = 8) -> list[dict]:
122
+ """Return tickers whose symbol starts with *query* (case-insensitive prefix match).
123
+
124
+ Results are sorted alphabetically. Each entry is a dict with keys
125
+ ``ticker`` (str) and ``name`` (str, may be empty if names not loaded).
126
+ Returns an empty list if *query* is empty.
127
+ """
128
+ q = query.strip().upper()
129
+ if not q:
130
+ return []
131
+ db = load_ticker_db()
132
+ names = load_ticker_names()
133
+ matches = sorted(t for t in db if t.startswith(q))[:limit]
134
+ return [{"ticker": t, "name": names.get(t, "")} for t in matches]
135
+
136
+
137
  if __name__ == "__main__":
138
  logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
139
  initialize_ticker_db()