← back to Stock Vshape Viewer

public/screen.js

204 lines

/* screen.js — the ONE analysis engine, shared by the Node server (require)
 * and the browser (<script> -> window.SCREEN). Keeping it in one file means
 * the universe scan and the on-screen chart can never disagree. */
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.SCREEN = factory();
})(typeof self !== 'undefined' ? self : this, function () {

  function sma(closes, n) {
    const out = new Array(closes.length).fill(null);
    let sum = 0;
    for (let i = 0; i < closes.length; i++) {
      sum += closes[i];
      if (i >= n) sum -= closes[i - n];
      if (i >= n - 1) out[i] = sum / n;
    }
    return out;
  }

  // Wilder's RSI(14), aligned to input length (null until seeded)
  function rsi(closes, period = 14) {
    const out = new Array(closes.length).fill(null);
    if (closes.length < period + 1) return out;
    let gain = 0, loss = 0;
    for (let i = 1; i <= period; i++) { const d = closes[i] - closes[i - 1]; if (d >= 0) gain += d; else loss -= d; }
    gain /= period; loss /= period;
    out[period] = loss === 0 ? 100 : 100 - 100 / (1 + gain / loss);
    for (let i = period + 1; i < closes.length; i++) {
      const d = closes[i] - closes[i - 1], g = d > 0 ? d : 0, l = d < 0 ? -d : 0;
      gain = (gain * (period - 1) + g) / period; loss = (loss * (period - 1) + l) / period;
      out[i] = loss === 0 ? 100 : 100 - 100 / (1 + gain / loss);
    }
    return out;
  }

  // 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,
  };

  // Full parametric analysis of one stock's windowed daily series.
  function analyze(days, ma50, ma200, opts) {
    const n = days.length, f = Object.assign({}, DEFAULTS, opts || {});
    const recentWin = Math.max(5, Math.min(10, Math.floor(n * 0.09)));

    let rh = { price: -Infinity, i: n - 1 };
    for (let i = n - recentWin; i < n; i++) if (days[i].high > rh.price) rh = { price: days[i].high, date: days[i].date, i };

    const back = Math.round(f.weeksAgo * 5), tol = Math.round(f.weeksTol * 5);
    let lo = Math.max(0, (n - 1) - back - tol), hi = Math.min(n - recentWin - 1, (n - 1) - back + tol);
    if (hi < lo) { lo = 0; hi = Math.max(0, n - recentWin - 1); }
    let eh = { price: -Infinity, i: lo };
    for (let i = lo; i <= hi; i++) if (days[i].high > eh.price) eh = { price: days[i].high, date: days[i].date, i };
    const weeksAgoActual = Math.round(((n - 1) - eh.i) / 5);

    const a = Math.min(eh.i, rh.i), b = Math.max(eh.i, rh.i);
    let tr = { price: Infinity, i: a };
    for (let i = a; i <= b; i++) if (days[i].low < tr.price) tr = { price: days[i].low, date: days[i].date, i };

    const drawdown = eh.price > 0 ? ((tr.price - eh.price) / eh.price) * 100 : 0;
    const newHigh = rh.price >= eh.price, dipOK = drawdown <= -f.minDip, fits = dipOK && newHigh;

    let dipTo50 = false, dipTo200 = false;
    for (let i = a; i <= b; i++) {
      if (ma50[i] != null && days[i].low <= ma50[i] * 1.02) dipTo50 = true;
      if (ma200[i] != null && days[i].low <= ma200[i] * 1.03) dipTo200 = true;
    }

    const closes = days.map(d => d.close);
    const rsiArr = rsi(closes, 14);
    const rsiNow = rsiArr[n - 1];
    const vols = days.map(d => d.vol || 0);
    let vsum = 0, vc = 0; for (let i = Math.max(0, n - 20); i < n; i++) { vsum += vols[i]; vc++; }
    const volAvg = vc ? vsum / vc : 0;
    const volSurge = volAvg ? vols[n - 1] / volAvg : 1;

    // 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;
    const pctFromHigh = rh.price > 0 ? ((rh.price - C) / rh.price) * 100 : 0;

    const near = (d) => d != null && Math.abs(d) <= 3;
    let zoneCls = 'neu', zoneLabel = 'Neutral';
    if (d200 != null && C <= m200 * 1.02) { zoneCls = 'buy2'; zoneLabel = 'At / below 200-DMA — deep buy zone'; }
    else if (d50 != null && C <= m50 * 1.02 && C >= m50 * 0.90) { zoneCls = 'buy'; zoneLabel = 'At / below 50-DMA — buy zone'; }
    else if (near(d50)) { zoneCls = 'watch'; zoneLabel = 'Testing 50-DMA'; }
    else if (d50 != null && d50 > 12) { zoneCls = 'ext'; zoneLabel = 'Extended above 50-DMA — wait for pullback'; }

    // composite BUY SCORE 0-100 (a heuristic "worth a look" rank, not advice).
    // DTD 7/8-B rework: dip-depth reward is GATED on newHigh (reward a RECLAIMED deep
    // dip, never a still-falling knife); redundant new-high +8 removed (it duplicated
    // the fits gate and didn't change the ranking of surfaced candidates).
    let score = 0; const parts = [];
    if (fits) { score += 30; parts.push({ k: 'V-shape fit', v: 30 }); }
    if (newHigh) { const dd = Math.round(Math.min(20, Math.max(0, Math.abs(drawdown) - f.minDip))); if (dd) { score += dd; parts.push({ k: 'dip depth', v: dd }); } }
    if (rsiNow != null) { let r = 0; if (rsiNow >= 40 && rsiNow <= 60) r = 15; else if (rsiNow < 40) r = 12; else if (rsiNow > 70) r = -10; if (r) { score += r; parts.push({ k: 'RSI zone', v: r }); } }
    let z = 0; if (zoneCls === 'buy2') z = 22; else if (zoneCls === 'buy') z = 18; else if (zoneCls === 'watch') z = 10; else if (zoneCls === 'ext') z = -6; if (z) { score += z; parts.push({ k: 'entry vs MA', v: z }); }
    if (volSurge > 1.3) { score += 5; parts.push({ k: 'volume surge', v: 5 }); }
    score = Math.max(0, Math.min(100, Math.round(score)));

    let why;
    if (fits) why = `High ~${weeksAgoActual} wks ago, dipped ${drawdown.toFixed(1)}%, reclaimed a new high.`;
    else if (!dipOK && newHigh) why = `New high, but dip was only ${drawdown.toFixed(1)}% (< ${f.minDip}%).`;
    else if (dipOK && !newHigh) why = `Dipped ${drawdown.toFixed(1)}% but no new high yet (lower high).`;
    else why = `No clean ${f.minDip}% dip + new-high sequence in this window.`;

    return {
      eh, tr, rh, recentWin, weeksAgoActual, drawdown, newHigh, dipOK, fits,
      verdict: fits ? 'PASS' : 'FAIL', why, dipTo50, dipTo200, below50, below200,
      C, m50, m200, d50, d200, pctFromHigh, zoneCls, zoneLabel,
      rsi: rsiNow == null ? null : Math.round(rsiNow), volSurge: +volSurge.toFixed(2), buyScore: score, scoreParts: parts,
      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),
    };
  }

  function passFilter(an, f) {
    if (!an) return false;
    if (an.drawdown > -f.minDip) return false;
    if (an.pctFromHigh > f.maxFromHigh) return false;
    if (an.C < f.minPrice) return false;
    if (f.reqNewHigh && !an.newHigh) return false;
    if (f.dipTo50 && !an.dipTo50) return false;
    if (f.dipTo200 && !an.dipTo200) return false;
    if (f.below50 && !an.below50) return false;
    if (f.below200 && !an.below200) return false;
    return true;
  }

  // OCC symbol -> {exp,type,strike}. e.g. NVDA261218C00250000
  function parseOCC(sym) {
    const m = sym.match(/^([A-Z]+)(\d{2})(\d{2})(\d{2})([CP])(\d{8})$/);
    if (!m) return null;
    return { exp: `20${m[2]}-${m[3]}-${m[4]}`, type: m[5] === 'C' ? 'call' : 'put', strike: +m[6] / 1000 };
  }

  // Screen an options chain for cheap, far-dated contracts.
  // chain = [{exp,type,strike,bid,ask,last,oi,vol,delta}], nowMs = Date.now()
  function screenOptions(chain, nowMs, o) {
    const min = o.min ?? 0.05, max = o.max ?? 0.15, minDays = o.minDays ?? 90, type = o.type || 'both';
    const und = o.underlying || 0;
    const out = [];
    for (const c of chain) {
      if (type !== 'both' && c.type !== (type === 'calls' ? 'call' : 'put')) continue;
      const days = Math.round((new Date(c.exp + 'T00:00:00Z') - nowMs) / 86400000);
      if (days < minDays) continue;
      const mid = (c.bid > 0 && c.ask > 0) ? (c.bid + c.ask) / 2 : (c.last || 0);
      if (!(mid >= min && mid <= max)) continue;
      const be = c.type === 'call' ? c.strike + mid : c.strike - mid;
      const otm = und ? (c.type === 'call' ? (c.strike - und) / und : (und - c.strike) / und) * 100 : null;
      out.push({ type: c.type, strike: c.strike, exp: c.exp, days, mid: +mid.toFixed(2),
        bid: c.bid, ask: c.ask, last: c.last, oi: c.oi || 0, vol: c.vol || 0, delta: c.delta,
        breakeven: +be.toFixed(2), otmPct: otm == null ? null : +otm.toFixed(1),
        cost: Math.round(mid * 100) }); // 1 contract = 100 shares
    }
    // most tradeable first: open interest, then soonest-qualifying, then cheapest
    out.sort((a, b) => (b.oi - a.oi) || (a.days - b.days) || (a.mid - b.mid));
    return out;
  }

  return { sma, rsi, analyze, passFilter, parseOCC, screenOptions, DEFAULTS };
});