[object Object]

← back to Stock Vshape Viewer

Volume profile (shares-at-price / POC + value area), OBV volume-force + price-velocity data points, draggable chart range brush, scan auto-loads top winners onto board with adaptive lane spacing, options-availability badges on scan results

2c3a2dcab28833c8314b98d54c16e076f5fe9375 · 2026-08-27 11:14:16 -0700 · Steve

Files touched

Diff

commit 2c3a2dcab28833c8314b98d54c16e076f5fe9375
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 27 11:14:16 2026 -0700

    Volume profile (shares-at-price / POC + value area), OBV volume-force + price-velocity data points, draggable chart range brush, scan auto-loads top winners onto board with adaptive lane spacing, options-availability badges on scan results
---
 public/app.js     | 135 ++++++++++++++++++++++++++++++++++++++++++++++--------
 public/index.html |   6 +++
 public/screen.js  |  44 +++++++++++++++++-
 3 files changed, 165 insertions(+), 20 deletions(-)

diff --git a/public/app.js b/public/app.js
index 97e2d8e..2e14bbb 100644
--- a/public/app.js
+++ b/public/app.js
@@ -92,7 +92,7 @@ function process() {
     const an = window.analyze(days, ma50, ma200, state.filters);
     let min = Infinity, max = -Infinity;
     for (const d of days) { if (d.low < min) min = d.low; if (d.high > max) max = d.high; }
-    state.data[t] = { days, ma50, ma200, an, min, max };
+    state.data[t] = { days, ma50, ma200, an, min, max, _full: { days: full, ma50: ma50f, ma200: ma200f } };
   }
   rebuildScene();
   applyFilters();
@@ -193,8 +193,9 @@ function applyFilters() {
     gr.group.visible = ok;
     if (ok) matched.push(gr);
   }
-  // reposition visible sequentially so no gaps
-  matched.forEach((gr, k) => { gr.group.position.z = (k - (matched.length - 1) / 2) * LANE_GAP; });
+  // reposition visible sequentially; compress spacing so many layers still fit on screen
+  const gap = Math.min(LANE_GAP, 58 / Math.max(1, matched.length));
+  matched.forEach((gr, k) => { gr.group.position.z = (k - (matched.length - 1) / 2) * gap; });
   visible = matched.length;
   const ml = document.getElementById('matchline');
   ml.textContent = `${visible} of ${withData.length} match the screen`;
@@ -216,6 +217,23 @@ function drawPriceChart(D) {
   for (let k = 0; k <= 4; k++) { const p = min + (max - min) * k / 4, y = Y(p);
     ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke();
     ctx.fillText('$' + (p < 10 ? p.toFixed(2) : p.toFixed(0)), 6, y + 3); }
+  // volume-by-price profile (shares owned at each price) on the right ~28%
+  const prof = D.an.profile;
+  if (prof) {
+    const maxBarPx = (W - pad.l - pad.r) * 0.28, rightX = W - pad.r;
+    for (let b = 0; b < prof.bins.length; b++) {
+      const pLo = prof.lo + b * prof.bw, pHi = pLo + prof.bw;
+      const yTop = Y(pHi), yBot = Y(pLo);
+      const w = prof.maxVol ? (prof.bins[b] / prof.maxVol) * maxBarPx : 0;
+      const inVA = pHi > prof.vaLo && pLo < prof.vaHi;
+      ctx.fillStyle = inVA ? 'rgba(124,156,220,.34)' : 'rgba(124,156,220,.14)';
+      ctx.fillRect(rightX - w, yTop, w, Math.max(1, yBot - yTop - 1));
+    }
+    const yp = Y(prof.poc); // Point of Control (most shares held here)
+    ctx.strokeStyle = '#ffd54a'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
+    ctx.beginPath(); ctx.moveTo(pad.l, yp); ctx.lineTo(rightX, yp); ctx.stroke(); ctx.setLineDash([]);
+    ctx.fillStyle = '#ffd54a'; ctx.font = '9px sans-serif'; ctx.fillText('POC $' + prof.poc.toFixed(prof.poc < 10 ? 2 : 0), rightX - 66, yp - 3);
+  }
   const base = D.an.fits ? '57,217,138' : '255,92,122';
   // area
   const grad = ctx.createLinearGradient(0, pad.t, 0, H);
@@ -275,35 +293,85 @@ function renderDaily(D) {
   }).join('');
   document.getElementById('d-daily').innerHTML = head + body;
 }
