← back to Stock Vshape Viewer
public/index.html
331 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Stock V-Shape · Live 3D Screener</title>
<style>
:root{
--bg:#070b14; --panel:rgba(14,20,34,.92); --stroke:#1e2a44;
--ink:#e8eefc; --dim:#8ea3c8; --pass:#39d98a; --fail:#ff5c7a;
--high:#39d98a; --trough:#ff5c7a; --newhigh:#ffd54a; --ma50:#5cc8ff; --ma200:#c792ff;
--buy:#39d98a; --buy2:#2ee6c8; --watch:#ffd54a; --ext:#ff9f43; --neu:#8ea3c8;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);overflow:hidden;
font:14px/1.45 -apple-system,BlinkMacSystemFont,"SF Pro Text",Segoe UI,Roboto,sans-serif}
#app{position:fixed;inset:0}
canvas{display:block}
.glass{background:var(--panel);border:1px solid var(--stroke);border-radius:14px;
backdrop-filter:blur(10px);box-shadow:0 12px 40px rgba(0,0,0,.45)}
button{font-family:inherit}
.btn{background:#101a30;border:1px solid var(--stroke);color:var(--ink);border-radius:9px;
padding:7px 11px;font-size:12px;cursor:pointer}
.btn:hover{border-color:#3a568f}.btn.on{background:#1c2c50;border-color:#4a6bb5}
/* ===== top criteria bar ===== */
#topbar{position:fixed;top:0;left:0;right:0;height:52px;z-index:30;display:flex;align-items:center;
gap:14px;padding:0 14px 0 62px;background:linear-gradient(180deg,rgba(10,15,26,.97),rgba(10,15,26,.82));
border-bottom:1px solid var(--stroke);backdrop-filter:blur(8px)}
#topbar .brand{font-weight:700;font-size:14px;white-space:nowrap}
#topbar .brand span{color:var(--dim);font-weight:400}
.crit{display:flex;gap:9px;flex:1;overflow-x:auto;scrollbar-width:none}
.crit::-webkit-scrollbar{display:none}
.cchip{display:flex;align-items:center;gap:6px;padding:5px 10px;border:1px solid var(--stroke);
border-radius:20px;font-size:11.5px;white-space:nowrap;color:var(--dim);background:rgba(255,255,255,.02)}
.cchip b{color:var(--ink);font-weight:600}
.cchip .num{display:inline-flex;width:16px;height:16px;border-radius:50%;background:#22345c;color:#cfe0ff;
align-items:center;justify-content:center;font-size:10px;font-weight:700}
#live{font-size:11px;color:var(--dim);white-space:nowrap;display:flex;align-items:center;gap:6px}
#live .dotlive{width:8px;height:8px;border-radius:50%;background:var(--pass);box-shadow:0 0 8px var(--pass);
animation:pulse 1.6s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
/* ===== hamburger + left panel ===== */
#burger{position:fixed;top:9px;left:12px;z-index:40;width:38px;height:34px;border-radius:9px;
background:#101a30;border:1px solid var(--stroke);color:var(--ink);cursor:pointer;font-size:16px}
#burger:hover{border-color:#4a6bb5}
#left{position:fixed;top:52px;left:0;bottom:0;width:308px;z-index:25;transform:translateX(-100%);
transition:transform .22s ease;display:flex;flex-direction:column;
background:var(--panel);border-right:1px solid var(--stroke);backdrop-filter:blur(10px)}
#left.open{transform:translateX(0)}
#left .scroll{overflow-y:auto;padding:12px;flex:1}
.sec{margin-bottom:16px}
.sec h3{margin:0 0 8px;font-size:11px;letter-spacing:.6px;text-transform:uppercase;color:var(--dim)}
.addrow{display:flex;gap:6px}
.addrow input{flex:1;background:#0c1424;border:1px solid var(--stroke);color:var(--ink);border-radius:8px;
padding:8px 10px;font-size:13px;text-transform:uppercase}
.wl{display:flex;flex-direction:column;gap:5px;margin-top:8px}
.wl .item{display:flex;align-items:center;gap:8px;padding:7px 9px;border-radius:8px;background:rgba(255,255,255,.03)}
.wl .item.dim{opacity:.4}
.wl .tk{font-weight:700;width:52px;font-size:13px}
.wl .item .nm{flex:1;min-width:0;font-size:11.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.wl .vb{font-size:9.5px;font-weight:700;padding:2px 5px;border-radius:5px}
.wl .vb.PASS{background:rgba(57,217,138,.16);color:var(--pass)}
.wl .vb.FAIL{background:rgba(255,92,122,.16);color:var(--fail)}
.wl .vb.ERR{background:rgba(255,159,67,.16);color:var(--ext)}
.wl .x{cursor:pointer;color:#63769c;font-size:14px}.wl .x:hover{color:var(--fail)}
.wl .eye{cursor:pointer;font-size:13px;opacity:.8}
.fld{margin-bottom:12px}
.fld label{display:flex;justify-content:space-between;font-size:11.5px;color:var(--dim);margin-bottom:4px}
.fld label b{color:var(--ink)}
.fld input[type=range]{width:100%;accent-color:#5c7cff}
.segs{display:flex;gap:4px}
.segs button{flex:1;padding:5px 0;font-size:10.5px;background:#0c1424;border:1px solid var(--stroke);
color:var(--dim);border-radius:7px;cursor:pointer}
.segs button.on{background:#1c2c50;border-color:#4a6bb5;color:var(--ink)}
.chk{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dim);margin-bottom:9px;cursor:pointer}
.chk input{accent-color:#5c7cff;width:15px;height:15px}
.ssrow{display:flex;gap:6px;margin-bottom:8px}
.ssrow input{flex:1;background:#0c1424;border:1px solid var(--stroke);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
.saved{display:flex;flex-direction:column;gap:5px}
.saved .s{display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:8px;background:rgba(255,255,255,.03);font-size:12px;cursor:pointer}
.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}
.btn.go{width:100%;margin-top:8px;padding:11px;font-weight:700;font-size:13px;
background:linear-gradient(180deg,#1c5e3a,#124029);border-color:#2f8f5a;color:#d8ffe9}
.btn.go:hover{border-color:#39d98a;box-shadow:0 0 14px rgba(57,217,138,.25)}
.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}
#hint{position:fixed;bottom:60px;left:50%;transform:translateX(-50%);color:var(--dim);font-size:12px;z-index:5;pointer-events:none;opacity:.8}
#legend3d{position:fixed;bottom:14px;left:14px;z-index:12;padding:8px 10px;font-size:10.5px;color:var(--dim);opacity:.9}
#legend3d .lg-title{font-weight:700;color:var(--ink);font-size:11px;margin-bottom:4px;cursor:pointer;user-select:none}
#legend3d #lg-body{display:flex;flex-direction:column;gap:3px}
#legend3d #lg-body.hidden{display:none}
#legend3d .sw{display:inline-block;width:9px;height:9px;border-radius:2px;vertical-align:middle;margin-right:2px}
#legend3d .ln{display:inline-block;width:13px;height:2px;vertical-align:middle;margin-right:2px}
#legend3d .glowdot{display:inline-block;width:9px;height:9px;border-radius:50%;background:#fff;box-shadow:0 0 5px #9fd8ff;vertical-align:middle;margin-right:2px}
/* ===== detail panel ===== */
#detail{position:fixed;bottom:14px;right:14px;width:min(470px,46vw);max-height:calc(100vh - 80px);
overflow:auto;padding:14px;z-index:20;display:none}
#detail.show{display:block}
#detail .dh{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
#detail .dh b{font-size:16px}#detail .dh .x{cursor:pointer;color:var(--dim);font-size:18px}
#detail .sub{color:var(--dim);font-size:12px;margin:2px 0 8px}
.zone{display:inline-block;padding:4px 10px;border-radius:8px;font-size:12px;font-weight:600;margin-bottom:8px}
.zone.buy{background:rgba(57,217,138,.16);color:var(--buy)}
.zone.buy2{background:rgba(46,230,200,.16);color:var(--buy2)}
.zone.watch{background:rgba(255,213,74,.16);color:var(--watch)}
.zone.ext{background:rgba(255,159,67,.16);color:var(--ext)}
.zone.neu{background:rgba(142,163,200,.14);color:var(--neu)}
.tfrow{display:flex;gap:5px;margin:2px 0 8px}
.tfrow button{flex:1;padding:5px 0;font-size:11px;background:#0c1424;border:1px solid var(--stroke);
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}
.presets{display:flex;flex-direction:column;gap:4px}
.presets .p{padding:7px 9px;border-radius:8px;background:rgba(255,255,255,.03);cursor:pointer;border:1px solid transparent}
.presets .p:hover{background:rgba(87,140,255,.12);border-color:#2f4d8f}
.presets .p.on{background:rgba(87,140,255,.16);border-color:#4a6bb5}
.presets .p .pn{font-size:12px;font-weight:600}
.presets .p .pd{font-size:10px;color:var(--dim);margin-top:1px}
#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}
.kpis{display:flex;gap:12px;flex-wrap:wrap;margin:6px 0}
.kpi{font-size:11px;color:var(--dim)}.kpi b{display:block;font-size:13.5px;color:var(--ink)}
.kpi.high b{color:var(--high)}.kpi.trough b{color:var(--trough)}.kpi.newhigh b{color:var(--newhigh)}
.scoreparts{display:flex;flex-wrap:wrap;gap:5px;align-items:center;margin:2px 0 6px;font-size:10.5px;color:var(--dim)}
.scoreparts .sp{padding:2px 7px;border-radius:6px;font-weight:600}
.scoreparts .sp.pos{background:rgba(57,217,138,.14);color:var(--pass)}
.scoreparts .sp.neg{background:rgba(255,92,122,.14);color:var(--fail)}
.listtoggle{margin:8px 0 4px;cursor:pointer;color:#8fb2ff;font-size:12px}
table.daily{width:100%;border-collapse:collapse;font-size:11px;display:none}
table.daily.show{display:table}
table.daily th,table.daily td{padding:4px 6px;text-align:right;border-bottom:1px solid #141d30}
table.daily th{color:var(--dim);position:sticky;top:0;background:var(--panel)}
table.daily td:first-child,table.daily th:first-child{text-align:left}
.up{color:var(--pass)}.down{color:var(--fail)}
.vnote{color:#6f83a8;font-size:10.5px;margin-top:6px}
</style>
</head>
<body>
<div id="app"></div>
<button id="burger" title="Watchlist · filters · saved searches">☰</button>
<div id="topbar">
<div class="brand">📈 V-Shape <span>screener</span></div>
<div class="crit">
<span class="cchip"><i class="num">1</i>Earlier high <b id="crit-weeks">~6 wks ago</b></span>
<span class="cchip"><i class="num">2</i>Dip <b id="crit-dip">≥10%</b> in between</span>
<span class="cchip"><i class="num">3</i><b>Fresh new high</b> now</span>
<span class="cchip">Entry: pullback to <b style="color:var(--ma50)">50-DMA</b> / <b style="color:var(--ma200)">200-DMA</b></span>
</div>
<div id="live"><span class="dotlive"></span><span id="live-txt">loading live prices…</span></div>
</div>
<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>
<button class="btn" id="scan-csv" style="width:100%;margin-top:6px;display:none">⬇ Export matches to CSV</button>
</div>
<div class="sec">
<h3>Preset strategies</h3>
<div class="presets" id="presets"></div>
</div>
<div class="sec">
<h3>Watchlist · add a stock</h3>
<div class="addrow">
<input id="add-tk" placeholder="ticker e.g. AAPL" maxlength="8" />
<button class="btn" id="add-btn">+ Add</button>
</div>
<div class="wl" id="wl"></div>
<div style="display:flex;gap:6px;margin-top:8px">
<button class="btn" id="reset-watchlist" style="flex:1">↺ Watchlist</button>
<button class="btn" id="reset-all" style="flex:1">↺ Reset all</button>
</div>
</div>
<div class="sec">
<h3>Screen criteria — all adjustable</h3>
<div class="fld"><label>New high was <b id="v-weeksAgo">6 wks</b> ago</label>
<input type="range" id="f-weeksAgo" min="1" max="16" step="1" value="6"></div>
<div class="fld"><label>± window <b id="v-weeksTol">3 wks</b></label>
<input type="range" id="f-weeksTol" min="1" max="8" step="1" value="3"></div>
<div class="fld"><label>Min dip from high <b id="v-minDip">10%</b></label>
<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>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>
<label class="chk"><input type="checkbox" id="f-below50"> Currently below 50-DMA</label>
<label class="chk"><input type="checkbox" id="f-below200"> Currently below 200-DMA</label>
<div class="matchline" id="matchline"></div>
<button class="btn" id="reset-filters">Reset filters</button>
<button class="btn go" id="go-scan">▶ GO — scan with these 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">
<button class="btn" id="ss-save">Save</button></div>
<div class="saved" id="saved"></div>
</div>
</div>
</div>
<div id="ctl" class="glass">
<button class="btn on" id="spin">⟳ Auto-rotate</button>
<button class="btn" id="reset">⤢ Reset view</button>
<button class="btn" id="volnorm" title="volume-thickness scale: per-stock (each ribbon vs its own peak) or comparable (all ribbons on one scale)">⬍ Vol: per-stock</button>
<button class="btn" id="refresh">↻ Refresh prices</button>
</div>
<div id="hint">drag to orbit · scroll to zoom · click a layer for its chart · ☰ to screen</div>
<div id="legend3d" class="glass">
<div class="lg-title">3D key <span id="lg-toggle">▸</span></div>
<div id="lg-body" class="hidden">
<div><b>height</b> = price · <b>thicker + brighter</b> = more volume</div>
<div><i class="sw" style="background:var(--pass)"></i>PASS <i class="sw" style="background:var(--fail)"></i>fails screen</div>
<div><span class="glowdot"></span> highest-volume day</div>
<div><i class="sw" style="background:var(--high)"></i>high <i class="sw" style="background:var(--trough)"></i>trough <i class="sw" style="background:var(--newhigh)"></i>recent high</div>
<div><i class="ln" style="background:var(--ma50)"></i>50-DMA <i class="ln" style="background:var(--ma200)"></i>200-DMA</div>
</div>
</div>
<div id="detail" class="glass">
<div class="dh"><b id="d-tk"></b><span class="x" id="d-x">✕</span></div>
<div class="sub" id="d-sub"></div>
<div class="zone" id="d-zone"></div>
<div id="d-why" style="font-size:12px;color:var(--dim);margin-bottom:8px"></div>
<div class="tfrow" id="d-tf"></div>
<canvas id="chart"></canvas>
<div class="malegend">
<span><i style="background:var(--ma50)"></i>50-DMA</span>
<span><i style="background:var(--ma200)"></i>200-DMA</span>
<span><i style="background:var(--high)"></i>high</span>
<span><i style="background:var(--trough)"></i>trough</span>
<span><i style="background:var(--newhigh)"></i>recent high</span>
<span id="vol-layer-toggle" style="cursor:pointer;margin-left:auto;color:#5c8fd6;font-weight:600" title="toggle the volume-by-price layer">◧ Vol layer</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="scoreparts" id="d-scoreparts"></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="listtoggle" id="d-bttoggle" style="color:#ffd54a">▸ Score backtest — does the buy-score actually work?</div>
<div id="d-backtest"></div>
<div class="vnote" id="d-vnote"></div>
</div>
<script>
/* creds-in-URL fetch guard (fleet pattern): if the page is opened as
http://user:pass@host/ the baseURI carries credentials and relative
fetch() throws. Resolve root-relative URLs against the clean origin. */
(function () {
var of = window.fetch.bind(window);
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && input.charAt(0) === '/') input = location.origin + input;
else if (input && typeof input.url === 'string' && input.url.charAt(0) === '/')
input = new Request(location.origin + input.url, input);
} catch (e) {}
return of(input, init);
};
})();
</script>
<script src="screen.js"></script>
<script src="data.js"></script>
<script type="importmap">
{ "imports": {
"three": "/vendor/three/three.module.js",
"three/addons/": "/vendor/three/addons/"
}}
</script>
<script type="module" src="app.js"></script>
</body>
</html>