← back to Stock Vshape Viewer
public/app.js
840 lines
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// ============================ state ============================
const LS = {
tickers: 'vshape.tickers', filters: 'vshape.filters', saved: 'vshape.saved', tf: 'vshape.tf', volLayer: 'vshape.vollayer', volNorm: 'vshape.volnorm',
};
const TF = [
{ k: '1M', n: 21 }, { k: '3M', n: 63 }, { k: '6M', n: 126 },
{ k: '1Y', n: 252 }, { k: '2Y', n: 504 },
];
const COL = { high: 0x39d98a, trough: 0xff5c7a, newhigh: 0xffd54a, ma50: 0x5cc8ff, ma200: 0xc792ff };
const PASS = 0x39d98a, FAIL = 0xff5c7a;
const state = {
tickers: load(LS.tickers, window.SEED.map(s => s.ticker)),
filters: Object.assign({}, window.DEFAULT_FILTERS, load(LS.filters, {})),
saved: load(LS.saved, []),
tfN: load(LS.tf, 126),
volLayer: load(LS.volLayer, true),
volNorm: load(LS.volNorm, 'per-stock'),
meta: Object.fromEntries(window.SEED.map(s => [s.ticker, s])),
caps: {}, // ticker -> market cap ($) when known (from scan matches)
raw: {}, // ticker -> { days:[...full], source, asOf } | { error }
data: {}, // ticker -> processed { days, ma50, ma200, an, min, max }
selected: null,
};
function load(k, d) { try { const v = JSON.parse(localStorage.getItem(k)); return v ?? d; } catch { return d; } }
function save(k, v) { localStorage.setItem(k, JSON.stringify(v)); }
function metaOf(t) { return state.meta[t] || { ticker: t, name: t, cls: '—', marketCap: '—', sector: '—' }; }
// resolve a ticker's market cap ($) from scan data, else parse the seed metadata string
function capOf(t) {
if (state.caps && state.caps[t] != null) return state.caps[t];
const m = String(metaOf(t).marketCap || '').replace(/[~$,\s]/g, '').match(/([\d.]+)([TBM])/i);
return m ? parseFloat(m[1]) * ({ T: 1e12, B: 1e9, M: 1e6 })[m[2].toUpperCase()] : null;
}
// ============================ three.js scene ============================
const app = document.getElementById('app');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x070b14);
scene.fog = new THREE.Fog(0x070b14, 30, 80);
const camera = new THREE.PerspectiveCamera(52, innerWidth / innerHeight, 0.1, 300);
const camHome = new THREE.Vector3(22, 16, 28);
camera.position.copy(camHome);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
app.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.dampingFactor = 0.08;
controls.autoRotate = true; controls.autoRotateSpeed = 0.55;
controls.target.set(0, 3, 0);
scene.add(new THREE.AmbientLight(0x88aaff, 0.75));
const key = new THREE.DirectionalLight(0xffffff, 1.1); key.position.set(12, 20, 10); scene.add(key);
const rim = new THREE.DirectionalLight(0x5c7cff, 0.55); rim.position.set(-14, 8, -10); scene.add(rim);
const grid = new THREE.GridHelper(70, 70, 0x1e2a44, 0x121a2c); scene.add(grid);
const LANE_GAP = 4.2, LANE_LEN = 22, MAX_H = 7;
const laneMeshes = []; // pickable ribbons
let groups = []; // {ticker, group, mat, line}
function makeLabel(text, color = '#e8eefc', size = 44, bold = true) {
const c = document.createElement('canvas'), ctx = c.getContext('2d');
const font = `${bold ? '700' : '400'} ${size}px -apple-system,Segoe UI,Roboto,sans-serif`;
ctx.font = font; const w = Math.ceil(ctx.measureText(text).width) + 20;
c.width = w; c.height = size + 20; ctx.font = font; ctx.fillStyle = color; ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0,0,0,.6)'; ctx.shadowBlur = 6; ctx.fillText(text, 10, c.height / 2);
const tex = new THREE.CanvasTexture(c); tex.anisotropy = 4;
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
spr.scale.set(w / 100 * 1.6, c.height / 100 * 1.6, 1); return spr;
}
// ============================ data pipeline ============================
async function fetchOne(ticker) {
try {
const r = await fetch(`/api/history?ticker=${encodeURIComponent(ticker)}`);
const j = await r.json();
if (!r.ok || j.error) return { error: j.error || `HTTP ${r.status}` };
return j;
} catch (e) { return { error: String(e.message || e) }; }
}
async function loadAll() {
setLive('loading live prices…');
await Promise.all(state.tickers.map(async t => { state.raw[t] = await fetchOne(t); }));
const src = Object.values(state.raw).find(r => r.source)?.source || 'n/a';
const asOf = Object.values(state.raw).find(r => r.asOf)?.asOf;
setLive(`live · ${src} · ${asOf ? new Date(asOf).toLocaleString() : ''}`);
process();
}
function process() {
state.data = {};
for (const t of state.tickers) {
const raw = state.raw[t];
if (!raw || raw.error || !raw.days || raw.days.length < 30) continue;
const full = raw.days;
const closes = full.map(d => d.close);
const ma50f = window.sma(closes, 50), ma200f = window.sma(closes, 200);
const N = Math.min(state.tfN, full.length);
const days = full.slice(-N), ma50 = ma50f.slice(-N), ma200 = ma200f.slice(-N);
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, _full: { days: full, ma50: ma50f, ma200: ma200f } };
}
rebuildScene();
applyFilters();
renderWatchlist();
updateCriteriaChips();
if (state.selected && state.data[state.selected]) showDetail(state.selected);
}
// ============================ 3D build ============================
function priceToY(d, price) { const lo = d.min * 0.985, hi = d.max; return ((price - lo) / (hi - lo)) * MAX_H + 0.4; }
function idxToX(n, i) { return (i / (n - 1)) * LANE_LEN - LANE_LEN / 2; }
function rebuildScene() {
for (const g of groups) {
g.group.traverse(o => { // dispose to avoid GPU leak on repeated rebuilds
if (o.geometry) o.geometry.dispose();
if (o.material) { const m = o.material; (Array.isArray(m) ? m : [m]).forEach(x => { x.map && x.map.dispose(); x.dispose(); }); }
});
scene.remove(g.group);
}
groups = []; laneMeshes.length = 0;
// global peak volume across every loaded stock (for cross-stock normalization)
state.globalVmax = 0;
for (const tk of state.tickers) { const DD = state.data[tk]; if (DD) for (const d of DD.days) { const v = d.vol || 0; if (v > state.globalVmax) state.globalVmax = v; } }
for (const t of state.tickers) {
const D = state.data[t]; if (!D) continue;
const s = metaOf(t), an = D.an, n = D.days.length;
const g = new THREE.Group();
const baseColor = new THREE.Color(an.fits ? PASS : FAIL);
// VOLUME-EXTRUDED 3D ribbon — z-thickness AND per-column color both scale with volume.
// Normalization: per-stock (own peak) or global (comparable across the whole board).
const vmaxLocal = Math.max(...D.days.map(d => d.vol || 0)) || 1;
const vmaxRef = state.volNorm === 'global' ? (state.globalVmax || vmaxLocal) : vmaxLocal;
const volRatio = i => Math.min(1, (D.days[i].vol || 0) / vmaxRef);
const depthAt = i => 0.1 + volRatio(i) * 1.35; // half-depth (bloat), capped to lane spacing
const cLo = baseColor.clone().multiplyScalar(0.4); // dim = low volume
const cHi = baseColor.clone().lerp(new THREE.Color(0xffffff), 0.65); // bright = high volume
const pos = [], idx = [], cols = [];
// 4 verts per column: 0 baseFront(+d), 1 topFront(+d), 2 baseBack(-d), 3 topBack(-d)
for (let i = 0; i < n; i++) {
const x = idxToX(n, i), y = priceToY(D, D.days[i].close), d = depthAt(i);
pos.push(x, 0.02, d); pos.push(x, y, d);
pos.push(x, 0.02, -d); pos.push(x, y, -d);
const c = cLo.clone().lerp(cHi, volRatio(i));
for (let k = 0; k < 4; k++) cols.push(c.r, c.g, c.b);
}
const Vp = 4;
for (let i = 0; i < n - 1; i++) {
const A = Vp * i, B = Vp * (i + 1);
idx.push(A + 0, A + 1, B + 1, A + 0, B + 1, B + 0); // front (+d)
idx.push(A + 2, B + 3, A + 3, A + 2, B + 2, B + 3); // back (-d)
idx.push(A + 1, A + 3, B + 3, A + 1, B + 3, B + 1); // top ridge (the price surface)
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));
geo.setIndex(idx); geo.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({ vertexColors: true, transparent: true, opacity: 0.72,
side: THREE.DoubleSide, metalness: 0.25, roughness: 0.5, emissive: baseColor, emissiveIntensity: 0.1 });
const ribbon = new THREE.Mesh(geo, mat); ribbon.userData.ticker = t; g.add(ribbon); laneMeshes.push(ribbon);
// subtle GLOW on the single highest-volume day
let pk = 0; for (let i = 1; i < n; i++) if ((D.days[i].vol || 0) > (D.days[pk].vol || 0)) pk = i;
const gx = idxToX(n, pk), gy = priceToY(D, D.days[pk].close);
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.26, 16, 16),
new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.92 }));
glow.position.set(gx, gy + 0.15, 0); g.add(glow);
const halo = new THREE.Mesh(new THREE.RingGeometry(0.34, 0.62, 22),
new THREE.MeshBasicMaterial({ color: 0x9fd8ff, transparent: true, opacity: 0.5, side: THREE.DoubleSide }));
halo.position.copy(glow.position); halo.userData.ring = true; g.add(halo);
// price line
const linePts = []; for (let i = 0; i < n; i++) linePts.push(new THREE.Vector3(idxToX(n, i), priceToY(D, D.days[i].close), 0.01));
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(linePts),
new THREE.LineBasicMaterial({ color: baseColor.clone().offsetHSL(0, 0, 0.15) })); g.add(line);
// 50 / 200 DMA lines
for (const [arr, col] of [[D.ma50, COL.ma50], [D.ma200, COL.ma200]]) {
const p = [];
for (let i = 0; i < n; i++) if (arr[i] != null) p.push(new THREE.Vector3(idxToX(n, i), priceToY(D, arr[i]), 0.02));
if (p.length > 2) g.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(p),
new THREE.LineBasicMaterial({ color: col, transparent: true, opacity: 0.85 })));
}
// anchor spheres + labels
for (const [pt, type] of [[an.eh, 'high'], [an.tr, 'trough'], [an.rh, 'newhigh']]) {
const c = COL[type], y = priceToY(D, pt.price), x = idxToX(n, pt.i);
const sph = new THREE.Mesh(new THREE.SphereGeometry(0.32, 20, 20),
new THREE.MeshStandardMaterial({ color: c, emissive: c, emissiveIntensity: 0.9 }));
sph.position.set(x, y, 0); g.add(sph);
const ring = new THREE.Mesh(new THREE.RingGeometry(0.4, 0.54, 24),
new THREE.MeshBasicMaterial({ color: c, transparent: true, opacity: 0.5, side: THREE.DoubleSide }));
ring.position.copy(sph.position); ring.userData.ring = true; g.add(ring);
const dec = pt.price < 10 ? 2 : pt.price < 100 ? 1 : 0;
const lab = makeLabel('$' + pt.price.toFixed(dec), '#' + new THREE.Color(c).getHexString(), 32);
lab.position.set(x, y + 0.85, 0); g.add(lab);
}
// ticker + verdict labels
const tk = makeLabel(t, an.fits ? '#39d98a' : '#ff5c7a', 54); tk.position.set(-LANE_LEN / 2 - 2.6, 1.4, 0); g.add(tk);
const vb = makeLabel(`${an.verdict} ${an.drawdown.toFixed(1)}%`, '#8ea3c8', 28, false);
vb.position.set(-LANE_LEN / 2 - 2.6, 0.35, 0); g.add(vb);
g.userData = { ticker: t, mat, line };
groups.push({ ticker: t, group: g, mat, line });
scene.add(g);
}
}
// ============================ filters ============================
function passFilter(t) {
const D = state.data[t]; return D ? window.passFilterOf(D.an, state.filters) : false;
}
function applyFilters() {
let visible = 0, matched = [];
const withData = state.tickers.filter(t => state.data[t]);
for (const gr of groups) {
const ok = passFilter(gr.ticker);
gr.group.visible = ok;
if (ok) matched.push(gr);
}
// 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`;
renderWatchlist();
}
// ============================ 2D detail chart ============================
function drawPriceChart(D) {
const cv = document.getElementById('chart'), dpr = Math.min(devicePixelRatio, 2);
const W = cv.clientWidth, H = 196; cv.width = W * dpr; cv.height = H * dpr;
const ctx = cv.getContext('2d'); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, W, H);
const ser = D.days, pad = { l: 46, r: 10, t: 10, b: 16 };
let min = D.min, max = D.max;
for (const a of [D.ma50, D.ma200]) for (const v of a) if (v != null) { if (v < min) min = v; if (v > max) max = v; }
min *= 0.99; max *= 1.01;
const X = i => pad.l + (i / (ser.length - 1)) * (W - pad.l - pad.r);
const Y = p => pad.t + (1 - (p - min) / (max - min)) * (H - pad.t - pad.b);
ctx.strokeStyle = '#182338'; ctx.fillStyle = '#6f83a8'; ctx.font = '10px sans-serif';
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); }
const base = D.an.fits ? '57,217,138' : '255,92,122';
// area
const grad = ctx.createLinearGradient(0, pad.t, 0, H);
grad.addColorStop(0, `rgba(${base},.30)`); grad.addColorStop(1, `rgba(${base},0)`);
ctx.beginPath(); ctx.moveTo(X(0), Y(ser[0].close));
ser.forEach((p, i) => ctx.lineTo(X(i), Y(p.close)));
ctx.lineTo(X(ser.length - 1), H - pad.b); ctx.lineTo(X(0), H - pad.b); ctx.closePath(); ctx.fillStyle = grad; ctx.fill();
// MA lines
for (const [arr, col] of [[D.ma50, '#5cc8ff'], [D.ma200, '#c792ff']]) {
ctx.beginPath(); ctx.lineWidth = 1.3; ctx.strokeStyle = col; let started = false;
arr.forEach((v, i) => { if (v == null) return; if (!started) { ctx.moveTo(X(i), Y(v)); started = true; } else ctx.lineTo(X(i), Y(v)); });
ctx.stroke();
}
// price
ctx.beginPath(); ctx.lineWidth = 2; ctx.strokeStyle = `rgb(${base})`;
ser.forEach((p, i) => i ? ctx.lineTo(X(i), Y(p.close)) : ctx.moveTo(X(i), Y(p.close))); ctx.stroke();
// anchors
for (const [pt, type] of [[D.an.eh, 'high'], [D.an.tr, 'trough'], [D.an.rh, 'newhigh']]) {
const c = '#' + new THREE.Color(COL[type]).getHexString();
ctx.beginPath(); ctx.arc(X(pt.i), Y(pt.price), 5, 0, 7); ctx.fillStyle = c; ctx.fill();
ctx.strokeStyle = '#070b14'; ctx.lineWidth = 2; ctx.stroke();
}
// VOLUME-BY-PRICE LAYER — horizontal bars from the LEFT: shares traded at each price
const prof = D.an.profile;
if (prof && state.volLayer) {
const maxBarPx = (W - pad.l - pad.r) * 0.44; // layer spans up to ~44% out from the left axis
for (let b = 0; b < prof.bins.length; b++) {
if (!prof.bins[b]) continue;
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 isPoc = prof.poc >= pLo && prof.poc < pHi;
const inVA = pHi > prof.vaLo && pLo < prof.vaHi;
ctx.fillStyle = isPoc ? 'rgba(255,213,74,.55)' : inVA ? 'rgba(92,140,255,.42)' : 'rgba(92,140,255,.22)';
ctx.fillRect(pad.l, yTop, w, Math.max(1, yBot - yTop - 1));
}
const yp = Y(prof.poc); // Point of Control
ctx.strokeStyle = '#ffd54a'; ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(pad.l, yp); ctx.lineTo(W - pad.r, yp); ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle = '#ffd54a'; ctx.font = '9px sans-serif';
ctx.fillText('POC $' + prof.poc.toFixed(prof.poc < 10 ? 2 : 0), pad.l + 3, yp - 3);
}
}
function drawVolChart(D) {
const cv = document.getElementById('vchart'), dpr = Math.min(devicePixelRatio, 2);
const W = cv.clientWidth, H = 56; cv.width = W * dpr; cv.height = H * dpr;
const ctx = cv.getContext('2d'); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, W, H);
const ser = D.days, pad = { l: 46, r: 10 }, vmax = Math.max(...ser.map(p => p.vol || 0)) || 1;
const bw = (W - pad.l - pad.r) / ser.length; const base = D.an.fits ? '57,217,138' : '255,92,122';
ctx.fillStyle = '#6f83a8'; ctx.font = '9px sans-serif'; ctx.fillText('vol', 6, 12);
const anchIdx = { [D.an.eh.i]: COL.high, [D.an.tr.i]: COL.trough, [D.an.rh.i]: COL.newhigh };
ser.forEach((p, i) => {
const h = ((p.vol || 0) / vmax) * (H - 8);
ctx.fillStyle = anchIdx[i] != null ? '#' + new THREE.Color(anchIdx[i]).getHexString() : `rgba(${base},.4)`;
ctx.fillRect(pad.l + i * bw, H - 2 - h, Math.max(1, bw - 1), h);
});
}
function renderTF() {
const el = document.getElementById('d-tf');
el.innerHTML = TF.map(t => `<button data-n="${t.n}" class="${t.n === state.tfN ? 'on' : ''}">${t.k}</button>`).join('');
el.querySelectorAll('button').forEach(b => b.onclick = () => { state.tfN = +b.dataset.n; save(LS.tf, state.tfN); process(); });
}
function fmtVol(v) { return v >= 1e9 ? (v/1e9).toFixed(2)+'B' : v >= 1e6 ? (v/1e6).toFixed(1)+'M' : v >= 1e3 ? (v/1e3).toFixed(0)+'k' : String(v||0); }
function renderDaily(D) {
const rows = D.days.slice(-40).reverse();
const head = `<tr><th>Date</th><th>Close</th><th>Chg</th><th>Vol</th><th>vs50</th><th>vs200</th></tr>`;
const body = rows.map((d, k) => {
const gi = D.days.indexOf(d);
const prev = D.days[gi - 1]; const chg = prev ? (d.close / prev.close - 1) * 100 : 0;
const m50 = D.ma50[gi], m200 = D.ma200[gi];
const v50 = m50 ? ((d.close / m50 - 1) * 100) : null, v200 = m200 ? ((d.close / m200 - 1) * 100) : null;
const cc = chg >= 0 ? 'up' : 'down';
const col = x => x == null ? '—' : `<span class="${x >= 0 ? 'up' : 'down'}">${x >= 0 ? '+' : ''}${x.toFixed(1)}%</span>`;
return `<tr><td>${d.date.slice(5)}</td><td>$${d.close.toFixed(2)}</td>
<td class="${cc}">${chg >= 0 ? '+' : ''}${chg.toFixed(1)}%</td><td>${fmtVol(d.vol)}</td>
<td>${col(v50)}</td><td>${col(v200)}</td></tr>`;
}).join('');
document.getElementById('d-daily').innerHTML = head + body;
}
const brush = { t: null, si: 0, ei: 0 };
function renderDetailBody(t, D) {
state.detailD = 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}`;
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>setup score</span><b style="color:${an.buyScore>=60?'var(--pass)':an.buyScore>=40?'var(--watch)':'var(--dim)'}" title="backtested ~6-month setup signal, not a short-term timer — see Score backtest below">${an.buyScore}</b>/100 · ~6mo</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>`;
// three validated cap tiers: large (≥$40B, score works) · small (<$20B, score INVERTS) · mid ($20–40B, untested)
const cap = capOf(t), tier = cap == null ? null : cap < 20e9 ? 'small' : cap < 40e9 ? 'mid' : 'large';
const capFlag = tier === 'small'
? ` <span class="sp neg" title="Backtest: the buy-score INVERTS on small/mid-caps (120d correlation negative) — a high score here predicted WORSE returns. Validated only on large-caps.">⚠ small/mid-cap · score INVERTS</span>`
: tier === 'mid'
? ` <span class="sp" style="background:rgba(255,213,74,.14);color:var(--watch)" title="$20–40B is between the validated large-cap tier (score works) and the small-cap tier (inverts) — treat the score as untested here.">ⓘ cap untested — validated on large-caps only</span>`
: '';
// only claim the sweet spot on the VALIDATED large-cap tier
const sweet = tier === 'large' && an.buyScore >= 40 && an.buyScore <= 79
? ` <span class="sp pos" title="Backtest (large-cap): the 40–79 band beat baseline at every horizon; 80+ underperformed. Higher isn't always better.">★ backtest sweet spot</span>`
: (tier === 'large' && an.buyScore >= 80 ? ` <span class="sp neg" title="Backtest: the 80+ band was rare and underperformed the 40–79 sweet spot.">⚠ above sweet spot</span>` : '');
const smallWarn = capFlag;
document.getElementById('d-scoreparts').innerHTML = ((an.scoreParts && an.scoreParts.length)
? `<span>setup score ${an.buyScore} =</span>` + an.scoreParts.map(p => `<span class="sp ${p.v >= 0 ? 'pos' : 'neg'}">${p.k} ${p.v >= 0 ? '+' : ''}${p.v}</span>`).join('')
: '<span>setup score 0 — no qualifying components</span>') + sweet + smallWarn;
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);
// resolve cap for a manually-added ticker so the small-cap warning can fire
if (capOf(t) == null) {
fetch(`/api/cap?ticker=${encodeURIComponent(t)}`).then(r => r.json()).then(j => {
if (j && j.cap != null) { state.caps[t] = j.cap; if (state.selected === t && state.detailD) renderDetailBody(t, state.detailD); }
}).catch(() => {});
}
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;
gr.mat.opacity = state.selected ? (on ? 0.85 : 0.14) : 0.5;
gr.line.material.opacity = state.selected ? (on ? 1 : 0.22) : 1;
gr.line.material.transparent = true;
}
}
function clearSelect() {
state.selected = null; highlightSelection();
document.getElementById('detail').classList.remove('show');
}
// ============================ watchlist ============================
function renderWatchlist() {
const el = document.getElementById('wl');
el.innerHTML = state.tickers.map(t => {
const raw = state.raw[t], D = state.data[t];
let badge = 'ERR', bcls = 'ERR', nm = metaOf(t).name, dd = '';
if (raw && raw.error) { nm = 'data unavailable'; }
else if (D) { badge = D.an.verdict; bcls = D.an.verdict; dd = `${D.an.drawdown.toFixed(1)}%`; }
else { badge = '…'; bcls = 'ERR'; }
const hidden = D && !passFilter(t);
return `<div class="item ${hidden ? 'dim' : ''}" data-t="${t}">
<span class="eye" data-eye="${t}" title="focus">${state.selected===t?'◉':'○'}</span>
<span class="tk">${t}</span><span class="nm">${nm} ${dd?'· '+dd:''}</span>
<span class="vb ${bcls}">${badge}</span><span class="x" data-x="${t}">✕</span></div>`;
}).join('');
el.querySelectorAll('.item').forEach(it => it.onclick = e => {
const t = it.dataset.t;
if (e.target.dataset.x != null) return removeTicker(t);
if (state.data[t]) showDetail(t);
});
}
function addTicker(t) {
t = t.trim().toUpperCase().replace(/[^A-Z.\-]/g, '');
if (!t || state.tickers.includes(t)) return;
state.tickers.push(t); save(LS.tickers, state.tickers);
const inp = document.getElementById('add-tk'); inp.value = ''; inp.disabled = true;
fetchOne(t).then(r => {
inp.disabled = false;
state.raw[t] = r;
if (r.error) alertLine(`${t}: ${r.error}`);
process();
});
}
function removeTicker(t) {
state.tickers = state.tickers.filter(x => x !== t); save(LS.tickers, state.tickers);
delete state.raw[t]; delete state.data[t];
if (state.selected === t) clearSelect();
process();
}
function alertLine(msg) { const ml = document.getElementById('matchline'); ml.textContent = msg; ml.style.color = 'var(--ext)'; setTimeout(() => ml.style.color = '', 4000); }
// ============================ saved searches ============================
function renderSaved() {
const el = document.getElementById('saved');
el.innerHTML = state.saved.length ? state.saved.map((s, i) =>
`<div class="s" data-i="${i}">💾 ${s.name} <span style="color:#63769c;font-size:10px">${(s.tickers||[]).length} tkr</span><span class="x" data-del="${i}">✕</span></div>`).join('')
: `<div style="color:#63769c;font-size:11px">No saved screens yet.</div>`;
el.querySelectorAll('.s').forEach(row => row.onclick = e => {
const i = +row.dataset.i;
if (e.target.dataset.del != null) { state.saved.splice(i, 1); save(LS.saved, state.saved); return renderSaved(); }
loadSaved(state.saved[i]);
});
}
function saveSearch() {
const nameEl = document.getElementById('ss-name'); const name = nameEl.value.trim() || `screen ${state.saved.length + 1}`;
state.saved.push({ name, filters: { ...state.filters }, tfN: state.tfN, tickers: [...state.tickers] });
save(LS.saved, state.saved); nameEl.value = ''; renderSaved();
}
function loadSaved(s) {
state.filters = Object.assign({}, window.DEFAULT_FILTERS, s.filters);
state.tfN = s.tfN || 126;
if (s.tickers && s.tickers.length) state.tickers = [...s.tickers];
save(LS.filters, state.filters); save(LS.tf, state.tfN); save(LS.tickers, state.tickers);
syncFilterUI(); syncOptUI();
loadAll(); // re-fetch (ticker set may differ)
}
// preset strategies — one-click screens
function renderPresets() {
const el = document.getElementById('presets');
el.innerHTML = window.PRESETS.map((p, i) =>
`<div class="p" data-i="${i}" title="${p.desc}"><div class="pn">${p.name}</div><div class="pd">${p.desc}</div></div>`).join('');
el.querySelectorAll('.p').forEach(row => row.onclick = () => applyPreset(+row.dataset.i));
}
function applyPreset(i) {
const p = window.PRESETS[i];
state.filters = Object.assign({}, window.DEFAULT_FILTERS, state.filters ? { optMin: state.filters.optMin, optMax: state.filters.optMax, optMinMonths: state.filters.optMinMonths, optType: state.filters.optType } : {}, p.filters);
state.tfN = p.tfN || 126;
save(LS.filters, state.filters); save(LS.tf, state.tfN);
document.querySelectorAll('#presets .p').forEach((el, k) => el.classList.toggle('on', k === i));
syncFilterUI(); syncOptUI(); updateCriteriaChips(); process();
}
// ============================ filter UI wiring ============================
const SLIDERS = [
['weeksAgo', 'f-weeksAgo', 'v-weeksAgo', v => `${v} wks`, true],
['weeksTol', 'f-weeksTol', 'v-weeksTol', v => `${v} wks`, true],
['minDip', 'f-minDip', 'v-minDip', v => `${v}%`, true],
['maxFromHigh', 'f-maxFromHigh', 'v-maxFromHigh', v => `${v}%`, false],
['minPrice', 'f-minPrice', 'v-minPrice', v => `$${v}`, false],
];
const TOGGLES = [
['reqNewHigh', 'f-reqNewHigh'], ['dipTo50', 'f-dipTo50'], ['dipTo200', 'f-dipTo200'],
['below50', 'f-below50'], ['below200', 'f-below200'],
];
function syncFilterUI() {
for (const [key, id, vid, fmt] of SLIDERS) {
const el = document.getElementById(id); el.value = state.filters[key];
document.getElementById(vid).textContent = fmt(state.filters[key]);
}
for (const [key, id] of TOGGLES) document.getElementById(id).checked = !!state.filters[key];
}
let processQueued = false;
function scheduleProcess() { // coalesce a burst of slider events into 1 rebuild/frame
if (processQueued) return; processQueued = true;
requestAnimationFrame(() => { processQueued = false; process(); });
}
function wireFilters() {
for (const [key, id, vid, fmt] of SLIDERS) {
const el = document.getElementById(id);
el.oninput = () => {
state.filters[key] = +el.value; document.getElementById(vid).textContent = fmt(+el.value);
save(LS.filters, state.filters);
updateCriteriaChips();
scheduleProcess(); // every criterion recomputes ALL stocks, live
};
}
for (const [key, id] of TOGGLES) {
const el = document.getElementById(id);
el.onchange = () => { state.filters[key] = el.checked; save(LS.filters, state.filters); applyFilters(); renderWatchlist(); };
}
document.getElementById('reset-filters').onclick = () => {
state.filters = { ...window.DEFAULT_FILTERS }; save(LS.filters, state.filters);
document.querySelectorAll('#presets .p').forEach(el => el.classList.remove('on'));
syncFilterUI(); syncOptUI(); updateCriteriaChips(); process();
};
}
function updateCriteriaChips() {
document.getElementById('crit-weeks').textContent = `~${state.filters.weeksAgo} wks ago`;
document.getElementById('crit-dip').textContent = `≥${state.filters.minDip}%`;
}
function setLive(txt) { document.getElementById('live-txt').textContent = txt; }
// ============================ picking + controls ============================
const ray = new THREE.Raycaster(), mouse = new THREE.Vector2(); let downXY = null;
renderer.domElement.addEventListener('pointerdown', e => downXY = [e.clientX, e.clientY]);
renderer.domElement.addEventListener('pointerup', e => {
if (!downXY) return; const moved = Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]); downXY = null;
if (moved > 6) return;
mouse.x = (e.clientX / innerWidth) * 2 - 1; mouse.y = -(e.clientY / innerHeight) * 2 + 1;
ray.setFromCamera(mouse, camera);
const hit = ray.intersectObjects(laneMeshes, false)[0];
if (hit) showDetail(hit.object.userData.ticker);
});
document.getElementById('d-x').onclick = clearSelect;
document.getElementById('d-listtoggle').onclick = e => {
const tbl = document.getElementById('d-daily'); const open = tbl.classList.toggle('show');
e.target.textContent = (open ? '▾' : '▸') + ' Daily list (entry analysis)';
};
document.getElementById('burger').onclick = () => document.getElementById('left').classList.toggle('open');
document.getElementById('add-btn').onclick = () => addTicker(document.getElementById('add-tk').value);
document.getElementById('add-tk').addEventListener('keydown', e => { if (e.key === 'Enter') addTicker(e.target.value); });
document.getElementById('ss-save').onclick = saveSearch;
const spinBtn = document.getElementById('spin');
spinBtn.onclick = () => { controls.autoRotate = !controls.autoRotate; spinBtn.classList.toggle('on', controls.autoRotate); };
document.getElementById('reset').onclick = () => { camera.position.copy(camHome); controls.target.set(0, 3, 0); clearSelect(); };
document.getElementById('refresh').onclick = () => loadAll();
const volnormBtn = document.getElementById('volnorm');
const syncVolNorm = () => { volnormBtn.textContent = state.volNorm === 'global' ? '⬍ Vol: comparable' : '⬍ Vol: per-stock'; volnormBtn.classList.toggle('on', state.volNorm === 'global'); };
syncVolNorm();
volnormBtn.onclick = () => { state.volNorm = state.volNorm === 'global' ? 'per-stock' : 'global'; save(LS.volNorm, state.volNorm); syncVolNorm(); rebuildScene(); applyFilters(); };
document.querySelector('#legend3d .lg-title').onclick = () => {
const hidden = document.getElementById('lg-body').classList.toggle('hidden');
document.getElementById('lg-toggle').textContent = hidden ? '▸' : '▾';
};
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); drawBrush(); } }
});
// ============================ 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)';
const N = 12;
state.lastMatches = j.matches || [];
document.getElementById('scan-csv').style.display = state.lastMatches.length ? 'block' : 'none';
await autoLoadMatches(j.matches || [], N); // wait until the board actually has them
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>`;
enrichResultsOptions();
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 => {
state.caps[m.ticker] = m.cap; // remember cap for the small-cap warning
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>
<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));
}
// export the current scan matches to CSV
function exportCSV() {
const m = state.lastMatches || []; if (!m.length) return;
const cols = ['ticker', 'name', 'buyScore', 'price', 'drawdown', 'weeksAgoActual', 'rsi', 'volSurge', 'd50', 'd200', 'zoneLabel'];
const esc = v => `"${String(v == null ? '' : v).replace(/"/g, '""')}"`;
const csv = [cols.join(',')].concat(m.map(r => cols.map(c => esc(r[c])).join(','))).join('\n');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
a.download = `vshape-scan-${new Date().toISOString().slice(0, 10)}.csv`;
a.click(); URL.revokeObjectURL(a.href);
}
// 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) {
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>`; }
}
// ============================ score backtest (does it actually work?) ============================
let btPollTimer = null;
async function loadBacktest() {
const box = document.getElementById('d-backtest');
box.innerHTML = '<div style="color:#6f83a8">running the backtest (44 names × 2yr)…</div>';
clearTimeout(btPollTimer);
const poll = async () => {
try {
const j = await (await fetch('/api/backtest')).json();
if (j.status !== 'ready') { box.innerHTML = '<div style="color:#6f83a8">building the backtest… a few seconds</div>'; btPollTimer = setTimeout(poll, 3000); return; }
renderBacktest(j);
} catch (e) { box.innerHTML = `<div style="color:var(--ext)">backtest error: ${e.message}</div>`; }
};
poll();
}
function renderBacktest(j) {
const box = document.getElementById('d-backtest');
const pct = x => (x >= 0 ? '+' : '') + (x * 100).toFixed(1) + '%';
const cc = v => v >= 0.4 ? 'up' : v <= -0.4 ? 'down' : '';
const L = j.large, S = j.small;
// headline: score→return correlation BY CAP TIER
let html = `<div style="color:#6f83a8;margin:2px 0 4px">does a higher score → higher forward return? (correlation by market-cap tier)</div>`;
html += `<table class="daily show" style="font-size:11px"><tr><th>cap tier</th>${L.horizons.map(h => `<th>${h.H}d</th>`).join('')}</tr>`;
html += `<tr><td>large-cap (${L.tickers})</td>${L.horizons.map(h => `<td class="${cc(h.corr)}">${h.corr}</td>`).join('')}</tr>`;
html += `<tr><td>small-cap (${S.tickers})</td>${S.horizons.map(h => `<td class="${cc(h.corr)}">${h.corr}</td>`).join('')}</tr></table>`;
// large-cap band detail (where the signal works)
const BANDS = [[0, 19], [20, 39], [40, 59], [60, 79], [80, 100]];
html += `<div style="color:#6f83a8;margin:8px 0 3px">large-cap forward return by score band (the tier where it works)</div>`;
html += `<table class="daily show" style="font-size:11px"><tr><th>band</th>${L.horizons.map(h => `<th>${h.H}d</th>`).join('')}</tr>`;
html += `<tr><td style="color:var(--dim)">baseline</td>${L.horizons.map(h => `<td style="color:var(--dim)">${pct(h.baseline)}</td>`).join('')}</tr>`;
for (const [lo, hi] of BANDS) {
html += `<tr><td>${lo}-${hi}</td>${L.horizons.map(h => { const b = h.bands.find(x => x.lo === lo); if (!b || b.n < 15) return `<td style="color:#4a5877">·</td>`;
return `<td class="${b.mret > h.baseline ? 'up' : 'down'}" title="n=${b.n}, win ${(b.win * 100).toFixed(0)}%">${pct(b.mret)}</td>`; }).join('')}</tr>`;
}
html += `</table>`;
const l120 = (L.horizons.find(h => h.H === 120) || {}).corr, s120 = (S.horizons.find(h => h.H === 120) || {}).corr;
html += `<div style="font-size:11px;margin-top:6px;line-height:1.5"><b style="color:var(--newhigh)">Read:</b>
the setup score works on <b class="up">large-caps (120d corr ${l120})</b> but <b class="down">INVERTS on speculative small-caps (120d ${s120})</b>.
Trust the score on quality large-caps as a ~6-month signal; treat a high score on a small-cap with skepticism. (Recent 2yr; not risk-adjusted.)</div>`;
box.innerHTML = html;
}
function wireScanAndOptions() {
document.getElementById('scan-btn').onclick = startScan;
document.getElementById('go-scan').onclick = startScan;
document.getElementById('scan-csv').onclick = exportCSV;
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); };
document.getElementById('d-bttoggle').onclick = loadBacktest;
// volume-by-price layer toggle
const vlt = document.getElementById('vol-layer-toggle');
const syncVlt = () => { vlt.style.opacity = state.volLayer ? '1' : '0.4'; };
syncVlt();
vlt.onclick = () => {
state.volLayer = !state.volLayer; save(LS.volLayer, state.volLayer); syncVlt();
if (state.detailD) drawPriceChart(state.detailD);
};
// reset buttons
document.getElementById('reset-watchlist').onclick = () => {
state.tickers = window.SEED.map(s => s.ticker); save(LS.tickers, state.tickers);
if (state.selected && !state.tickers.includes(state.selected)) clearSelect();
loadAll();
};
document.getElementById('reset-all').onclick = () => {
state.filters = { ...window.DEFAULT_FILTERS }; state.tfN = 126; state.tickers = window.SEED.map(s => s.ticker);
save(LS.filters, state.filters); save(LS.tf, state.tfN); save(LS.tickers, state.tickers);
document.querySelectorAll('#presets .p').forEach(el => el.classList.remove('on'));
document.getElementById('scan-results').innerHTML = ''; document.getElementById('scan-prog').innerHTML = '';
clearSelect(); syncFilterUI(); syncOptUI(); updateCriteriaChips();
loadAll();
};
}
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(); syncOptUI(); wireFilters(); wireScanAndOptions(); wireBrush(); renderSaved(); renderPresets(); updateCriteriaChips();
loadAll();
let t0 = performance.now();
(function loop(now) {
requestAnimationFrame(loop);
const k = Math.min(1, (now - t0) / 1100), ease = 1 - Math.pow(1 - k, 3);
for (const gr of groups) gr.group.scale.y = ease;
for (const gr of groups) gr.group.traverse(o => { if (o.userData && o.userData.ring) o.lookAt(camera.position); });
controls.update(); renderer.render(scene, camera);
})(t0);