-function showDetail(t) {
-  const D = state.data[t]; if (!D) return;
-  state.selected = t;
-  document.getElementById('detail').classList.add('show');
-  const s = metaOf(t), an = D.an;
-  document.getElementById('d-tk').textContent = `${t} · ${s.name}`;
-  document.getElementById('d-sub').textContent = `${s.cls} ${s.marketCap} · ${s.sector}`;
-  const zone = document.getElementById('d-zone');
-  zone.className = 'zone ' + an.zoneCls; zone.textContent = an.zoneLabel;
+const brush = { t: null, si: 0, ei: 0 };
+function renderDetailBody(t, D) {
+  const an = D.an;
+  const zone = document.getElementById('d-zone'); zone.className = 'zone ' + an.zoneCls; zone.textContent = an.zoneLabel;
   document.getElementById('d-why').innerHTML =
     `<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"><span>price velocity</span><b style="color:${an.priceVel>=0?'var(--pass)':'var(--fail)'}">${an.priceVel>=0?'+':''}${an.priceVel}%</b>/10d</div>
+    <div class="kpi"><span>OBV · vol force</span><b style="color:${an.obvTrend==='up'?'var(--pass)':an.obvTrend==='down'?'var(--fail)':'var(--dim)'}">${an.obvTrend}</b>${an.upDownVol}× up/dn</div>
+    <div class="kpi"><span>POC · most held</span><b style="color:var(--newhigh)">${an.poc==null?'—':'$'+an.poc}</b>${an.vaLo==null?'':'VA $'+an.vaLo+'–$'+an.vaHi}</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>
     <div class="kpi newhigh"><span>recent high</span><b>$${an.rh.price.toFixed(2)}</b>${an.rh.date.slice(5)}</div>
     <div class="kpi"><span>vs 50-DMA</span><b style="color:${an.d50>=0?'var(--pass)':'var(--fail)'}">${an.d50==null?'—':(an.d50>=0?'+':'')+an.d50.toFixed(1)+'%'}</b></div>
     <div class="kpi"><span>vs 200-DMA</span><b style="color:${an.d200>=0?'var(--pass)':'var(--fail)'}">${an.d200==null?'—':(an.d200>=0?'+':'')+an.d200.toFixed(1)+'%'}</b></div>`;
-  document.getElementById('d-vnote').textContent =
-    `Live daily prices. MAs computed over full history, sliced to the ${TF.find(x=>x.n===state.tfN)?.k||''} window. Anchors derived within this window.`;
+  document.getElementById('d-vnote').textContent = D.custom
+    ? `Custom range ${D.days[0].date} → ${D.days[D.days.length-1].date} (${D.days.length} sessions) — anchors, MAs, RSI & buy-score recomputed for this range.`
+    : `Live daily prices. MAs over full history, sliced to the ${TF.find(x=>x.n===state.tfN)?.k||''} window.`;
   drawPriceChart(D); drawVolChart(D); renderDaily(D);
+}
+function showDetail(t) {
+  const D = state.data[t]; if (!D) return;
+  state.selected = t;
+  document.getElementById('detail').classList.add('show');
+  const s = metaOf(t);
+  document.getElementById('d-tk').textContent = `${t} · ${s.name}`;
+  document.getElementById('d-sub').textContent = `${s.cls} ${s.marketCap} · ${s.sector}`;
+  renderTF();
+  // reset brush selection to the current timeframe window
+  brush.t = t;
+  const full = D._full ? D._full.days : D.days;
+  brush.si = Math.max(0, full.length - D.days.length); brush.ei = full.length - 1;
+  drawBrush();
+  renderDetailBody(t, D);
   highlightSelection();
 }
+function drawBrush() {
+  const D = state.data[brush.t]; if (!D || !D._full) return;
+  const full = D._full.days, n = full.length;
+  const cv = document.getElementById('d-brush'), dpr = Math.min(devicePixelRatio, 2);
+  const W = cv.clientWidth || 400, H = 38; cv.width = W * dpr; cv.height = H * dpr;
+  const ctx = cv.getContext('2d'); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, W, H);
+  let mn = Infinity, mx = -Infinity; for (const d of full) { if (d.close < mn) mn = d.close; if (d.close > mx) mx = d.close; }
+  const X = i => (i / (n - 1)) * W, Y = p => 3 + (1 - (p - mn) / (mx - mn)) * (H - 6);
+  const x0 = X(brush.si), x1 = X(brush.ei);
+  ctx.fillStyle = 'rgba(92,140,255,.18)'; ctx.fillRect(x0, 0, Math.max(2, x1 - x0), H);
+  ctx.strokeStyle = '#5c8fd6'; ctx.lineWidth = 1; ctx.strokeRect(x0 + 0.5, 0.5, Math.max(1, x1 - x0 - 1), H - 1);
+  ctx.beginPath(); ctx.lineWidth = 1; ctx.strokeStyle = '#8ea3c8';
+  full.forEach((d, i) => i ? ctx.lineTo(X(i), Y(d.close)) : ctx.moveTo(X(i), Y(d.close))); ctx.stroke();
+}
+function commitBrush() {
+  const D = state.data[brush.t]; if (!D || !D._full) return;
+  const n = D._full.days.length;
+  let si = brush.si, ei = brush.ei;
+  if (ei - si < 30) { ei = Math.min(n - 1, si + 30); si = Math.max(0, ei - 30); }
+  brush.si = si; brush.ei = ei;
+  const days = D._full.days.slice(si, ei + 1), ma50 = D._full.ma50.slice(si, ei + 1), ma200 = D._full.ma200.slice(si, ei + 1);
+  const an = window.analyze(days, ma50, ma200, state.filters);
+  let mn = Infinity, mx = -Infinity; for (const d of days) { if (d.low < mn) mn = d.low; if (d.high > mx) mx = d.high; }
+  document.querySelectorAll('#d-tf button').forEach(b => b.classList.remove('on'));
+  drawBrush();
+  renderDetailBody(brush.t, { days, ma50, ma200, an, min: mn, max: mx, custom: true });
+}
+function wireBrush() {
+  const cv = document.getElementById('d-brush'); let dragging = false, anchor = 0;
+  const idxAt = (e) => {
+    const r = cv.getBoundingClientRect(); const frac = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
+    const D = state.data[brush.t]; const n = (D && D._full ? D._full.days.length : 1); return Math.round(frac * (n - 1));
+  };
+  cv.addEventListener('pointerdown', e => { if (!brush.t) return; dragging = true; anchor = idxAt(e); brush.si = anchor; brush.ei = anchor; cv.setPointerCapture(e.pointerId); drawBrush(); });
+  cv.addEventListener('pointermove', e => { if (!dragging) return; const j = idxAt(e); brush.si = Math.min(anchor, j); brush.ei = Math.max(anchor, j); drawBrush(); });
+  cv.addEventListener('pointerup', () => { if (!dragging) return; dragging = false; commitBrush(); });
+}
 function highlightSelection() {
   for (const gr of groups) {
     const on = gr.ticker === state.selected;
@@ -459,7 +527,7 @@ document.getElementById('refresh').onclick = () => loadAll();
 addEventListener('keydown', e => { if (e.key === 'Escape') { clearSelect(); document.getElementById('left').classList.remove('open'); } });
 addEventListener('resize', () => {
   camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight);
-  if (state.selected) { const D = state.data[state.selected]; if (D) { drawPriceChart(D); drawVolChart(D); } }
+  if (state.selected) { const D = state.data[state.selected]; if (D) { drawPriceChart(D); drawVolChart(D); drawBrush(); } }
 });
 
 // ============================ universe scan ============================
@@ -492,7 +560,10 @@ function pollScan(job) {
       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>`;
+        const N = 12;
+        document.getElementById('scan-prog').innerHTML += `<div style="margin-top:4px;color:#6f83a8">done in ${(j.elapsedMs/1000).toFixed(0)}s · loaded top ${Math.min(N, j.matchCount)} into the board · ◈ = cheap far-dated options · click any to add more</div>`;
+        autoLoadMatches(j.matches || [], N);
+        enrichResultsOptions();
         return;
       }
       scanTimer = setTimeout(step, 1500);
@@ -507,13 +578,39 @@ function renderResults(matches) {
     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>`;
+      <span class="mm">$${m.price} · ${m.drawdown}% · RSI ${m.rsi ?? '—'} · ${m.zoneLabel.split('—')[0].trim()}</span>
+      <span class="optbadge" data-ot="${m.ticker}" title="cheap far-dated options"></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);
   });
 }
