[object Object]

← back to Stock Vshape Viewer

Market scanner: NASDAQ+DJIA universe scan (buy-score ranked, live progress), CBOE cheap far-dated options finder ($0.05-0.15 >3mo, puts+calls), RSI+vol-surge, shared screen.js engine, $8 tradeable floor

23089cb1e59e1a35ee7d14c981da879aea4bdca3 · 2026-08-27 11:01:31 -0700 · Steve

Files touched

Diff

commit 23089cb1e59e1a35ee7d14c981da879aea4bdca3
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 27 11:01:31 2026 -0700

    Market scanner: NASDAQ+DJIA universe scan (buy-score ranked, live progress), CBOE cheap far-dated options finder ($0.05-0.15 >3mo, puts+calls), RSI+vol-surge, shared screen.js engine, $8 tradeable floor
---
 public/app.js     | 121 ++++++++++++++++++++++++---
 public/data.js    | 102 +++--------------------
 public/index.html |  45 +++++++++-
 public/screen.js  | 159 +++++++++++++++++++++++++++++++++++
 server.js         | 242 +++++++++++++++++++++++++++++++++++-------------------
 5 files changed, 480 insertions(+), 189 deletions(-)

diff --git a/public/app.js b/public/app.js
index cb8bd23..97e2d8e 100644
--- a/public/app.js
+++ b/public/app.js
@@ -183,17 +183,7 @@ function rebuildScene() {
 
 // ============================ filters ============================
 function passFilter(t) {
-  const D = state.data[t]; if (!D) return false;
-  const f = state.filters, an = D.an;
-  if (an.drawdown > -f.minDip) return false;
-  if (an.pctFromHigh > f.maxFromHigh) return false;
-  if (an.C < f.minPrice) return false;
-  if (f.reqNewHigh && !an.newHigh) return false;
-  if (f.dipTo50 && !an.dipTo50) return false;
-  if (f.dipTo200 && !an.dipTo200) return false;
-  if (f.below50 && !an.below50) return false;
-  if (f.below200 && !an.below200) return false;
-  return true;
+  const D = state.data[t]; return D ? window.passFilterOf(D.an, state.filters) : false;
 }
 function applyFilters() {
   let visible = 0, matched = [];
@@ -298,7 +288,11 @@ function showDetail(t) {
     `<b style="color:${an.fits ? 'var(--pass)' : 'var(--fail)'}">${an.verdict}</b> — ${an.why}`;
   renderTF();
   const dd = Math.abs(an.drawdown) >= state.filters.minDip;
+  const rsiCol = an.rsi == null ? 'var(--dim)' : an.rsi < 35 ? 'var(--pass)' : an.rsi > 70 ? 'var(--fail)' : 'var(--ink)';
   document.getElementById('d-kpis').innerHTML = `
+    <div class="kpi"><span>buy score</span><b style="color:${an.buyScore>=60?'var(--pass)':an.buyScore>=40?'var(--watch)':'var(--dim)'}">${an.buyScore}</b>/100</div>
+    <div class="kpi"><span>RSI(14)</span><b style="color:${rsiCol}">${an.rsi==null?'—':an.rsi}</b>${an.rsi==null?'':an.rsi<35?'oversold':an.rsi>70?'overbought':'neutral'}</div>
+    <div class="kpi"><span>vol surge</span><b style="color:${an.volSurge>1.3?'var(--pass)':'var(--ink)'}">${an.volSurge}×</b>vs 20d</div>
     <div class="kpi high"><span>high (${an.weeksAgoActual}w ago)</span><b>$${an.eh.price.toFixed(2)}</b>${an.eh.date.slice(5)}</div>
     <div class="kpi trough"><span>trough</span><b>$${an.tr.price.toFixed(2)}</b>${an.tr.date.slice(5)}</div>
     <div class="kpi"><span>drawdown</span><b style="color:${dd?'var(--fail)':'var(--dim)'}">${an.drawdown.toFixed(1)}%</b>${dd?'✓':'<min'}</div>
@@ -468,8 +462,111 @@ addEventListener('resize', () => {
   if (state.selected) { const D = state.data[state.selected]; if (D) { drawPriceChart(D); drawVolChart(D); } }
 });
 
+// ============================ universe scan ============================
+let scanMax = 1500, scanTimer = null;
+function filterQuery() {
+  const f = state.filters;
+  return `minDip=${f.minDip}&maxFromHigh=${f.maxFromHigh}&minPrice=${f.minPrice}&weeksAgo=${f.weeksAgo}&weeksTol=${f.weeksTol}`
+    + `&reqNewHigh=${f.reqNewHigh}&dipTo50=${f.dipTo50}&dipTo200=${f.dipTo200}&below50=${f.below50}&below200=${f.below200}&tfN=${state.tfN}`;
+}
+async function startScan() {
+  const btn = document.getElementById('scan-btn'); btn.disabled = true; btn.textContent = '⏳ Scanning…';
+  const prog = document.getElementById('scan-prog');
+  document.getElementById('scan-results').innerHTML = '';
+  try {
+    const r = await fetch(`/api/scan/start?max=${scanMax}&${filterQuery()}`);
+    const j = await r.json();
+    if (j.error || !j.job) throw new Error(j.error || 'no job');
+    prog.innerHTML = `starting… universe ${j.universe}<div class="bar"><i></i></div>`;
+    pollScan(j.job);
+  } catch (e) { prog.textContent = 'scan failed: ' + e.message; btn.disabled = false; btn.textContent = '⚡ Scan NASDAQ + DJIA (≥$8)'; }
+}
+function pollScan(job) {
+  clearTimeout(scanTimer);
+  const step = async () => {
+    try {
+      const r = await fetch(`/api/scan/status?job=${job}`); const j = await r.json();
+      const pct = j.total ? Math.round(j.scanned / j.total * 100) : 0;
+      document.getElementById('scan-prog').innerHTML =
+        `scanned ${j.scanned}/${j.total} · <b style="color:var(--pass)">${j.matchCount} matches</b><div class="bar"><i style="width:${pct}%"></i></div>`;
+      renderResults(j.matches || []);
+      if (j.done) {
+        const btn = document.getElementById('scan-btn'); btn.disabled = false; btn.textContent = '⚡ Scan NASDAQ + DJIA (≥$8)';
+        document.getElementById('scan-prog').innerHTML += `<div style="margin-top:4px;color:#6f83a8">done in ${(j.elapsedMs/1000).toFixed(0)}s · click a result to add it to the board</div>`;
+        return;
+      }
+      scanTimer = setTimeout(step, 1500);
+    } catch (e) { document.getElementById('scan-prog').textContent = 'poll error: ' + e.message; }
+  };
+  step();
+}
+function renderResults(matches) {
+  const el = document.getElementById('scan-results');
+  el.innerHTML = matches.slice(0, 60).map(m => {
+    const bcol = m.buyScore >= 60 ? 'var(--pass)' : m.buyScore >= 40 ? 'var(--watch)' : '#63769c';
+    return `<div class="r" data-t="${m.ticker}">
+      <span class="bs" style="background:rgba(87,140,255,.12);color:${bcol}">${m.buyScore}</span>
+      <span class="tk">${m.ticker}</span>
+      <span class="mm">$${m.price} · ${m.drawdown}% · RSI ${m.rsi ?? '—'} · ${m.zoneLabel.split('—')[0].trim()}</span></div>`;
+  }).join('');
+  el.querySelectorAll('.r').forEach(r => r.onclick = () => {
+    const t = r.dataset.t;
+    if (!state.tickers.includes(t)) addTicker(t); else showDetail(t);
+  });
+}
+
+// ============================ cheap far-dated options ============================
+async function loadOptions(t) {
+  const box = document.getElementById('d-opts'); box.innerHTML = '<div style="color:#6f83a8">loading CBOE chain…</div>';
+  const f = state.filters;
+  try {
+    const r = await fetch(`/api/options?ticker=${t}&min=${f.optMin}&max=${f.optMax}&minMonths=${f.optMinMonths}&type=${f.optType}`);
+    const j = await r.json();
+    if (j.error) { box.innerHTML = `<div style="color:var(--ext)">no options (${j.error})</div>`; return; }
+    if (!j.contracts || !j.contracts.length) { box.innerHTML = `<div style="color:#6f83a8">No contracts $${f.optMin}–$${f.optMax} ≥${f.optMinMonths}mo (scanned ${j.total}).</div>`; return; }
+    const head = `<div style="color:#6f83a8;margin-bottom:3px">${j.count} of ${j.total} contracts · underlying $${j.underlying}</div>
+      <table class="daily opts show"><tr><th>Type</th><th>Strike</th><th>Exp</th><th>Days</th><th>Mid</th><th>Cost</th><th>OI</th><th>BE</th></tr>`;
+    const body = j.contracts.map(c => `<tr>
+      <td class="${c.type==='call'?'opt-call':'opt-put'}">${c.type}</td><td>$${c.strike}</td><td>${c.exp.slice(2)}</td>
+      <td>${c.days}</td><td>$${c.mid}</td><td>$${c.cost}</td><td>${c.oi>=1000?(c.oi/1000).toFixed(0)+'k':c.oi}</td><td>$${c.breakeven}</td></tr>`).join('');
+    box.innerHTML = head + body + '</table>';
+  } catch (e) { box.innerHTML = `<div style="color:var(--ext)">options error: ${e.message}</div>`; }
+}
+
+function wireScanAndOptions() {
+  document.getElementById('scan-btn').onclick = startScan;
+  const sm = document.getElementById('scan-max');
+  sm.oninput = () => { scanMax = +sm.value; document.getElementById('v-scanMax').textContent = sm.value; };
+  // options criteria
+  const optSliders = [['optMin','f-optMin'],['optMax','f-optMax'],['optMinMonths','f-optMonths']];
+  const refreshOpt = () => {
+    document.getElementById('v-optRange').textContent = `$${state.filters.optMin.toFixed(2)}–$${state.filters.optMax.toFixed(2)}`;
+    document.getElementById('v-optMonths').textContent = `${state.filters.optMinMonths} mo`;
+    save(LS.filters, state.filters);
+    if (state.selected && document.getElementById('d-opts').innerHTML.includes('<table')) loadOptions(state.selected);
+  };
+  document.getElementById('f-optMin').oninput = e => { state.filters.optMin = +e.target.value; refreshOpt(); };
+  document.getElementById('f-optMax').oninput = e => { state.filters.optMax = +e.target.value; refreshOpt(); };
+  document.getElementById('f-optMonths').oninput = e => { state.filters.optMinMonths = +e.target.value; refreshOpt(); };
+  document.querySelectorAll('#seg-optType button').forEach(b => b.onclick = () => {
+    document.querySelectorAll('#seg-optType button').forEach(x => x.classList.remove('on'));
+    b.classList.add('on'); state.filters.optType = b.dataset.v; save(LS.filters, state.filters);
+    if (state.selected && document.getElementById('d-opts').innerHTML.includes('<table')) loadOptions(state.selected);
+  });
+  // lazy-load options when the detail toggle is clicked
+  document.getElementById('d-opttoggle').onclick = () => { if (state.selected) loadOptions(state.selected); };
+}
+function syncOptUI() {
+  document.getElementById('f-optMin').value = state.filters.optMin;
+  document.getElementById('f-optMax').value = state.filters.optMax;
+  document.getElementById('f-optMonths').value = state.filters.optMinMonths;
+  document.getElementById('v-optRange').textContent = `$${state.filters.optMin.toFixed(2)}–$${state.filters.optMax.toFixed(2)}`;
+  document.getElementById('v-optMonths').textContent = `${state.filters.optMinMonths} mo`;
+  document.querySelectorAll('#seg-optType button').forEach(x => x.classList.toggle('on', x.dataset.v === state.filters.optType));
+}
+
 // ============================ boot + loop ============================
-syncFilterUI(); wireFilters(); renderSaved(); updateCriteriaChips();
+syncFilterUI(); syncOptUI(); wireFilters(); wireScanAndOptions(); renderSaved(); updateCriteriaChips();
 loadAll();
 
 let t0 = performance.now();
diff --git a/public/data.js b/public/data.js
index 7514850..215bf45 100644
--- a/public/data.js
+++ b/public/data.js
@@ -1,6 +1,5 @@
-/* data.js — seed watchlist + LIVE, PARAMETRIC screen analytics.
- * All anchors/verdicts are derived from live daily prices (/api/history).
- * Every screen rule is adjustable via the filters object. */
+/* data.js — seed watchlist + filter defaults. The analysis engine lives in
+ * screen.js (window.SCREEN), shared with the server. Here we just bridge it. */
 
 window.SEED = [
   { ticker: 'NVDA', name: 'NVIDIA',           cls: 'Mega-cap',      marketCap: '~$5.45T', sector: 'Semiconductors' },
@@ -11,94 +10,15 @@ window.SEED = [
   { ticker: 'ABLV', name: 'Able View Global', cls: 'Micro-cap',     marketCap: '~$47M',   sector: 'Consumer / Beauty' },
 ];
 
-// The screen — every field is user-adjustable in the ☰ panel.
+// Every screen rule (adjustable in the ☰ panel). minPrice 8 = "tradeable" floor.
 window.DEFAULT_FILTERS = {
-  weeksAgo: 6,       // target: earlier high was ~this many weeks ago
-  weeksTol: 3,       // ± tolerance (weeks) when locating that high
-  minDip: 10,        // require a drawdown of at least this % between the two highs
-  maxFromHigh: 60,   // current close within this % of its recent high
-  minPrice: 0,       // price floor
-  reqNewHigh: true,  // require a fresh high above the earlier high
-  dipTo50: false,    // the pullback touched / undercut the 50-DMA
-  dipTo200: false,   // the pullback touched / undercut the 200-DMA
-  below50: false,    // currently below the 50-DMA
-  below200: false,   // currently below the 200-DMA
+  weeksAgo: 6, weeksTol: 3, minDip: 10, maxFromHigh: 60, minPrice: 8,
+  reqNewHigh: true, dipTo50: false, dipTo200: false, below50: false, below200: false,
+  // cheap far-dated options finder (premium $/share, months to expiry)
+  optMin: 0.05, optMax: 0.15, optMinMonths: 3, optType: 'both',
 };
 
-// simple moving average aligned to input length (null until n samples exist)
-window.sma = function (closes, n) {
-  const out = new Array(closes.length).fill(null);
-  let sum = 0;
-  for (let i = 0; i < closes.length; i++) {
-    sum += closes[i];
-    if (i >= n) sum -= closes[i - n];
-    if (i >= n - 1) out[i] = sum / n;
-  }
-  return out;
-};
-
-/* Full parametric analysis of one stock's recent window.
- * days/ma50/ma200 are the SAME length, aligned. opts = filters. */
-window.analyze = function (days, ma50, ma200, opts) {
-  const n = days.length;
-  const f = Object.assign({}, window.DEFAULT_FILTERS, opts || {});
-  const recentWin = Math.max(5, Math.min(10, Math.floor(n * 0.09)));
-
-  // (3) recent high = highest HIGH in the trailing window
-  let rh = { price: -Infinity, i: n - 1 };
-  for (let i = n - recentWin; i < n; i++) if (days[i].high > rh.price) rh = { price: days[i].high, date: days[i].date, i };
-
-  // (1) earlier high — search a window centred ~weeksAgo*5 sessions back
-  const back = Math.round(f.weeksAgo * 5), tol = Math.round(f.weeksTol * 5);
-  let lo = Math.max(0, (n - 1) - back - tol), hi = Math.min(n - recentWin - 1, (n - 1) - back + tol);
-  if (hi < lo) { lo = 0; hi = Math.max(0, n - recentWin - 1); } // fallback: whole pre-window
-  let eh = { price: -Infinity, i: lo };
-  for (let i = lo; i <= hi; i++) if (days[i].high > eh.price) eh = { price: days[i].high, date: days[i].date, i };
-  const weeksAgoActual = Math.round(((n - 1) - eh.i) / 5);
-
-  // (2) trough = lowest LOW between the two highs
-  const a = Math.min(eh.i, rh.i), b = Math.max(eh.i, rh.i);
-  let tr = { price: Infinity, i: a };
-  for (let i = a; i <= b; i++) if (days[i].low < tr.price) tr = { price: days[i].low, date: days[i].date, i };
-
-  const drawdown = eh.price > 0 ? ((tr.price - eh.price) / eh.price) * 100 : 0;
-  const newHigh = rh.price >= eh.price;
-  const dipOK = drawdown <= -f.minDip;
-  const fits = dipOK && newHigh;
-
-  // did the pullback touch / undercut the moving averages?
-  let dipTo50 = false, dipTo200 = false;
-  for (let i = a; i <= b; i++) {
-    if (ma50[i] != null && days[i].low <= ma50[i] * 1.02) dipTo50 = true;
-    if (ma200[i] != null && days[i].low <= ma200[i] * 1.03) dipTo200 = true;
-  }
-
-  // current state vs the averages
-  const C = days[n - 1].close, m50 = ma50[n - 1], m200 = ma200[n - 1];
-  const d50 = m50 ? (C / m50 - 1) * 100 : null;
-  const d200 = m200 ? (C / m200 - 1) * 100 : null;
-  const below50 = m50 != null && C < m50;
-  const below200 = m200 != null && C < m200;
-  const pctFromHigh = rh.price > 0 ? ((rh.price - C) / rh.price) * 100 : 0;
-
-  // entry-zone read
-  const near = (d) => d != null && Math.abs(d) <= 3;
-  let cls = 'neu', label = 'Neutral';
-  if (d200 != null && C <= m200 * 1.02) { cls = 'buy2'; label = 'At / below 200-DMA — deep buy zone'; }
-  else if (d50 != null && C <= m50 * 1.02 && C >= m50 * 0.90) { cls = 'buy'; label = 'At / below 50-DMA — buy zone'; }
-  else if (near(d50)) { cls = 'watch'; label = 'Testing 50-DMA'; }
-  else if (d50 != null && d50 > 12) { cls = 'ext'; label = 'Extended above 50-DMA — wait for pullback'; }
-
-  let why;
-  if (fits) why = `High ~${weeksAgoActual} wks ago, dipped ${drawdown.toFixed(1)}%, reclaimed a new high.`;
-  else if (!dipOK && newHigh) why = `New high, but dip was only ${drawdown.toFixed(1)}% (< ${f.minDip}%).`;
-  else if (dipOK && !newHigh) why = `Dipped ${drawdown.toFixed(1)}% but no new high yet (lower high).`;
-  else why = `No clean ${f.minDip}% dip + new-high sequence in this window.`;
-
-  return {
-    eh, tr, rh, recentWin, weeksAgoActual,
-    drawdown, newHigh, dipOK, fits, verdict: fits ? 'PASS' : 'FAIL', why,
-    dipTo50, dipTo200, below50, below200,
-    C, m50, m200, d50, d200, pctFromHigh, zoneCls: cls, zoneLabel: label,
-  };
-};
+// bridge the shared engine so existing calls (window.sma / window.analyze) work
+window.sma = window.SCREEN.sma;
+window.analyze = window.SCREEN.analyze;
+window.passFilterOf = window.SCREEN.passFilter;
diff --git a/public/index.html b/public/index.html
index 30e5d4f..07f0422 100644
--- a/public/index.html
+++ b/public/index.html
@@ -84,6 +84,22 @@
   .saved .s:hover{background:rgba(87,140,255,.12)}
   .saved .s .x{margin-left:auto;color:#63769c}.saved .s .x:hover{color:var(--fail)}
   .matchline{font-size:11px;color:var(--dim);padding:6px 2px}
+  .btn.scan{background:linear-gradient(180deg,#1c3a5e,#14263f);border-color:#2f5a8f}
+  .btn.scan:hover{border-color:#5c8fd6}
+  .scanrow{margin:8px 0}.scanrow label{display:flex;justify-content:space-between;font-size:11.5px;color:var(--dim);margin-bottom:4px}
+  .scanrow input[type=range]{width:100%;accent-color:#5c7cff}
+  .scanprog{font-size:11px;color:var(--dim);min-height:14px;margin:4px 0}
+  .scanprog .bar{height:5px;background:#0c1424;border-radius:3px;overflow:hidden;margin-top:4px}
+  .scanprog .bar i{display:block;height:100%;background:linear-gradient(90deg,#5c8fd6,#39d98a);width:0}
+  .results{display:flex;flex-direction:column;gap:3px;margin-top:6px;max-height:230px;overflow:auto}
+  .results .r{display:flex;align-items:center;gap:7px;padding:5px 7px;border-radius:7px;background:rgba(255,255,255,.03);cursor:pointer;font-size:11.5px}
+  .results .r:hover{background:rgba(87,140,255,.12)}
+  .results .r .bs{font-weight:700;width:26px;text-align:center;border-radius:5px;font-size:11px}
+  .results .r .tk{font-weight:700;width:50px}
+  .results .r .mm{flex:1;color:var(--dim);font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  table.opts td,table.opts th{padding:3px 5px}
+  .opt-call{color:var(--pass)}.opt-put{color:var(--fail)}
+  #d-opts{font-size:11px;max-height:180px;overflow:auto;margin:2px 0}
 
   /* ===== bottom controls ===== */
   #ctl{position:fixed;bottom:14px;left:50%;transform:translateX(-50%);z-index:15;display:flex;gap:8px;padding:8px}
@@ -141,6 +157,15 @@
 
 <div id="left">
   <div class="scroll">
+    <div class="sec">
+      <h3>Scan the market</h3>
+      <button class="btn scan" id="scan-btn" style="width:100%;padding:10px;font-weight:600">⚡ Scan NASDAQ + DJIA (≥$8)</button>
+      <div class="scanrow"><label>Max symbols <b id="v-scanMax">1500</b></label>
+        <input type="range" id="scan-max" min="100" max="4200" step="100" value="1500"></div>
+      <div id="scan-prog" class="scanprog"></div>
+      <div class="results" id="scan-results"></div>
+    </div>
+
     <div class="sec">
       <h3>Watchlist · add a stock</h3>
       <div class="addrow">
@@ -160,8 +185,8 @@
         <input type="range" id="f-minDip" min="0" max="40" step="1" value="10"></div>
       <div class="fld"><label>Max % below its recent high <b id="v-maxFromHigh">60%</b></label>
         <input type="range" id="f-maxFromHigh" min="0" max="80" step="1" value="60"></div>
-      <div class="fld"><label>Min price <b id="v-minPrice">$0</b></label>
-        <input type="range" id="f-minPrice" min="0" max="500" step="5" value="0"></div>
+      <div class="fld"><label>Tradeable price floor <b id="v-minPrice">$8</b></label>
+        <input type="range" id="f-minPrice" min="0" max="500" step="1" value="8"></div>
       <label class="chk"><input type="checkbox" id="f-reqNewHigh" checked> Require a fresh new high</label>
       <label class="chk"><input type="checkbox" id="f-dipTo50"> Dipped to the 50-DMA</label>
       <label class="chk"><input type="checkbox" id="f-dipTo200"> Dipped to the 200-DMA</label>
@@ -171,6 +196,19 @@
       <button class="btn" id="reset-filters">Reset filters</button>
     </div>
 
+    <div class="sec">
+      <h3>Cheap far-dated options</h3>
+      <div class="fld"><label>Premium range <b id="v-optRange">$0.05–$0.15</b></label>
+        <input type="range" id="f-optMin" min="0.01" max="0.30" step="0.01" value="0.05">
+        <input type="range" id="f-optMax" min="0.05" max="1.00" step="0.01" value="0.15"></div>
+      <div class="fld"><label>Min months to expiry <b id="v-optMonths">3 mo</b></label>
+        <input type="range" id="f-optMonths" min="1" max="24" step="1" value="3"></div>
+      <div class="fld"><label>Type</label>
+        <div class="segs" id="seg-optType">
+          <button data-v="both" class="on">Both</button><button data-v="calls">Calls</button><button data-v="puts">Puts</button></div></div>
+      <div style="font-size:10.5px;color:#6f83a8">Applied to the selected stock's chain (CBOE). Deep-OTM lottery tickets — pennies risked for convex upside.</div>
+    </div>
+
     <div class="sec">
       <h3>Saved searches</h3>
       <div class="ssrow"><input id="ss-name" placeholder="name this screen…" maxlength="28">
@@ -205,6 +243,8 @@
   <div class="kpis" id="d-kpis"></div>
   <div class="listtoggle" id="d-listtoggle">▸ Daily list (entry analysis)</div>
   <table class="daily" id="d-daily"></table>
+  <div class="listtoggle" id="d-opttoggle" style="color:#2ee6c8">▸ Cheap far-dated options (puts & calls)</div>
+  <div id="d-opts"></div>
   <div class="vnote" id="d-vnote"></div>
 </div>
 
@@ -224,6 +264,7 @@
   };
 })();
 </script>
+<script src="screen.js"></script>
 <script src="data.js"></script>
 <script type="importmap">
 { "imports": {
diff --git a/public/screen.js b/public/screen.js
new file mode 100644
index 0000000..55b1e01
--- /dev/null
+++ b/public/screen.js
@@ -0,0 +1,159 @@
+/* screen.js — the ONE analysis engine, shared by the Node server (require)
+ * and the browser (<script> -> window.SCREEN). Keeping it in one file means
+ * the universe scan and the on-screen chart can never disagree. */
+(function (root, factory) {
+  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
+  else root.SCREEN = factory();
+})(typeof self !== 'undefined' ? self : this, function () {
+
+  function sma(closes, n) {
+    const out = new Array(closes.length).fill(null);
+    let sum = 0;
+    for (let i = 0; i < closes.length; i++) {
+      sum += closes[i];
+      if (i >= n) sum -= closes[i - n];
+      if (i >= n - 1) out[i] = sum / n;
+    }
+    return out;
+  }
+
+  // Wilder's RSI(14), aligned to input length (null until seeded)
+  function rsi(closes, period = 14) {
+    const out = new Array(closes.length).fill(null);
+    if (closes.length < period + 1) return out;
+    let gain = 0, loss = 0;
+    for (let i = 1; i <= period; i++) { const d = closes[i] - closes[i - 1]; if (d >= 0) gain += d; else loss -= d; }
+    gain /= period; loss /= period;
+    out[period] = loss === 0 ? 100 : 100 - 100 / (1 + gain / loss);
+    for (let i = period + 1; i < closes.length; i++) {
+      const d = closes[i] - closes[i - 1], g = d > 0 ? d : 0, l = d < 0 ? -d : 0;
+      gain = (gain * (period - 1) + g) / period; loss = (loss * (period - 1) + l) / period;
+      out[i] = loss === 0 ? 100 : 100 - 100 / (1 + gain / loss);
+    }
+    return out;
+  }
+
+  const DEFAULTS = {
+    weeksAgo: 6, weeksTol: 3, minDip: 10, maxFromHigh: 60, minPrice: 8,
+    reqNewHigh: true, dipTo50: false, dipTo200: false, below50: false, below200: false,
+  };
+
+  // Full parametric analysis of one stock's windowed daily series.
+  function analyze(days, ma50, ma200, opts) {
+    const n = days.length, f = Object.assign({}, DEFAULTS, opts || {});
+    const recentWin = Math.max(5, Math.min(10, Math.floor(n * 0.09)));
+
+    let rh = { price: -Infinity, i: n - 1 };
+    for (let i = n - recentWin; i < n; i++) if (days[i].high > rh.price) rh = { price: days[i].high, date: days[i].date, i };
+
+    const back = Math.round(f.weeksAgo * 5), tol = Math.round(f.weeksTol * 5);
+    let lo = Math.max(0, (n - 1) - back - tol), hi = Math.min(n - recentWin - 1, (n - 1) - back + tol);
+    if (hi < lo) { lo = 0; hi = Math.max(0, n - recentWin - 1); }
+    let eh = { price: -Infinity, i: lo };
+    for (let i = lo; i <= hi; i++) if (days[i].high > eh.price) eh = { price: days[i].high, date: days[i].date, i };
+    const weeksAgoActual = Math.round(((n - 1) - eh.i) / 5);
+
+    const a = Math.min(eh.i, rh.i), b = Math.max(eh.i, rh.i);
+    let tr = { price: Infinity, i: a };
+    for (let i = a; i <= b; i++) if (days[i].low < tr.price) tr = { price: days[i].low, date: days[i].date, i };
+
+    const drawdown = eh.price > 0 ? ((tr.price - eh.price) / eh.price) * 100 : 0;
+    const newHigh = rh.price >= eh.price, dipOK = drawdown <= -f.minDip, fits = dipOK && newHigh;
+
+    let dipTo50 = false, dipTo200 = false;
+    for (let i = a; i <= b; i++) {
+      if (ma50[i] != null && days[i].low <= ma50[i] * 1.02) dipTo50 = true;
+      if (ma200[i] != null && days[i].low <= ma200[i] * 1.03) dipTo200 = true;
+    }
+
+    const closes = days.map(d => d.close);
+    const rsiArr = rsi(closes, 14);
+    const rsiNow = rsiArr[n - 1];
+    const vols = days.map(d => d.vol || 0);
+    let vsum = 0, vc = 0; for (let i = Math.max(0, n - 20); i < n; i++) { vsum += vols[i]; vc++; }
+    const volAvg = vc ? vsum / vc : 0;
+    const volSurge = volAvg ? vols[n - 1] / volAvg : 1;
+
+    const C = days[n - 1].close, m50 = ma50[n - 1], m200 = ma200[n - 1];
+    const d50 = m50 ? (C / m50 - 1) * 100 : null, d200 = m200 ? (C / m200 - 1) * 100 : null;
+    const below50 = m50 != null && C < m50, below200 = m200 != null && C < m200;
+    const pctFromHigh = rh.price > 0 ? ((rh.price - C) / rh.price) * 100 : 0;
+
+    const near = (d) => d != null && Math.abs(d) <= 3;
+    let zoneCls = 'neu', zoneLabel = 'Neutral';
+    if (d200 != null && C <= m200 * 1.02) { zoneCls = 'buy2'; zoneLabel = 'At / below 200-DMA — deep buy zone'; }
+    else if (d50 != null && C <= m50 * 1.02 && C >= m50 * 0.90) { zoneCls = 'buy'; zoneLabel = 'At / below 50-DMA — buy zone'; }
+    else if (near(d50)) { zoneCls = 'watch'; zoneLabel = 'Testing 50-DMA'; }
+    else if (d50 != null && d50 > 12) { zoneCls = 'ext'; zoneLabel = 'Extended above 50-DMA — wait for pullback'; }
+
+    // composite BUY SCORE 0-100 (a heuristic "worth a look" rank, not advice)
+    let score = 0;
+    if (fits) score += 30;
+    score += Math.min(20, Math.max(0, Math.abs(drawdown) - f.minDip));
+    if (rsiNow != null) { if (rsiNow >= 40 && rsiNow <= 60) score += 15; else if (rsiNow < 40) score += 12; else if (rsiNow > 70) score -= 10; }
+    if (zoneCls === 'buy2') score += 22; else if (zoneCls === 'buy') score += 18; else if (zoneCls === 'watch') score += 10; else if (zoneCls === 'ext') score -= 6;
+    if (newHigh) score += 8;
+    if (volSurge > 1.3) score += 5;
+    score = Math.max(0, Math.min(100, Math.round(score)));
+
+    let why;
+    if (fits) why = `High ~${weeksAgoActual} wks ago, dipped ${drawdown.toFixed(1)}%, reclaimed a new high.`;
+    else if (!dipOK && newHigh) why = `New high, but dip was only ${drawdown.toFixed(1)}% (< ${f.minDip}%).`;
+    else if (dipOK && !newHigh) why = `Dipped ${drawdown.toFixed(1)}% but no new high yet (lower high).`;
+    else why = `No clean ${f.minDip}% dip + new-high sequence in this window.`;
+
+    return {
+      eh, tr, rh, recentWin, weeksAgoActual, drawdown, newHigh, dipOK, fits,
+      verdict: fits ? 'PASS' : 'FAIL', why, dipTo50, dipTo200, below50, below200,
+      C, m50, m200, d50, d200, pctFromHigh, zoneCls, zoneLabel,
+      rsi: rsiNow == null ? null : Math.round(rsiNow), volSurge: +volSurge.toFixed(2), buyScore: score,
+      rsiArr,
+    };
+  }
+
+  function passFilter(an, f) {
+    if (!an) return false;
+    if (an.drawdown > -f.minDip) return false;
+    if (an.pctFromHigh > f.maxFromHigh) return false;
+    if (an.C < f.minPrice) return false;
+    if (f.reqNewHigh && !an.newHigh) return false;
+    if (f.dipTo50 && !an.dipTo50) return false;
+    if (f.dipTo200 && !an.dipTo200) return false;
+    if (f.below50 && !an.below50) return false;
+    if (f.below200 && !an.below200) return false;
+    return true;
+  }
+
+  // OCC symbol -> {exp,type,strike}. e.g. NVDA261218C00250000
+  function parseOCC(sym) {
+    const m = sym.match(/^([A-Z]+)(\d{2})(\d{2})(\d{2})([CP])(\d{8})$/);
+    if (!m) return null;
+    return { exp: `20${m[2]}-${m[3]}-${m[4]}`, type: m[5] === 'C' ? 'call' : 'put', strike: +m[6] / 1000 };
+  }
+
+  // Screen an options chain for cheap, far-dated contracts.
+  // chain = [{exp,type,strike,bid,ask,last,oi,vol,delta}], nowMs = Date.now()
+  function screenOptions(chain, nowMs, o) {
+    const min = o.min ?? 0.05, max = o.max ?? 0.15, minDays = o.minDays ?? 90, type = o.type || 'both';
+    const und = o.underlying || 0;
+    const out = [];
+    for (const c of chain) {
+      if (type !== 'both' && c.type !== (type === 'calls' ? 'call' : 'put')) continue;
+      const days = Math.round((new Date(c.exp + 'T00:00:00Z') - nowMs) / 86400000);
+      if (days < minDays) continue;
+      const mid = (c.bid > 0 && c.ask > 0) ? (c.bid + c.ask) / 2 : (c.last || 0);
+      if (!(mid >= min && mid <= max)) continue;
+      const be = c.type === 'call' ? c.strike + mid : c.strike - mid;
+      const otm = und ? (c.type === 'call' ? (c.strike - und) / und : (und - c.strike) / und) * 100 : null;
+      out.push({ type: c.type, strike: c.strike, exp: c.exp, days, mid: +mid.toFixed(2),
+        bid: c.bid, ask: c.ask, last: c.last, oi: c.oi || 0, vol: c.vol || 0, delta: c.delta,
+        breakeven: +be.toFixed(2), otmPct: otm == null ? null : +otm.toFixed(1),
+        cost: Math.round(mid * 100) }); // 1 contract = 100 shares
+    }
+    // most tradeable first: open interest, then soonest-qualifying, then cheapest
+    out.sort((a, b) => (b.oi - a.oi) || (a.days - b.days) || (a.mid - b.mid));
+    return out;
+  }
+
+  return { sma, rsi, analyze, passFilter, parseOCC, screenOptions, DEFAULTS };
+});
diff --git a/server.js b/server.js
index a6c16a6..b8c19cf 100644
--- a/server.js
+++ b/server.js
@@ -1,119 +1,193 @@
 #!/usr/bin/env node
-// Zero-dependency server for the Stock V-Shape 3D viewer.
-// - Basic-auth gated (admin / DW2024!), OS-assigned free port.
-// - /api/history?ticker=XXX proxies FREE daily OHLCV (Stooq -> Yahoo fallback),
-//   so the browser gets live prices without hitting CORS walls.
+// Zero-dependency server for the Stock V-Shape screener.
+// Basic-auth gated. Endpoints:
+//   /api/history?ticker=      live daily OHLCV (Stooq -> Yahoo)
+//   /api/universe?minPrice=   NASDAQ screener + DJIA, filtered by price
+//   /api/scan/start?...       start a background universe screen (returns job id)
+//   /api/scan/status?job=     progress + matches
+//   /api/options?ticker=      CBOE free options chain (parsed), for cheap far-dated screen
 const http = require('http');
 const fs = require('fs');
 const path = require('path');
+const SCREEN = require('./public/screen.js');
 
 const USER = process.env.VIEWER_USER || 'admin';
 const PASS = process.env.VIEWER_PASS || 'DW2024!';
 const ROOT = path.join(__dirname, 'public');
-const CACHE = new Map();           // ticker -> { ts, payload }
-const TTL = 5 * 60 * 1000;         // 5 min
+const HCACHE = new Map(); const HTTL = 5 * 60 * 1000;      // history cache
+const OCACHE = new Map(); const OTTL = 10 * 60 * 1000;     // options cache
+let UNIV = null, UNIV_TS = 0; const UTTL = 30 * 60 * 1000; // universe cache
+const JOBS = new Map();
+const UA = { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', 'Accept': 'application/json' };
 
-const MIME = {
-  '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
-  '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml',
-};
+const DJIA = ['AAPL','AMGN','AXP','BA','CAT','CRM','CSCO','CVX','DIS','DOW','GS','HD','HON','IBM','JNJ','JPM','KO','MCD','MMM','MRK','MSFT','NKE','NVDA','PG','SHW','TRV','UNH','V','VZ','WMT'];
 
-function unauthorized(res) {
-  res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="stock-vshape-viewer"' });
-  res.end('Authentication required');
-}
+const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8' };
+function unauthorized(res) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="stock-vshape"' }); res.end('Auth required'); }
+function jsonRes(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); }
 
-// ---- FREE price sources (no API key) ----
-async function fromStooq(ticker) {
-  const url = `https://stooq.com/q/d/l/?s=${ticker.toLowerCase()}.us&i=d`;
-  const r = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
+// ---------------- price history (Stooq -> Yahoo) ----------------
+async function fromStooq(t) {
+  const r = await fetch(`https://stooq.com/q/d/l/?s=${t.toLowerCase()}.us&i=d`, { headers: UA });
   if (!r.ok) throw new Error(`stooq ${r.status}`);
-  const csv = await r.text();
-  const lines = csv.trim().split('\n');
-  if (lines.length < 3 || !/^Date,Open/i.test(lines[0])) throw new Error('stooq bad csv');
-  const rows = lines.slice(1).map(l => {
-    const [date, o, h, lo, c, v] = l.split(',');
-    return { date, open: +o, high: +h, low: +lo, close: +c, vol: +v || 0 };
-  }).filter(d => isFinite(d.close) && d.close > 0);
-  return rows.slice(-540); // ~2 years, enough to seed a 200-day MA
+  const lines = (await r.text()).trim().split('\n');
+  if (lines.length < 3 || !/^Date,Open/i.test(lines[0])) throw new Error('stooq bad');
+  return lines.slice(1).map(l => { const [date, o, h, lo, c, v] = l.split(','); return { date, open: +o, high: +h, low: +lo, close: +c, vol: +v || 0 }; })
+    .filter(d => isFinite(d.close) && d.close > 0).slice(-540);
 }
-
-async function fromYahoo(ticker) {
-  const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(ticker)}?range=2y&interval=1d`;
-  const r = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
+async function fromYahoo(t) {
+  const r = await fetch(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(t)}?range=2y&interval=1d`, { headers: UA });
   if (!r.ok) throw new Error(`yahoo ${r.status}`);
-  const j = await r.json();
-  const res = j?.chart?.result?.[0];
+  const res = (await r.json())?.chart?.result?.[0];
   if (!res || !res.timestamp) throw new Error('yahoo empty');
-  const q = res.indicators.quote[0];
-  const out = [];
-  for (let i = 0; i < res.timestamp.length; i++) {
-    const c = q.close[i];
-    if (c == null) continue;
-    out.push({
-      date: new Date(res.timestamp[i] * 1000).toISOString().slice(0, 10),
-      open: q.open[i], high: q.high[i], low: q.low[i], close: c, vol: q.volume[i] || 0,
-    });
-  }
+  const q = res.indicators.quote[0], out = [];
+  for (let i = 0; i < res.timestamp.length; i++) { const c = q.close[i]; if (c == null) continue;
+    out.push({ date: new Date(res.timestamp[i] * 1000).toISOString().slice(0, 10), open: q.open[i], high: q.high[i], low: q.low[i], close: c, vol: q.volume[i] || 0 }); }
   return out.slice(-540);
 }
-
-async function getHistory(ticker) {
-  const hit = CACHE.get(ticker);
-  if (hit && Date.now() - hit.ts < TTL) return hit.payload;
+async function getHistory(t) {
+  const hit = HCACHE.get(t); if (hit && Date.now() - hit.ts < HTTL) return hit.payload;
   let days, source, err = [];
-  for (const [name, fn] of [['stooq', fromStooq], ['yahoo', fromYahoo]]) {
-    try { days = await fn(ticker); source = name; break; }
-    catch (e) { err.push(`${name}:${e.message}`); }
-  }
+  for (const [nm, fn] of [['stooq', fromStooq], ['yahoo', fromYahoo]]) { try { days = await fn(t); source = nm; break; } catch (e) { err.push(`${nm}:${e.message}`); } }
   if (!days || !days.length) throw new Error('all sources failed — ' + err.join(' | '));
-  const payload = { ticker, source, asOf: new Date().toISOString(), count: days.length, days };
-  CACHE.set(ticker, { ts: Date.now(), payload });
-  return payload;
+  const payload = { ticker: t, source, asOf: new Date().toISOString(), count: days.length, days };
+  HCACHE.set(t, { ts: Date.now(), payload }); return payload;
+}
+
+// ---------------- universe (NASDAQ screener + DJIA) ----------------
+async function getUniverse(minPrice) {
+  if (UNIV && Date.now() - UNIV_TS < UTTL) return UNIV.filter(u => u.price >= minPrice);
+  const r = await fetch('https://api.nasdaq.com/api/screener/stocks?tableonly=true&limit=6000&offset=0&exchange=NASDAQ', { headers: { ...UA, 'Accept-Language': 'en-US,en;q=0.9' } });
+  let rows = [];
+  if (r.ok) { const j = await r.json(); rows = j?.data?.table?.rows || j?.data?.rows || []; }
+  const seen = new Set(), list = [];
+  for (const row of rows) {
+    const sym = (row.symbol || '').trim().toUpperCase();
+    if (!sym || /[\^./]/.test(sym) || seen.has(sym)) continue;
+    const price = parseFloat(String(row.lastsale || '').replace(/[$,]/g, '')) || 0;
+    const cap = parseFloat(String(row.marketCap || '').replace(/[$,]/g, '')) || 0;
+    seen.add(sym); list.push({ ticker: sym, price, cap, name: row.name || sym });
+  }
+  for (const d of DJIA) if (!seen.has(d)) { seen.add(d); list.push({ ticker: d, price: 0, cap: Infinity, name: d + ' (DJIA)' }); }
+  list.sort((a, b) => b.cap - a.cap);
+  UNIV = list; UNIV_TS = Date.now();
+  return list.filter(u => u.price >= minPrice || u.cap === Infinity);
+}
+
+// ---------------- options (CBOE free delayed chain) ----------------
+async function getOptions(t) {
+  const hit = OCACHE.get(t); if (hit && Date.now() - hit.ts < OTTL) return hit.payload;
+  let data;
+  for (const u of [`https://cdn.cboe.com/api/global/delayed_quotes/options/${t}.json`, `https://cdn.cboe.com/api/global/delayed_quotes/options/_${t}.json`]) {
+    const r = await fetch(u, { headers: UA }); if (r.ok) { data = await r.json(); break; }
+  }
+  if (!data) throw new Error('cboe no chain');
+  const under = data?.data?.current_price || 0;
+  const chain = [];
+  for (const o of (data?.data?.options || [])) {
+    const p = SCREEN.parseOCC(o.option); if (!p) continue;
+    chain.push({ exp: p.exp, type: p.type, strike: p.strike, bid: o.bid, ask: o.ask, last: o.last_trade_price, oi: o.open_interest, vol: o.volume, delta: o.delta });
+  }
+  const payload = { ticker: t, underlying: under, asOf: new Date().toISOString(), chain };
+  OCACHE.set(t, { ts: Date.now(), payload }); return payload;
+}
+
+// ---------------- universe scan job ----------------
+function filtersFromQuery(p) {
+  const num = (k, d) => p.has(k) ? +p.get(k) : d, bool = (k) => p.get(k) === 'true';
+  return { weeksAgo: num('weeksAgo', 6), weeksTol: num('weeksTol', 3), minDip: num('minDip', 10),
+    maxFromHigh: num('maxFromHigh', 60), minPrice: num('minPrice', 8), reqNewHigh: p.has('reqNewHigh') ? bool('reqNewHigh') : true,
+    dipTo50: bool('dipTo50'), dipTo200: bool('dipTo200'), below50: bool('below50'), below200: bool('below200') };
+}
+async function runScan(job, filters, tfN, max) {
+  const universe = (await getUniverse(filters.minPrice)).slice(0, max);
+  job.total = universe.length;
+  let i = 0;
+  async function worker() {
+    while (i < universe.length && !job.abort) {
+      const u = universe[i++];
+      try {
+        const { days: full } = await getHistory(u.ticker);
+        if (full && full.length >= 60) {
+          const closes = full.map(d => d.close);
+          const ma50 = SCREEN.sma(closes, 50), ma200 = SCREEN.sma(closes, 200);
+          const N = Math.min(tfN, full.length);
+          const an = SCREEN.analyze(full.slice(-N), ma50.slice(-N), ma200.slice(-N), filters);
+          if (an.C >= filters.minPrice && SCREEN.passFilter(an, filters)) {
+            job.matches.push({ ticker: u.ticker, name: u.name, cap: u.cap === Infinity ? null : u.cap, price: +an.C.toFixed(2),
+              drawdown: +an.drawdown.toFixed(1), weeksAgoActual: an.weeksAgoActual, rsi: an.rsi, volSurge: an.volSurge,
+              d50: an.d50 == null ? null : +an.d50.toFixed(1), d200: an.d200 == null ? null : +an.d200.toFixed(1),
+              zoneCls: an.zoneCls, zoneLabel: an.zoneLabel, buyScore: an.buyScore });
+          }
+        }
+      } catch { /* skip a bad symbol */ }
+      job.scanned++;
+    }
+  }
+  await Promise.all(Array.from({ length: 16 }, worker));
+  job.matches.sort((a, b) => b.buyScore - a.buyScore);
+  job.done = true; job.finishedAt = Date.now();
 }
 
+// ---------------- HTTP ----------------
 const server = http.createServer(async (req, res) => {
-  // --- Basic auth ---
-  const hdr = req.headers.authorization || '';
-  const [scheme, encoded] = hdr.split(' ');
-  if (scheme !== 'Basic' || !encoded) return unauthorized(res);
-  const [u, p] = Buffer.from(encoded, 'base64').toString().split(':');
+  const hdr = req.headers.authorization || ''; const [scheme, enc] = hdr.split(' ');
+  if (scheme !== 'Basic' || !enc) return unauthorized(res);
+  const [u, p] = Buffer.from(enc, 'base64').toString().split(':');
   if (u !== USER || p !== PASS) return unauthorized(res);
 
   const [pathname, query] = (req.url || '/').split('?');
+  const P = new URLSearchParams(query || '');
+  const tick = () => (P.get('ticker') || '').toUpperCase().replace(/[^A-Z.\-]/g, '');
 
-  // --- API: live price history ---
-  if (pathname === '/api/history') {
-    const params = new URLSearchParams(query || '');
-    const ticker = (params.get('ticker') || '').toUpperCase().replace(/[^A-Z.\-]/g, '');
-    if (!ticker) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end('{"error":"ticker required"}'); }
-    try {
-      const payload = await getHistory(ticker);
-      res.writeHead(200, { 'Content-Type': 'application/json' });
-      res.end(JSON.stringify(payload));
-    } catch (e) {
-      res.writeHead(502, { 'Content-Type': 'application/json' });
-      res.end(JSON.stringify({ error: String(e.message || e), ticker }));
+  try {
+    if (pathname === '/api/history') {
+      const t = tick(); if (!t) return jsonRes(res, 400, { error: 'ticker required' });
+      return jsonRes(res, 200, await getHistory(t));
     }
-    return;
-  }
+    if (pathname === '/api/universe') {
+      const list = await getUniverse(+(P.get('minPrice') || 8));
+      return jsonRes(res, 200, { count: list.length, universe: list.slice(0, +(P.get('max') || 5000)) });
+    }
+    if (pathname === '/api/options') {
+      const t = tick(); if (!t) return jsonRes(res, 400, { error: 'ticker required' });
+      const o = await getOptions(t);
+      const screened = SCREEN.screenOptions(o.chain, Date.now(), {
+        min: +(P.get('min') || 0.05), max: +(P.get('max') || 0.15),
+        minDays: Math.round((+(P.get('minMonths') || 3)) * 30.4), type: P.get('type') || 'both', underlying: o.underlying });
+      return jsonRes(res, 200, { ticker: t, underlying: o.underlying, asOf: o.asOf, total: o.chain.length, count: screened.length, contracts: screened.slice(0, 60) });
+    }
+    if (pathname === '/api/scan/start') {
+      const filters = filtersFromQuery(P), tfN = +(P.get('tfN') || 126), max = Math.min(+(P.get('max') || 1500), 4200);
+      const id = 'job' + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36);
+      const job = { id, total: 0, scanned: 0, matches: [], done: false, startedAt: Date.now(), abort: false };
+      JOBS.set(id, job);
+      runScan(job, filters, tfN, max).catch(e => { job.error = String(e.message || e); job.done = true; });
+      // prune old jobs
+      for (const [k, v] of JOBS) if (Date.now() - v.startedAt > 20 * 60 * 1000) JOBS.delete(k);
+      return jsonRes(res, 200, { job: id, universe: job.total });
+    }
+    if (pathname === '/api/scan/status') {
+      const job = JOBS.get(P.get('job')); if (!job) return jsonRes(res, 404, { error: 'no such job' });
+      return jsonRes(res, 200, { total: job.total, scanned: job.scanned, done: job.done, error: job.error || null,
+        matchCount: job.matches.length, elapsedMs: (job.finishedAt || Date.now()) - job.startedAt,
+        matches: job.done ? job.matches : job.matches.slice(0, 60) });
+    }
+  } catch (e) { return jsonRes(res, 502, { error: String(e.message || e) }); }
 
-  // --- Static serving (path-traversal safe) ---
-  let rel = decodeURIComponent(pathname);
-  if (rel === '/') rel = '/index.html';
-  const filePath = path.normalize(path.join(ROOT, rel));
-  if (!filePath.startsWith(ROOT)) { res.writeHead(403); return res.end('Forbidden'); }
-  fs.readFile(filePath, (err, buf) => {
+  // static
+  let rel = decodeURIComponent(pathname); if (rel === '/') rel = '/index.html';
+  const fp = path.normalize(path.join(ROOT, rel));
+  if (!fp.startsWith(ROOT)) { res.writeHead(403); return res.end('Forbidden'); }
+  fs.readFile(fp, (err, buf) => {
     if (err) { res.writeHead(404); return res.end('Not found'); }
-    res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream' });
-    res.end(buf);
+    res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream' }); res.end(buf);
   });
 });
 
 server.listen(process.env.PORT ? Number(process.env.PORT) : 0, () => {
   const { port } = server.address();
-  console.log(`\n  📈  Stock V-Shape 3D viewer (LIVE)`);
-  console.log(`  →  http://localhost:${port}/`);
-  console.log(`  →  auth: ${USER} / ${PASS}`);
-  console.log(`  →  prices: Stooq (free) -> Yahoo fallback, 5-min cache\n`);
+  console.log(`\n  📈  Stock V-Shape screener (LIVE + scan + options)`);
+  console.log(`  →  http://localhost:${port}/   auth: ${USER} / ${PASS}`);
+  console.log(`  →  history: Stooq/Yahoo · universe: NASDAQ+DJIA · options: CBOE (all free)\n`);
 });

← abac055 Sliders recompute ALL stocks live on drag (rAF-coalesced) +  ·  back to Stock Vshape Viewer  ·  Volume profile (shares-at-price / POC + value area), OBV vol 2c3a2dc →