← 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
M public/app.jsM public/index.htmlM public/screen.js
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 →