+// after a scan, tag each displayed winner with how many cheap far-dated options it has
+async function enrichResultsOptions() {
+  const badges = [...document.querySelectorAll('#scan-results .optbadge')];
+  const f = state.filters; let i = 0;
+  async function worker() {
+    while (i < badges.length) {
+      const b = badges[i++], t = b.dataset.ot; b.textContent = '·'; b.style.color = '#5c6f92';
+      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.count > 0) { b.textContent = '◈' + j.count; b.style.background = 'rgba(46,230,200,.14)'; b.style.color = '#2ee6c8'; b.title = `${j.count} cheap far-dated options`; }
+        else { b.textContent = '–'; b.style.color = '#5c6f92'; b.title = 'no cheap far-dated options'; }
+      } catch { b.textContent = '?'; }
+    }
+  }
+  await Promise.all(Array.from({ length: 6 }, worker));
+}
+// push a scan's top-ranked winners straight onto the 3D board
+async function autoLoadMatches(matches, N) {
+  const top = matches.slice(0, N).map(m => m.ticker);
+  const fresh = top.filter(t => !state.tickers.includes(t));
+  if (!fresh.length) return;
+  state.tickers = [...state.tickers, ...fresh]; save(LS.tickers, state.tickers);
+  await loadAll();
+}
 
 // ============================ cheap far-dated options ============================
 async function loadOptions(t) {
@@ -566,7 +663,7 @@ function syncOptUI() {
 }
 
 // ============================ boot + loop ============================
-syncFilterUI(); syncOptUI(); wireFilters(); wireScanAndOptions(); renderSaved(); updateCriteriaChips();
+syncFilterUI(); syncOptUI(); wireFilters(); wireScanAndOptions(); wireBrush(); renderSaved(); updateCriteriaChips();
 loadAll();
 
 let t0 = performance.now();
diff --git a/public/index.html b/public/index.html
index 07f0422..a783e13 100644
--- a/public/index.html
+++ b/public/index.html
@@ -123,6 +123,10 @@
     color:var(--dim);border-radius:7px;cursor:pointer}
   .tfrow button.on{background:#1c2c50;border-color:#4a6bb5;color:var(--ink)}
   #chart{width:100%;height:196px;display:block}
+  .brushwrap{margin:4px 0 2px}
+  #d-brush{width:100%;height:38px;display:block;cursor:ew-resize;border:1px solid var(--stroke);border-radius:6px;background:#0a1120}
+  .brushhint{font-size:9.5px;color:#5c6f92;margin-top:2px;text-align:center}
+  .results .r .optbadge{font-size:10px;font-weight:700;padding:1px 5px;border-radius:5px;min-width:18px;text-align:center}
   #vchart{width:100%;height:56px;display:block;margin-top:3px}
   .malegend{display:flex;gap:12px;font-size:10.5px;color:var(--dim);margin:6px 0}
   .malegend i{display:inline-block;width:14px;height:2px;vertical-align:middle;margin-right:4px}
@@ -240,6 +244,8 @@
     <span><i style="background:var(--newhigh)"></i>recent high</span>
   </div>
   <canvas id="vchart"></canvas>
+  <div class="brushwrap"><canvas id="d-brush"></canvas>
+    <div class="brushhint" id="d-brushhint">↔ drag across the strip to select a custom range · click a timeframe to reset</div></div>
   <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>
diff --git a/public/screen.js b/public/screen.js
index 55b1e01..5b29f2b 100644
--- a/public/screen.js
+++ b/public/screen.js
@@ -33,6 +33,29 @@
     return out;
   }
 
+  // Volume-by-price profile: how many shares traded at each price level.
+  // Returns per-bin volume, the Point of Control (POC), and the 70% Value Area.
+  function volumeProfile(days, bins = 24) {
+    let lo = Infinity, hi = -Infinity;
+    for (const d of days) { if (d.low < lo) lo = d.low; if (d.high > hi) hi = d.high; }
+    if (!(hi > lo)) return null;
+    const bw = (hi - lo) / bins, arr = new Array(bins).fill(0);
+    for (const d of days) {
+      const tp = (d.high + d.low + d.close) / 3; // typical price
+      let b = Math.floor((tp - lo) / bw); if (b < 0) b = 0; if (b >= bins) b = bins - 1;
+      arr[b] += (d.vol || 0);
+    }
+    let pocI = 0; for (let i = 1; i < bins; i++) if (arr[i] > arr[pocI]) pocI = i;
+    const total = arr.reduce((a, b) => a + b, 0) || 1;
+    let loI = pocI, hiI = pocI, acc = arr[pocI];
+    while (acc < total * 0.7 && (loI > 0 || hiI < bins - 1)) {
+      const dn = loI > 0 ? arr[loI - 1] : -1, up = hiI < bins - 1 ? arr[hiI + 1] : -1;
+      if (up >= dn) { hiI++; acc += arr[hiI]; } else { loI--; acc += arr[loI]; }
+    }
+    return { lo, hi, bw, bins: arr, maxVol: arr[pocI], total,
+      poc: lo + (pocI + 0.5) * bw, vaLo: lo + loI * bw, vaHi: lo + (hiI + 1) * bw };
+  }
+
   const DEFAULTS = {
     weeksAgo: 6, weeksTol: 3, minDip: 10, maxFromHigh: 60, minPrice: 8,
     reqNewHigh: true, dipTo50: false, dipTo200: false, below50: false, below200: false,
@@ -74,6 +97,21 @@
     const volAvg = vc ? vsum / vc : 0;
     const volSurge = volAvg ? vols[n - 1] / volAvg : 1;
 
+    // volume-as-velocity: OBV trend, up/down volume force, price velocity
+    let obv = 0, upVol = 0, dnVol = 0; const obvArr = [0];
+    for (let i = 1; i < n; i++) {
+      const ch = days[i].close - days[i - 1].close, v = vols[i];
+      if (ch > 0) { obv += v; upVol += v; } else if (ch < 0) { obv -= v; dnVol += v; }
+      obvArr.push(obv);
+    }
+    const third = Math.max(1, Math.floor(n / 3));
+    const obvNow = obvArr[n - 1], obvPast = obvArr[n - 1 - third] != null ? obvArr[n - 1 - third] : obvArr[0];
+    const obvTrend = obvNow > obvPast ? 'up' : obvNow < obvPast ? 'down' : 'flat';
+    const upDownVol = dnVol > 0 ? upVol / dnVol : (upVol > 0 ? 99 : 1);
+    const look = Math.min(10, n - 1);
+    const priceVel = look > 0 ? ((days[n - 1].close / days[n - 1 - look].close) - 1) * 100 : 0;
+    const profile = volumeProfile(days, 24);
+
     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;
@@ -107,7 +145,11 @@
       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,
+      rsiArr, profile,
+      poc: profile ? +profile.poc.toFixed(2) : null,
+      vaLo: profile ? +profile.vaLo.toFixed(2) : null,
+      vaHi: profile ? +profile.vaHi.toFixed(2) : null,
+      obvTrend, upDownVol: +upDownVol.toFixed(2), priceVel: +priceVel.toFixed(1),
     };
   }
 

← 23089cb Market scanner: NASDAQ+DJIA universe scan (buy-score ranked,  ·  back to Stock Vshape Viewer  ·  10 preloaded strategy presets (one-click screens: Deep V-Rec d409d10 →